@getstrata/starter 1.0.8 → 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;
1553
+ })),
1554
+ },` : "";
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), "/");
1410
1613
  })),
1411
1614
  },` : "";
1412
- const apiRoutes = jsonApi ? `
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.";
1689
+ if ((process.env.FEATURE_REGISTRATION ?? "true") === "false") {
1690
+ return new Response("Not found", { status: 404 });
1475
1691
  }
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.";
1484
- }
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,13 +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";
1946
+ import { pingDatabase } from "../../bootstrap/database.ts";
1947
+ import { Note } from "../../models/Note.ts";
1704
1948
  import { plainText, renderPage } from "../../lib/view.ts";
1705
1949
 
1706
- // Proves the database answers and the schema is migrated. Point it at a table your app owns.
1707
1950
  async function schemaReady(): Promise<boolean> {
1708
1951
  try {
1709
- await getSql().unsafe("SELECT 1 FROM notes LIMIT 1");
1952
+ await Note.query().limit(1).get();
1710
1953
  return true;
1711
1954
  } catch {
1712
1955
  return false;
@@ -1911,6 +2154,7 @@ function renderLayout(layers, projectName) {
1911
2154
  <meta name="description" content="<%= it.layout.description %>" />
1912
2155
  <% } %>
1913
2156
  <link rel="stylesheet" href="/assets/site.css" />
2157
+ <script src="/assets/htmx.min.js"></script>
1914
2158
  </head>
1915
2159
  <body>
1916
2160
  <header class="site-header">
@@ -1939,7 +2183,7 @@ function renderHomeView(projectName, layers) {
1939
2183
  <a class="button" href="/register">Create account</a>
1940
2184
  <a class="button button-secondary" href="/login">Sign in</a>
1941
2185
  </div>
1942
- <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>
1943
2187
  <% } else { %>
1944
2188
  <p class="lede">You are signed in as <strong><%= it.currentUser.email %></strong>.</p>
1945
2189
  <p>Add routes in <code>src/modules</code>. This homepage is yours to restyle.</p>
@@ -2015,6 +2259,9 @@ function renderVerifyEmailView() {
2015
2259
  function renderMfaChallengeView() {
2016
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"');
2017
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
+ }
2018
2265
  function renderMfaSetupView() {
2019
2266
  return `<section class="auth-card">
2020
2267
  <h1>Authenticator app</h1>
@@ -2050,111 +2297,105 @@ function renderAuthDirectory(layers) {
2050
2297
  return null;
2051
2298
  }
2052
2299
  const hashed = hashApiToken(token);
2053
- const rows = await getSql().unsafe<
2054
- {
2055
- id: number;
2056
- user_id: number;
2057
- abilities: string;
2058
- expires_at: Date | string | null;
2059
- role?: string;
2060
- is_admin?: number | boolean;
2061
- email_verified_at?: Date | string | null;
2062
- }
2063
- >(
2064
- \`SELECT t.id, t.user_id, t.abilities, t.expires_at, u.is_admin, u.email_verified_at
2065
- FROM api_tokens t INNER JOIN users u ON u.id = t.user_id
2066
- WHERE t.token_hash = ?\`,
2067
- [hashed],
2068
- );
2069
- const row = rows[0];
2070
- if (!row) {
2300
+ const tokenRow = await runWithMigrationBypassForIdentifier(hashed, async () => {
2301
+ return await ApiToken.where({ token_hash: hashed }).first();
2302
+ });
2303
+ if (!tokenRow) {
2071
2304
  return null;
2072
2305
  }
2073
- 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) {
2074
2315
  return null;
2075
2316
  }
2076
2317
  let abilities: string[] = [];
2077
2318
  try {
2078
- abilities = JSON.parse(String(row.abilities ?? "[]")) as string[];
2319
+ abilities = JSON.parse(String(tokenRow.get("abilities") ?? "[]")) as string[];
2079
2320
  } catch {
2080
- abilities = ["profile:read"];
2321
+ abilities = [];
2081
2322
  }
2082
2323
  return {
2083
- id: Number(row.user_id),
2084
- role: row.is_admin ? "admin" : "member",
2324
+ id: user_id,
2325
+ role: mapRole(user.get("is_admin")),
2085
2326
  abilities,
2086
- tokenId: Number(row.id),
2087
- emailVerifiedAt: row.email_verified_at ?? null,
2327
+ tokenId: Number(tokenRow.get("id")),
2328
+ emailVerifiedAt: asTimestamp(user.get("email_verified_at")),
2088
2329
  };
2089
2330
  },` : `
2090
2331
  async resolveUserFromToken() {
2091
2332
  return null;
2092
2333
  },`;
2093
- const placeholder = layers.database === "postgres" ? "$1" : "?";
2094
2334
  const hashImport = authUsesToken(layers.auth) ? `import { hashApiToken } from "@getstrata/core/auth/tokenHash";
2095
2335
  ` : "";
2096
- const mfaSelect = layers.extras.mfa ? ", mfa_enabled, mfa_secret, mfa_recovery_codes" : "";
2097
- const mfaColumns = layers.extras.mfa ? `
2098
- mfa_enabled?: number | boolean;
2099
- mfa_secret?: string | null;
2100
- mfa_recovery_codes?: string | null;` : "";
2336
+ const apiTokenImport = authUsesToken(layers.auth) ? `import { ApiToken } from "../models/ApiToken.ts";
2337
+ ` : "";
2101
2338
  const mfaReturn = layers.extras.mfa ? `
2102
- mfa_enabled: row.mfa_enabled === true || row.mfa_enabled === 1,
2103
- mfa_secret: row.mfa_secret ?? null,
2104
- mfa_recovery_codes: row.mfa_recovery_codes ?? null,` : "";
2105
- 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"` : "";
2106
2350
  return `import type { AuthUser } from "@getstrata/core/auth/authContext";
2107
2351
  import { verifyPassword } from "@getstrata/core/auth/password";
2108
- ${hashImport}import type { AuthUserDirectory } from "@getstrata/core/contracts/authUserDirectory";
2109
- 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";
2110
2355
 
2111
- type UserRow = {
2112
- id: number;
2113
- name: string;
2114
- email: string;
2115
- is_admin: number | boolean;
2116
- email_verified_at: Date | string | null;
2117
- password: string;${mfaColumns}
2356
+ type LoadedUser = {
2357
+ get(key: "id" | "name" | "email" | "is_admin" | "email_verified_at" | "password" | "session_valid_after"${mfaKeys}): unknown;
2118
2358
  };
2119
2359
 
2120
2360
  function mapRole(isAdmin: unknown): string {
2121
2361
  return isAdmin === true || isAdmin === 1 || isAdmin === "1" ? "admin" : "member";
2122
2362
  }
2123
2363
 
2124
- 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 {
2125
2369
  return {
2126
- id: Number(row.id),
2127
- name: row.name,
2128
- email: row.email,
2129
- role: mapRole(row.is_admin),
2130
- email_verified_at: row.email_verified_at ?? null,
2131
- 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}
2132
2376
  };
2133
2377
  }
2134
2378
 
2135
2379
  async function findUserById(id: number) {
2136
- const rows = await getSql().unsafe<UserRow>(
2137
- "SELECT ${userColumns} FROM users WHERE id = ${placeholder}",
2138
- [id],
2139
- );
2140
- const row = rows[0];
2141
- if (!row) {
2142
- throw new Error(\`User \${id} not found.\`);
2143
- }
2144
- 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
+ });
2145
2387
  }
2146
2388
 
2147
2389
  async function findUserByEmail(email: string) {
2148
- const rows = await getSql().unsafe<UserRow>(
2149
- "SELECT ${userColumns} FROM users WHERE email = ${placeholder}",
2150
- [email.trim().toLowerCase()],
2151
- );
2152
- const row = rows[0];
2153
- 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
+ });
2154
2395
  }
2155
2396
 
2156
2397
  export const starterAuthDirectory: AuthUserDirectory = {
2157
- ${tokenLookup.replace("WHERE t.token_hash = ?", `WHERE t.token_hash = ${placeholder}`)}
2398
+ ${tokenLookup}
2158
2399
 
2159
2400
  findByIdOrThrow: findUserById,
2160
2401
 
@@ -2165,6 +2406,9 @@ ${tokenLookup.replace("WHERE t.token_hash = ?", `WHERE t.token_hash = ${placehol
2165
2406
  if (!user?.password || !(await verifyPassword(password, user.password))) {
2166
2407
  return null;
2167
2408
  }
2409
+ if ("mfa_enabled" in user && user.mfa_enabled) {
2410
+ return null;
2411
+ }
2168
2412
  return {
2169
2413
  id: user.id,
2170
2414
  role: user.role,
@@ -2183,6 +2427,11 @@ import type { ServiceProvider } from "@getstrata/core/contracts/di";
2183
2427
  import { envFlagEnabled } from "@getstrata/core/runtime/appEnv";
2184
2428
 
2185
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
+
2186
2435
  async resolve(request?: Request): Promise<AuthUser | null> {
2187
2436
  if (!envFlagEnabled(process.env.AUTH_DEV_HEADERS)) {
2188
2437
  return request ? null : currentAuthUser();
@@ -2228,13 +2477,13 @@ export default authProvider;
2228
2477
  role: user.is_admin ? "admin" : "member",
2229
2478
  ...(user.email_verified_at !== undefined ? { emailVerifiedAt: user.email_verified_at } : {}),
2230
2479
  }),
2231
- });` : ` const fallback = ${authUsesToken(layers.auth) ? "new DatabaseTokenGuard(container)" : "new JwtGuard()"};
2480
+ });` : ` const fallback = ${authUsesToken(layers.auth) ? "new DatabaseTokenGuard(container)" : "new JwtGuard(container)"};
2232
2481
  const auth = new AuthManager(fallback);`;
2233
2482
  const tokenRegs = authUsesToken(layers.auth) ? ` const apiGuard = new DatabaseTokenGuard(container);
2234
2483
  auth.registerGuard("api", apiGuard);
2235
2484
  auth.registerGuard("access_token", apiGuard);
2236
2485
  auth.registerGuard("token", apiGuard);` : "";
2237
- const jwtReg = authUsesJwt(layers.auth) ? ` auth.registerGuard("jwt", new JwtGuard());` : "";
2486
+ const jwtReg = authUsesJwt(layers.auth) ? ` auth.registerGuard("jwt", new JwtGuard(container));` : "";
2238
2487
  const basicReg = authUsesToken(layers.auth) || authUsesJwt(layers.auth) ? ` auth.registerGuard("basic", new BasicAuthGuard(container));` : "";
2239
2488
  const ability = authUsesToken(layers.auth) ? ` container.set(CORE_ABILITY_CHECKER_TOKEN, createTokenAbilityChecker());` : "";
2240
2489
  const imports = [`import { CORE_AUTH_TOKEN } from "@getstrata/bootstrap/config";`];
@@ -2294,18 +2543,75 @@ export default authProvider;
2294
2543
  function envFlag(value) {
2295
2544
  return value ? "true" : "false";
2296
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";
2297
2550
  function appDatabaseName(projectName) {
2298
2551
  return projectName.replace(/[^A-Za-z0-9_]/g, "_");
2299
2552
  }
2553
+ function usesComposePostgres(layers) {
2554
+ return selectedDockerServices(layers).includes("postgres");
2555
+ }
2300
2556
  function defaultDatabaseUrl(layers, projectName) {
2301
2557
  if (layers.database === "sqlite") {
2302
2558
  return "sqlite:./storage/app.sqlite";
2303
2559
  }
2304
2560
  const database = appDatabaseName(projectName);
2305
2561
  if (layers.database === "mysql") {
2306
- return `mysql://root:root@localhost:3306/${database}`;
2562
+ return `mysql://root:${GENERATED_MYSQL_ROOT_PASSWORD}@localhost:3306/${database}`;
2307
2563
  }
2308
- 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
+ `;
2309
2615
  }
2310
2616
  function renderEnvExample(projectName, layers) {
2311
2617
  const lines = [
@@ -2315,8 +2621,10 @@ function renderEnvExample(projectName, layers) {
2315
2621
  "PORT=3000",
2316
2622
  "APP_URL=http://localhost:3000",
2317
2623
  `DATABASE_URL=${defaultDatabaseUrl(layers, projectName)}`,
2624
+ ...postgresAppRoleEnvNotes(layers),
2625
+ ...layers.database === "postgres" ? [`MIGRATION_DATABASE_URL=${defaultMigrationDatabaseUrl(layers, projectName)}`] : [],
2318
2626
  ...layers.database === "sqlite" ? [] : [
2319
- "# Optional. Migrate and boot against a different database than DATABASE_URL.",
2627
+ "# Optional. Point runtime at a different database than DATABASE_URL.",
2320
2628
  "# APP_DATABASE_URL="
2321
2629
  ],
2322
2630
  `DB_CONNECTION=${layers.database === "postgres" ? "pgsql" : layers.database}`,
@@ -2328,14 +2636,23 @@ function renderEnvExample(projectName, layers) {
2328
2636
  `MAIL_DRIVER=${layers.mail}`,
2329
2637
  `AUTH_DEV_HEADERS=${envFlag(layers.auth === "headers")}`
2330
2638
  ];
2331
- if (layers.frontend === "api") {
2332
- lines.push("FEATURE_PUBLIC_READS=false");
2333
- } else {
2334
- lines.push("# Local convenience so the welcome page reads without a login.", "# Production boot is blocked unless this is false.", "FEATURE_PUBLIC_READS=true");
2335
- }
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=");
2336
2651
  if (needsRedis(layers)) {
2337
- 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");
2338
2654
  } else {
2655
+ lines.push("# REDIS_PASSWORD=");
2339
2656
  lines.push("# REDIS_URL=redis://127.0.0.1:6379");
2340
2657
  }
2341
2658
  if (authUsesCookie(layers.auth) || layers.frontend === "server-htmx" || layers.frontend === "hybrid") {
@@ -2357,6 +2674,11 @@ function renderEnvExample(projectName, layers) {
2357
2674
  lines.push("# FEATURE_API_TOKENS=false");
2358
2675
  }
2359
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
+ }
2360
2682
  lines.push(`FEATURE_EMAIL_VERIFICATION=${envFlag(layers.extras.emailVerification)}`);
2361
2683
  if (layers.extras.metrics) {
2362
2684
  lines.push("METRICS_TOKEN=dev-metrics-token-change-me");
@@ -2402,22 +2724,24 @@ function renderDockerCompose(projectName, layers) {
2402
2724
  image: postgres:16-alpine
2403
2725
  environment:
2404
2726
  POSTGRES_USER: postgres
2405
- POSTGRES_PASSWORD: postgres
2727
+ POSTGRES_PASSWORD: ${GENERATED_POSTGRES_SUPERUSER_PASSWORD}
2406
2728
  POSTGRES_DB: ${database}
2407
2729
  ports:
2408
- - "5432:5432"
2730
+ # Localhost only. Do not publish this port on a production host.
2731
+ - "127.0.0.1:5432:5432"
2409
2732
  volumes:
2410
- - pgdata:/var/lib/postgresql/data`);
2733
+ - pgdata:/var/lib/postgresql/data
2734
+ - ./docker/postgres-init:/docker-entrypoint-initdb.d:ro`);
2411
2735
  }
2412
2736
  if (selectedSet.has("mysql")) {
2413
2737
  const database = appDatabaseName(projectName);
2414
2738
  services.push(` mysql:
2415
2739
  image: mysql:8.4
2416
2740
  environment:
2417
- MYSQL_ROOT_PASSWORD: root
2741
+ MYSQL_ROOT_PASSWORD: ${GENERATED_MYSQL_ROOT_PASSWORD}
2418
2742
  MYSQL_DATABASE: ${database}
2419
2743
  ports:
2420
- - "3306:3306"
2744
+ - "127.0.0.1:3306:3306"
2421
2745
  volumes:
2422
2746
  - mysqldata:/var/lib/mysql`);
2423
2747
  }
@@ -2425,25 +2749,28 @@ function renderDockerCompose(projectName, layers) {
2425
2749
  const server = selectedSet.has("mysql") ? "mysql" : "postgres";
2426
2750
  services.push(` adminer:
2427
2751
  image: adminer:5.4.2
2752
+ profiles:
2753
+ - debug
2428
2754
  environment:
2429
2755
  ADMINER_DEFAULT_SERVER: ${server}
2430
2756
  depends_on:
2431
2757
  - ${server}
2432
2758
  ports:
2433
- - "8080:8080"`);
2759
+ - "127.0.0.1:8080:8080"`);
2434
2760
  }
2435
2761
  if (selectedSet.has("redis")) {
2436
2762
  services.push(` redis:
2437
2763
  image: redis:7-alpine
2764
+ command: ["redis-server", "--requirepass", "\${REDIS_PASSWORD:-dev-redis-change-me}"]
2438
2765
  ports:
2439
- - "6379:6379"`);
2766
+ - "127.0.0.1:6379:6379"`);
2440
2767
  }
2441
2768
  if (selectedSet.has("mailpit")) {
2442
2769
  services.push(` mailpit:
2443
2770
  image: axllent/mailpit:latest
2444
2771
  ports:
2445
- - "1025:1025"
2446
- - "8025:8025"`);
2772
+ - "127.0.0.1:1025:1025"
2773
+ - "127.0.0.1:8025:8025"`);
2447
2774
  }
2448
2775
  const volumes = [];
2449
2776
  if (selectedSet.has("postgres")) {
@@ -2529,9 +2856,9 @@ function renderPackageJson(projectName, options = {}) {
2529
2856
  "@getstrata/cli": "workspace:*",
2530
2857
  "@getstrata/core": "workspace:*"
2531
2858
  } : {
2532
- "@getstrata/bootstrap": "^1.0.8",
2533
- "@getstrata/cli": "^1.0.8",
2534
- "@getstrata/core": "^1.0.8"
2859
+ "@getstrata/bootstrap": "^1.1.0",
2860
+ "@getstrata/cli": "^1.1.0",
2861
+ "@getstrata/core": "^1.1.0"
2535
2862
  };
2536
2863
  coreDeps.eta = "^4.6.0";
2537
2864
  if (options.layers?.database === "mysql") {
@@ -2613,8 +2940,11 @@ function renderSupportingToolsReadme(layers) {
2613
2940
  const system = mysql ? "MySQL" : "PostgreSQL";
2614
2941
  const server = mysql ? "mysql" : "postgres";
2615
2942
  const username = mysql ? "root" : "postgres";
2616
- const password = mysql ? "root" : "postgres";
2943
+ const password = mysql ? GENERATED_MYSQL_ROOT_PASSWORD : GENERATED_POSTGRES_SUPERUSER_PASSWORD;
2617
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
+ }
2618
2948
  }
2619
2949
  }
2620
2950
  if (localOn.length > 0) {
@@ -2628,13 +2958,13 @@ function renderSupportingToolsReadme(layers) {
2628
2958
  }
2629
2959
  function renderApiDocs(projectName, layers) {
2630
2960
  const rows = ["| Method | Path | Notes |", "| --- | --- | --- |"];
2631
- 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. |');
2632
2962
  rows.push("| `GET` | `/` | Welcome page. Restyle or replace it. |");
2633
2963
  if (authUsesToken(layers.auth)) {
2634
- 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>`. |");
2635
2965
  }
2636
2966
  if (authUsesJwt(layers.auth)) {
2637
- 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. |");
2638
2968
  }
2639
2969
  if (authNeedsUsers(layers.auth)) {
2640
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. |");
@@ -2642,7 +2972,7 @@ function renderApiDocs(projectName, layers) {
2642
2972
  if (layers.extras.metrics) {
2643
2973
  rows.push("| `GET` | `/metrics` | Prometheus text. Production requires `Authorization: Bearer <METRICS_TOKEN>`. |");
2644
2974
  }
2645
- 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.`;
2646
2976
  return `# ${projectName} API
2647
2977
 
2648
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}\`.`}
@@ -2666,7 +2996,7 @@ Create a module under \`src/modules/\` and return a route map. Modules are disco
2666
2996
  // src/modules/notes/index.ts
2667
2997
  import type { AppModule } from "@getstrata/bootstrap/contracts";
2668
2998
  import { jsonResponse } from "@getstrata/core/http/response";
2669
- import { getSql } from "../../bootstrap/database.ts";
2999
+ import { Note } from "../../models/Note.ts";
2670
3000
 
2671
3001
  const notesModule: AppModule = {
2672
3002
  name: "notes",
@@ -2674,10 +3004,8 @@ const notesModule: AppModule = {
2674
3004
  routes({ kernel }) {
2675
3005
  return {
2676
3006
  "/api/v1/notes": kernel.wrap("api", async () => {
2677
- const rows = await getSql().unsafe<{ id: number; body: string }>(
2678
- "SELECT id, body FROM notes ORDER BY id DESC",
2679
- );
2680
- return jsonResponse({ data: rows });
3007
+ const bodiesById = await Note.query().orderBy({ id: "desc" }).pluck("body", "id");
3008
+ return jsonResponse({ data: Object.fromEntries(bodiesById) });
2681
3009
  }),
2682
3010
  };
2683
3011
  },
@@ -2695,7 +3023,25 @@ Import from \`@getstrata/core/...\` subpaths rather than the package root, so si
2695
3023
  - [Databases](https://github.com/EyK-26/strata/blob/main/docs/DATABASE.md)
2696
3024
  `;
2697
3025
  }
2698
- function renderReadme(projectName, layers) {
3026
+ function renderReadmeIntro(projectName, options) {
3027
+ if (options.dogfood) {
3028
+ return `# ${projectName}
3029
+
3030
+ This in-repo app is Strata dogfood for internal end-to-end testing (migrate, seed, boot, OpenAPI, smoke). It is not a product. Start a product app with \`bunx create-strata\`.
3031
+ `;
3032
+ }
3033
+ if (options.inRepoExample) {
3034
+ return `# ${projectName}
3035
+
3036
+ This in-repo app is a generated sibling layer map. It is not a product and it is not CI dogfood. CI dogfood is \`apps/hiroapp\`. Start a product app with \`bunx create-strata\`.
3037
+ `;
3038
+ }
3039
+ return `# ${projectName}
3040
+
3041
+ Strata app generated by \`create-strata\`.
3042
+ `;
3043
+ }
3044
+ function renderReadme(projectName, layers, options = {}) {
2699
3045
  const docker = renderDockerCompose(projectName, layers);
2700
3046
  const next = [`cd ${projectName}`, "cp .env.example .env"];
2701
3047
  if (docker) {
@@ -2710,10 +3056,7 @@ function renderReadme(projectName, layers) {
2710
3056
  const dockerServices = selectedDockerServices(layers);
2711
3057
  const neededTools = neededDockerServices(layers);
2712
3058
  const dockerLabel = neededTools.length === 0 ? "not needed" : dockerServices.length > 0 ? dockerServices.join(", ") : "off (local installs)";
2713
- return `# ${projectName}
2714
-
2715
- Strata app generated by \`create-strata\`.
2716
-
3059
+ return `${renderReadmeIntro(projectName, options)}
2717
3060
  ## Layers
2718
3061
 
2719
3062
  | Layer | Choice |
@@ -2741,7 +3084,7 @@ ${next.join(`
2741
3084
  Open http://localhost:3000. Health check: \`GET /health\`.
2742
3085
 
2743
3086
  ${renderSupportingToolsReadme(layers)}${layers.auth !== "headers" ? `
2744
- Seeded login (password \`password\`):
3087
+ Seeded login (password \`StrataDemo!ChangeMe\`):
2745
3088
 
2746
3089
  - \`demo@example.com\` (member)
2747
3090
  - \`admin@example.test\` (admin)
@@ -2777,7 +3120,7 @@ Until \`frontend/dist\` exists, \`${layers.spaPrefix}\` answers 503. Use \`bun r
2777
3120
  ` : ""}${layers.database === "sqlite" ? "" : `
2778
3121
  ## Database
2779
3122
 
2780
- 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.` : ""}
2781
3124
  `}
2782
3125
  ## Deploy
2783
3126
 
@@ -2801,11 +3144,112 @@ The image sets \`APP_ENV=production\` and \`AUTH_DEV_HEADERS=false\`; everything
2801
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`."}
2802
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.
2803
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.
2804
- ${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" : ""}
2805
- \`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.
2806
3150
  `;
2807
3151
  }
2808
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;
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
+ `);
3251
+ }
3252
+
2809
3253
  // src/renderRuntime.ts
2810
3254
  function needsEnsure(layers) {
2811
3255
  return layers.database !== "sqlite";
@@ -3038,17 +3482,19 @@ function renderMigrateTs(layers) {
3038
3482
  statements.push(`CREATE TABLE IF NOT EXISTS tenant (
3039
3483
  id ${d.id},
3040
3484
  slug ${d.keyText} NOT NULL UNIQUE,
3041
- plan ${d.defaultText} NOT NULL DEFAULT 'enterprise',
3485
+ plan ${d.defaultText} NOT NULL DEFAULT 'free',
3042
3486
  region ${d.defaultText} NOT NULL DEFAULT 'eu'
3043
3487
  )`);
3044
3488
  }
3489
+ const notesTenantColumn = tenancyOn ? `
3490
+ tenant_id INTEGER NOT NULL DEFAULT 1,` : "";
3045
3491
  statements.push(`CREATE TABLE IF NOT EXISTS notes (
3046
3492
  id ${d.id},
3047
- body ${d.text} NOT NULL,
3493
+ body ${d.text} NOT NULL,${notesTenantColumn}
3048
3494
  created_at ${d.timestamp}
3049
3495
  )`);
3050
3496
  if (authNeedsUsers(layers.auth)) {
3051
- const tenantColumn = tenancyOn ? `
3497
+ const tenantColumn = tenancyOn || layers.extras.scim ? `
3052
3498
  tenant_id INTEGER NOT NULL DEFAULT 1,` : "";
3053
3499
  const mfaColumns = mfaOn ? `
3054
3500
  mfa_secret ${d.text},
@@ -3060,8 +3506,17 @@ function renderMigrateTs(layers) {
3060
3506
  email ${d.keyText} NOT NULL UNIQUE,
3061
3507
  password ${d.text} NOT NULL,
3062
3508
  is_admin ${d.bool},${tenantColumn}${mfaColumns}
3509
+ session_valid_after ${d.timestampNull},
3063
3510
  email_verified_at ${d.timestampNull},
3064
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}
3065
3520
  )`);
3066
3521
  }
3067
3522
  if (authUsesCookie(layers.auth)) {
@@ -3071,7 +3526,15 @@ function renderMigrateTs(layers) {
3071
3526
  expires_at ${d.timestamp},
3072
3527
  user_agent ${d.text},
3073
3528
  ip_address ${d.text},
3074
- 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}
3075
3538
  )`);
3076
3539
  }
3077
3540
  if (authUsesToken(layers.auth)) {
@@ -3086,17 +3549,26 @@ function renderMigrateTs(layers) {
3086
3549
  created_at ${d.timestamp}
3087
3550
  )`);
3088
3551
  }
3089
- 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(`
3090
3568
  `);
3091
3569
  const ph = layers.database === "postgres";
3092
- const notePlaceholder = ph ? "$1" : "?";
3093
3570
  const verifyOn = layers.extras.emailVerification && authNeedsUsers(layers.auth);
3094
- const userColumns = verifyOn ? "name, email, password, is_admin, email_verified_at" : "name, email, password, is_admin";
3095
- const userPlaceholders = verifyOn ? ph ? "$1, $2, $3, $4, $5), ($6, $7, $8, $9, $10" : "?, ?, ?, ?, ?), (?, ?, ?, ?, ?" : ph ? "$1, $2, $3, $4), ($5, $6, $7, $8" : "?, ?, ?, ?), (?, ?, ?, ?";
3096
- const adminFlag = ph ? "false" : "0";
3097
- const adminTrue = ph ? "true" : "1";
3098
3571
  const verifiedNow = nowTimestampLiteral(layers.database);
3099
- 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}]`;
3100
3572
  const seedTenant = tenancyOn ? `
3101
3573
  const [{ count: tenantCount }] = await sql.unsafe<{ count: string | number }>(
3102
3574
  "SELECT COUNT(*) AS count FROM tenant",
@@ -3104,47 +3576,80 @@ function renderMigrateTs(layers) {
3104
3576
  if (Number(tenantCount) === 0) {
3105
3577
  await sql.unsafe(
3106
3578
  "INSERT INTO tenant (slug, plan, region) VALUES (${ph ? "$1, $2, $3" : "?, ?, ?"})",
3107
- ["default", "enterprise", "eu"],
3579
+ ["default", "free", "eu"],
3108
3580
  );
3109
3581
  }` : "";
3110
3582
  const seedUsers = authNeedsUsers(layers.auth) ? `
3111
- const [{ count: userCount }] = await sql.unsafe<{ count: string | number }>(
3112
- "SELECT COUNT(*) AS count FROM users",
3113
- );
3114
- if (Number(userCount) === 0) {
3115
- const password = await hashPassword("password");
3116
- await sql.unsafe(
3117
- "INSERT INTO users (${userColumns}) VALUES (${userPlaceholders})",
3118
- ${userValues},
3119
- );
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
+ });
3120
3599
  }` : "";
3121
3600
  const hashImport = authNeedsUsers(layers.auth) ? `import { hashPassword } from "@getstrata/core/auth/password";
3122
3601
  ` : "";
3123
- const seedBlock = `${seedTenant}${seedUsers}`;
3124
- return `${hashImport}${ensureImport(layers)}import { closeDatabase, getSql } from "../bootstrap/database.ts";
3125
-
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";
3618
+ `;
3619
+ return `${hashImport}${rlsBypassImport}${postgresRoleImport}${ensureImport(layers)}${databaseImport}import { Note } from "../models/Note.ts";
3620
+ ${userImport}
3126
3621
  const migrations = [
3127
3622
  ${list}
3128
3623
  ];
3129
3624
 
3130
3625
  export async function seed() {
3131
- ${ensureCall(layers)} const sql = getSql();
3132
- const [{ count }] = await sql.unsafe<{ count: string | number }>(
3133
- "SELECT COUNT(*) AS count FROM notes",
3134
- );
3135
- if (Number(count) === 0) {
3136
- await sql.unsafe("INSERT INTO notes (body) VALUES (${notePlaceholder})", [
3137
- "Welcome to Strata!",
3138
- ]);
3139
- }${seedBlock}
3626
+ ${ensureCall(layers)}${bindSql}${seedOpen}${seedTenant}
3627
+ if ((await Note.query().value("id")) === null) {
3628
+ ${noteCreate}
3629
+ }${seedUsers}${seedClose}
3140
3630
  }
3141
3631
 
3142
3632
  export async function migrate() {
3143
- ${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();
3144
3649
  for (const statement of migrations) {
3145
3650
  await sql.unsafe(statement);
3146
3651
  }
3147
- await seed();
3652
+ `} await seed();
3148
3653
  }
3149
3654
 
3150
3655
  /** The CLI calls this after migrate() so pooled drivers do not hold the process open. */
@@ -3160,6 +3665,202 @@ if (import.meta.main) {
3160
3665
  }
3161
3666
  `;
3162
3667
  }
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"]';
3674
+ return `import { BaseRepository } from "@getstrata/core/database/baseRepository";
3675
+ import { Model, registerModelRepository } from "@getstrata/core/database/model";
3676
+ import { defineTable } from "@getstrata/core/database/table";
3677
+
3678
+ interface NoteRecord {
3679
+ id: number;
3680
+ body: string;${tenantField}
3681
+ created_at: Date | string;
3682
+ }
3683
+
3684
+ const notesTable = defineTable<NoteRecord, "id">({
3685
+ name: "notes",
3686
+ primaryKey: "id",
3687
+ columns: ["id", "body"${tenantColumn}, "created_at"],
3688
+ defaultOrderBy: { column: "id", direction: "ASC" },
3689
+ });
3690
+
3691
+ class NoteRepository extends BaseRepository<NoteRecord, "id"> {
3692
+ constructor() {
3693
+ super(notesTable);
3694
+ }
3695
+ }
3696
+
3697
+ class Note extends Model<NoteRecord, "id"> {
3698
+ static $fillable = ${fillable} as const;
3699
+ // created_at uses the table default. Sending a JS Date from $timestamps
3700
+ // is rejected by SQLite bindings.
3701
+ static $timestamps = false;
3702
+ }
3703
+
3704
+ registerModelRepository(Note, new NoteRepository());
3705
+
3706
+ export type { NoteRecord };
3707
+ export { Note };
3708
+ `;
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
+ }
3163
3864
  function dropTables(layers) {
3164
3865
  const ordered = [];
3165
3866
  if (authUsesToken(layers.auth)) {
@@ -3167,8 +3868,10 @@ function dropTables(layers) {
3167
3868
  }
3168
3869
  if (authUsesCookie(layers.auth)) {
3169
3870
  ordered.push("sessions");
3871
+ ordered.push("auth_saml_assertions");
3170
3872
  }
3171
3873
  if (authNeedsUsers(layers.auth)) {
3874
+ ordered.push("auth_one_time_tokens");
3172
3875
  ordered.push("users");
3173
3876
  }
3174
3877
  ordered.push("notes");
@@ -3177,20 +3880,31 @@ function dropTables(layers) {
3177
3880
  }
3178
3881
  return ordered;
3179
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
+ }
3180
3890
  function renderFreshTs(layers) {
3181
3891
  const tables = dropTables(layers);
3182
3892
  const cascade = layers.database === "sqlite" ? "" : " CASCADE";
3183
- return `${ensureImport(layers)}import { closeDatabase, getSql } from "../bootstrap/database.ts";
3184
- 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}
3185
3903
 
3186
3904
  const tables = ${JSON.stringify(tables)};
3187
3905
 
3188
3906
  export async function fresh() {
3189
- ${ensureCall(layers)} const sql = getSql();
3190
- for (const table of tables) {
3191
- await sql.unsafe(\`DROP TABLE IF EXISTS \${table}${cascade}\`);
3192
- }
3193
- await migrate();
3907
+ ${ensureCall(layers)}${dropBody}
3194
3908
  }
3195
3909
 
3196
3910
  /** The CLI calls this after fresh() so pooled drivers do not hold the process open. */
@@ -3248,16 +3962,21 @@ if (import.meta.main) {
3248
3962
  function renderRollbackTs(layers) {
3249
3963
  const tables = dropTables(layers);
3250
3964
  const cascade = layers.database === "sqlite" ? "" : " CASCADE";
3251
- 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}
3252
3975
 
3253
3976
  const tables = ${JSON.stringify(tables)};
3254
3977
 
3255
3978
  export async function rollback() {
3256
- ${ensureCall(layers)} const sql = getSql();
3257
- for (const table of tables) {
3258
- await sql.unsafe(\`DROP TABLE IF EXISTS \${table}${cascade}\`);
3259
- console.log(\`dropped \${table}\`);
3260
- }
3979
+ ${ensureCall(layers)}${dropBody}
3261
3980
  }
3262
3981
 
3263
3982
  if (import.meta.main) {
@@ -3268,13 +3987,15 @@ if (import.meta.main) {
3268
3987
  `;
3269
3988
  }
3270
3989
  function renderPreloadTs(layers, projectName) {
3271
- const database = appDatabaseName(projectName);
3272
- 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
+ ` : "";
3273
3994
  return `import { join } from "node:path";
3274
3995
  import { configureModulesDirectory } from "@getstrata/bootstrap/discoverModules";
3275
3996
 
3276
3997
  process.env.DATABASE_URL ??= ${JSON.stringify(fallback)};
3277
- process.env.FRONTEND_MODE ??= ${JSON.stringify(layers.frontend)};
3998
+ ${migrationLine}process.env.FRONTEND_MODE ??= ${JSON.stringify(layers.frontend)};
3278
3999
  process.env.SPA_PREFIX ??= ${JSON.stringify(layers.spaPrefix)};
3279
4000
  process.env.CACHE_DRIVER ??= ${JSON.stringify(layers.cache)};
3280
4001
  process.env.QUEUE_DRIVER ??= ${JSON.stringify(layers.queue)};
@@ -3371,11 +4092,10 @@ function renderEnsureDatabaseTs(layers, projectName) {
3371
4092
  if (layers.database === "sqlite") {
3372
4093
  return null;
3373
4094
  }
3374
- const database = appDatabaseName(projectName);
3375
- const fallback = layers.database === "mysql" ? `mysql://root:root@localhost:3306/${database}` : `postgresql://postgres:postgres@localhost:5432/${database}`;
4095
+ const fallback = defaultDatabaseUrl(layers, projectName);
3376
4096
  const resolveUrl = `/**
3377
- * The database name comes from DATABASE_URL. Set APP_DATABASE_URL to point
3378
- * 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.
3379
4099
  */
3380
4100
  function resolveAppDatabaseUrl(): string {
3381
4101
  const explicit = process.env.APP_DATABASE_URL?.trim();
@@ -3384,8 +4104,8 @@ function resolveAppDatabaseUrl(): string {
3384
4104
  }
3385
4105
  return process.env.DATABASE_URL?.trim() || DEFAULT_DATABASE_URL;
3386
4106
  }
3387
-
3388
- /** 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. */
3389
4109
  function safeDatabaseName(url: string): string {
3390
4110
  let name = "";
3391
4111
  try {
@@ -3407,7 +4127,7 @@ function safeDatabaseName(url: string): string {
3407
4127
 
3408
4128
  const DEFAULT_DATABASE_URL = ${JSON.stringify(fallback)};
3409
4129
 
3410
- ${resolveUrl}
4130
+ ${resolveUrl}${safeName}
3411
4131
  export async function ensureAppDatabase(): Promise<string> {
3412
4132
  const url = resolveAppDatabaseUrl();
3413
4133
  const name = safeDatabaseName(url);
@@ -3426,58 +4146,21 @@ export async function ensureAppDatabase(): Promise<string> {
3426
4146
  }
3427
4147
  `;
3428
4148
  }
3429
- return `const DEFAULT_DATABASE_URL = ${JSON.stringify(fallback)};
3430
-
3431
- ${resolveUrl}
3432
- function adminCandidateUrls(url: string): string[] {
3433
- const names = ["postgres", "template1"];
3434
- try {
3435
- const current = decodeURIComponent(new URL(url).pathname.replace(/^\\//, ""));
3436
- if (current && !names.includes(current)) {
3437
- names.push(current);
3438
- }
3439
- } catch {
3440
- }
3441
- return names.map((name) => {
3442
- const admin = new URL(url);
3443
- admin.pathname = \`/\${name}\`;
3444
- return admin.toString();
3445
- });
3446
- }
4149
+ return `import {
4150
+ ensurePostgresDatabaseAndAppRole,
4151
+ POSTGRES_APP_ROLE_PASSWORD,
4152
+ } from "@getstrata/core/tenant/enableTenantRls";
3447
4153
 
3448
- async function openAdminConnection(url: string): Promise<Bun.SQL> {
3449
- let lastError: unknown;
3450
- for (const candidate of adminCandidateUrls(url)) {
3451
- const adminSql = new Bun.SQL(candidate);
3452
- try {
3453
- await adminSql\`SELECT 1\`;
3454
- return adminSql;
3455
- } catch (error) {
3456
- lastError = error;
3457
- await adminSql.close().catch(() => undefined);
3458
- }
3459
- }
3460
- throw lastError instanceof Error
3461
- ? lastError
3462
- : new Error("Could not open an admin connection to create the app database.");
3463
- }
4154
+ const DEFAULT_DATABASE_URL = ${JSON.stringify(fallback)};
3464
4155
 
4156
+ ${resolveUrl}
3465
4157
  export async function ensureAppDatabase(): Promise<string> {
3466
4158
  const url = resolveAppDatabaseUrl();
3467
- const name = safeDatabaseName(url);
3468
-
3469
- const adminSql = await openAdminConnection(url);
3470
- try {
3471
- const rows = await adminSql\`
3472
- SELECT 1 AS ok FROM pg_database WHERE datname = \${name}
3473
- \`;
3474
- if (rows.length === 0) {
3475
- await adminSql.unsafe(\`CREATE DATABASE \${name}\`);
3476
- }
3477
- } finally {
3478
- await adminSql.close();
3479
- }
3480
-
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
+ });
3481
4164
  process.env.DATABASE_URL = url;
3482
4165
  return url;
3483
4166
  }
@@ -3486,11 +4169,27 @@ export async function ensureAppDatabase(): Promise<string> {
3486
4169
  function renderSidecarsTs(_layers) {
3487
4170
  return null;
3488
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
+ }
3489
4187
  function renderProvidersIndex() {
3490
4188
  return `import type { ServiceProvider } from "@getstrata/core/contracts/di";
3491
4189
  import authProvider from "./auth.ts";
3492
4190
  import cacheProvider from "./cache.ts";
3493
4191
  import configProvider from "./config.ts";
4192
+ import policyProvider from "./policy.ts";
3494
4193
  import queueProvider from "./queue.ts";
3495
4194
  import storageProvider from "./storage.ts";
3496
4195
 
@@ -3500,6 +4199,7 @@ const starterProviders: ServiceProvider[] = [
3500
4199
  storageProvider,
3501
4200
  queueProvider,
3502
4201
  authProvider,
4202
+ policyProvider,
3503
4203
  ];
3504
4204
 
3505
4205
  export { starterProviders };
@@ -3531,10 +4231,11 @@ import {
3531
4231
  ensureModulesLoaded,
3532
4232
  } from "@getstrata/bootstrap/discoverModules";
3533
4233
  import { createHealthRoutes } from "@getstrata/bootstrap/health";
3534
- ${metricsImport}import { assertProductionSecrets } from "@getstrata/bootstrap/secretsGuard";
4234
+ ${metricsImport}import { assertProductionSecrets, assertRlsLiveDatabaseRole } from "@getstrata/bootstrap/secretsGuard";
3535
4235
  import { createWebServer } from "@getstrata/bootstrap/web/server";
3536
4236
  import { setActiveApplicationContext } from "@getstrata/core/runtime/applicationRegistry";
3537
4237
  import { isProductionEnv } from "@getstrata/core/runtime/appEnv";
4238
+ import { isRlsTenancy } from "@getstrata/core/tenant/tenancyConfig";
3538
4239
  import { migrate } from "../db/migrate.ts";
3539
4240
  import { buildRoutes } from "../routes.ts";
3540
4241
  import { loadConfig } from "./config.ts";
@@ -3605,6 +4306,9 @@ export async function bootstrapApp(options: BootstrapOptions = {}): Promise<Boot
3605
4306
  ${needsEnsure(layers) ? ` await ensureAppDatabase();
3606
4307
  ` : ""} const appConfig = loadConfig();
3607
4308
  getSql();
4309
+ if (isRlsTenancy()) {
4310
+ await assertRlsLiveDatabaseRole();
4311
+ }
3608
4312
  configureModulesDirectory(join(import.meta.dir, "../modules"));
3609
4313
  await ensureModulesLoaded();
3610
4314
  const context = createAppContext();
@@ -3705,36 +4409,22 @@ export function plainText(body: string, status = 200): Response {
3705
4409
  }
3706
4410
 
3707
4411
  // src/renderScim.ts
3708
- function ph2(layers, count, start = 1) {
3709
- if (layers.database === "postgres") {
3710
- return Array.from({ length: count }, (_, index) => `$${start + index}`).join(", ");
3711
- }
3712
- return Array.from({ length: count }, () => "?").join(", ");
3713
- }
3714
- function sqlFalse2(layers) {
3715
- return layers.database === "postgres" ? "false" : "0";
3716
- }
3717
4412
  function renderScimModule(layers) {
3718
4413
  if (!layers.extras.scim || !authNeedsUsers(layers.auth)) {
3719
4414
  return null;
3720
4415
  }
3721
- const tenantOn = usesTenantTable(layers.tenancy);
3722
- const insertCols = tenantOn ? "name, email, password, is_admin, tenant_id" : "name, email, password, is_admin";
3723
- const insertPh = tenantOn ? ph2(layers, 5) : ph2(layers, 4);
3724
- const insertTail = tenantOn ? `, ${sqlFalse2(layers)}, tenantId` : `, ${sqlFalse2(layers)}`;
3725
- const emailPh = ph2(layers, 1);
3726
- const idPh = ph2(layers, 1);
3727
- const updatePh = `${ph2(layers, 1)}, ${ph2(layers, 1, 2)}, ${ph2(layers, 1, 3)}`;
3728
4416
  return `import { randomBytes } from "node:crypto";
3729
4417
  import type { AppModule } from "@getstrata/bootstrap/contracts";
3730
4418
  import { routeParams } from "@getstrata/bootstrap/web/routing";
3731
4419
  import { createScimAuthMiddleware } from "@getstrata/core/auth/scimAuthMiddleware";
3732
4420
  import { hashPassword } from "@getstrata/core/auth/password";
4421
+ import { BadRequestError } from "@getstrata/core/errors/http";
4422
+ import { parsePositiveIntParam } from "@getstrata/core/http/validation";
3733
4423
  import { withErrorHandling } from "@getstrata/core/http/response";
3734
4424
  import { withMiddleware } from "@getstrata/core/http/routeMiddleware";
3735
4425
  import { createScimThrottleMiddleware } from "@getstrata/core/http/scimThrottleMiddleware";
3736
4426
  import { currentTenant } from "@getstrata/core/tenant/tenantContext";
3737
- import { getSql } from "../../bootstrap/database.ts";
4427
+ import { User } from "../../models/User.ts";
3738
4428
 
3739
4429
  const USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User";
3740
4430
  const LIST_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
@@ -3742,12 +4432,29 @@ const ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error";
3742
4432
  const PATCH_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
3743
4433
  const CONFIG_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig";
3744
4434
 
3745
- type UserRow = { id: number; name: string; email: string };
3746
-
3747
4435
  function scimEnabled(): boolean {
3748
4436
  return (process.env.FEATURE_SCIM ?? "false") === "true";
3749
4437
  }
3750
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
+
3751
4458
  function scimJson(body: unknown, status = 200): Response {
3752
4459
  return Response.json(body, {
3753
4460
  status,
@@ -3759,13 +4466,15 @@ function scimError(detail: string, status: number): Response {
3759
4466
  return scimJson({ schemas: [ERROR_SCHEMA], detail, status: String(status) }, status);
3760
4467
  }
3761
4468
 
3762
- 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") ?? "");
3763
4472
  return {
3764
4473
  schemas: [USER_SCHEMA],
3765
- id: String(row.id),
3766
- userName: row.email,
3767
- name: { formatted: row.name },
3768
- 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 }],
3769
4478
  active: true,
3770
4479
  meta: { resourceType: "User" },
3771
4480
  };
@@ -3855,24 +4564,29 @@ const scimModule: AppModule = {
3855
4564
  const url = new URL(request.url);
3856
4565
  const filter = url.searchParams.get("filter") ?? "";
3857
4566
  const match = /userName\\s+eq\\s+"([^"]+)"/i.exec(filter);
3858
- 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));
3859
4569
  if (match?.[1]) {
3860
- rows = await getSql().unsafe<UserRow>(
3861
- "SELECT id, name, email FROM users WHERE email = ${emailPh}",
3862
- [match[1].trim().toLowerCase()],
3863
- );
3864
- } else {
3865
- 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
+ });
3866
4581
  }
3867
- const startIndex = Math.max(1, Number.parseInt(url.searchParams.get("startIndex") ?? "1", 10) || 1);
3868
- const count = Math.min(200, Math.max(1, Number.parseInt(url.searchParams.get("count") ?? String(rows.length || 1), 10) || 200));
3869
- 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();
3870
4584
  return scimJson({
3871
4585
  schemas: [LIST_SCHEMA],
3872
- totalResults: rows.length,
4586
+ totalResults,
3873
4587
  startIndex,
3874
- itemsPerPage: slice.length,
3875
- Resources: slice.map(toScimUser),
4588
+ itemsPerPage: page.length,
4589
+ Resources: page.map(toScimUser),
3876
4590
  });
3877
4591
  }),
3878
4592
  ),
@@ -3885,28 +4599,16 @@ const scimModule: AppModule = {
3885
4599
  if (!email) {
3886
4600
  return scimError("userName is required.", 400);
3887
4601
  }
3888
- const existing = await getSql().unsafe<UserRow>(
3889
- "SELECT id, name, email FROM users WHERE email = ${emailPh}",
3890
- [email],
3891
- );
3892
- if (existing[0]) {
4602
+ const existing = await User.where({ email, tenant_id: tenantId() }).first();
4603
+ if (existing) {
3893
4604
  return scimError("User already exists.", 409);
3894
4605
  }
3895
4606
  const hashed = await hashPassword(randomBytes(18).toString("hex"));
3896
- const tenantId = currentTenant()?.id ?? 1;
3897
- await getSql().unsafe(
3898
- "INSERT INTO users (${insertCols}) VALUES (${insertPh})",
3899
- [name, email, hashed${insertTail}],
3900
- );
3901
- const created = await getSql().unsafe<UserRow>(
3902
- "SELECT id, name, email FROM users WHERE email = ${emailPh}",
3903
- [email],
3904
- );
3905
- const row = created[0];
3906
- if (!row) {
4607
+ const created = await User.create({ name, email, password: hashed, is_admin: false, tenant_id: tenantId() });
4608
+ if (!created) {
3907
4609
  return scimError("Could not create user.", 500);
3908
4610
  }
3909
- return scimJson(toScimUser(row), 201);
4611
+ return scimJson(toScimUser(created), 201);
3910
4612
  }),
3911
4613
  ),
3912
4614
  },
@@ -3914,61 +4616,55 @@ const scimModule: AppModule = {
3914
4616
  GET: kernel.wrap(
3915
4617
  "api",
3916
4618
  wrapScim(async (request) => {
3917
- const id = Number.parseInt(routeParams(request).id ?? "", 10);
3918
- const rows = await getSql().unsafe<UserRow>(
3919
- "SELECT id, name, email FROM users WHERE id = ${idPh}",
3920
- [id],
3921
- );
3922
- const row = rows[0];
3923
- 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) {
3924
4625
  return scimError("User not found.", 404);
3925
4626
  }
3926
- return scimJson(toScimUser(row));
4627
+ return scimJson(toScimUser(user));
3927
4628
  }),
3928
4629
  ),
3929
4630
  PUT: kernel.wrap(
3930
4631
  "api",
3931
4632
  wrapScim(async (request) => {
3932
- const id = Number.parseInt(routeParams(request).id ?? "", 10);
4633
+ const id = readScimUserId(request);
4634
+ if (id instanceof Response) {
4635
+ return id;
4636
+ }
3933
4637
  const body = (await request.json()) as Record<string, unknown>;
3934
4638
  const email = readUserName(body);
3935
4639
  const name = readName(body, email);
3936
4640
  if (!email || !name) {
3937
4641
  return scimError("userName and name are required.", 400);
3938
4642
  }
3939
- await getSql().unsafe(
3940
- "UPDATE users SET name = ${updatePh.split(", ")[0]}, email = ${updatePh.split(", ")[1]} WHERE id = ${updatePh.split(", ")[2]}",
3941
- [name, email, id],
3942
- );
3943
- const rows = await getSql().unsafe<UserRow>(
3944
- "SELECT id, name, email FROM users WHERE id = ${idPh}",
3945
- [id],
3946
- );
3947
- const row = rows[0];
3948
- if (!row) {
4643
+ const user = await User.where({ id, tenant_id: tenantId() }).first();
4644
+ if (!user) {
3949
4645
  return scimError("User not found.", 404);
3950
4646
  }
3951
- return scimJson(toScimUser(row));
4647
+ await user.update({ name, email });
4648
+ return scimJson(toScimUser(user));
3952
4649
  }),
3953
4650
  ),
3954
4651
  PATCH: kernel.wrap(
3955
4652
  "api",
3956
4653
  wrapScim(async (request) => {
3957
- const id = Number.parseInt(routeParams(request).id ?? "", 10);
3958
- const existing = await getSql().unsafe<UserRow>(
3959
- "SELECT id, name, email FROM users WHERE id = ${idPh}",
3960
- [id],
3961
- );
3962
- const row = existing[0];
3963
- 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) {
3964
4660
  return scimError("User not found.", 404);
3965
4661
  }
3966
4662
  const body = (await request.json()) as { schemas?: string[]; Operations?: Array<{ op?: string; path?: string; value?: unknown }> };
3967
4663
  if (body.schemas && !body.schemas.includes(PATCH_SCHEMA)) {
3968
4664
  return scimError("Unsupported patch schema.", 400);
3969
4665
  }
3970
- let name = row.name;
3971
- let email = row.email;
4666
+ let name = String(user.get("name") ?? "");
4667
+ let email = String(user.get("email") ?? "");
3972
4668
  for (const operation of body.Operations ?? []) {
3973
4669
  const op = (operation.op ?? "replace").toLowerCase();
3974
4670
  if (op !== "replace" && op !== "add") {
@@ -3989,18 +4685,21 @@ const scimModule: AppModule = {
3989
4685
  name = readName(value, name);
3990
4686
  }
3991
4687
  }
3992
- await getSql().unsafe(
3993
- "UPDATE users SET name = ${updatePh.split(", ")[0]}, email = ${updatePh.split(", ")[1]} WHERE id = ${updatePh.split(", ")[2]}",
3994
- [name, email, id],
3995
- );
3996
- return scimJson(toScimUser({ id: row.id, name, email }));
4688
+ await user.update({ name, email });
4689
+ return scimJson(toScimUser(user));
3997
4690
  }),
3998
4691
  ),
3999
4692
  DELETE: kernel.wrap(
4000
4693
  "api",
4001
4694
  wrapScim(async (request) => {
4002
- const id = Number.parseInt(routeParams(request).id ?? "", 10);
4003
- 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
+ }
4004
4703
  return new Response(null, { status: 204 });
4005
4704
  }),
4006
4705
  ),
@@ -4064,7 +4763,10 @@ function writeGeneratedFiles(options) {
4064
4763
  writeText(join2(targetDir, ".env.example"), renderEnvExample(projectName, layers));
4065
4764
  writeText(join2(targetDir, ".gitignore"), renderGitignore());
4066
4765
  writeText(join2(targetDir, "package.json"), renderPackageJson(projectName, { ...options, layers }));
4067
- writeText(join2(targetDir, "README.md"), renderReadme(projectName, layers));
4766
+ writeText(join2(targetDir, "README.md"), renderReadme(projectName, layers, {
4767
+ inRepoExample: options.inRepoExample,
4768
+ dogfood: options.dogfood
4769
+ }));
4068
4770
  if (layers.frontend === "api") {
4069
4771
  writeText(join2(targetDir, "docs/API.md"), renderApiDocs(projectName, layers));
4070
4772
  } else {
@@ -4079,6 +4781,18 @@ function writeGeneratedFiles(options) {
4079
4781
  } else {
4080
4782
  removeIfExists(join2(targetDir, "docker-compose.yml"));
4081
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
+ }
4082
4796
  writeText(join2(src, "routes.ts"), renderRoutesTs());
4083
4797
  writeText(join2(src, "lib/view.ts"), renderViewTs(layers));
4084
4798
  writeText(join2(src, "bootstrap/config.ts"), renderConfigTs());
@@ -4093,8 +4807,22 @@ function writeGeneratedFiles(options) {
4093
4807
  writeText(join2(src, "bootstrap/createApp.ts"), renderCreateAppTs(layers));
4094
4808
  writeText(join2(src, "bootstrap/providers/config.ts"), renderConfigProvider(layers));
4095
4809
  writeText(join2(src, "bootstrap/providers/queue.ts"), renderQueueProvider());
4810
+ writeText(join2(src, "bootstrap/providers/policy.ts"), renderPolicyProvider());
4096
4811
  writeText(join2(src, "bootstrap/providers/index.ts"), renderProvidersIndex());
4097
4812
  writeText(join2(src, "bootstrap/providers/auth.ts"), renderAuthProvider(layers));
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
+ }
4098
4826
  writeText(join2(src, "db/migrate.ts"), renderMigrateTs(layers));
4099
4827
  writeText(join2(src, "db/fresh.ts"), renderFreshTs(layers));
4100
4828
  writeText(join2(src, "db/seed.ts"), renderSeedTs());
@@ -4125,6 +4853,17 @@ function writeGeneratedFiles(options) {
4125
4853
  removeIfExists(join2(src, "bootstrap/pendingMfa.ts"));
4126
4854
  }
4127
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
+ }
4128
4867
  writeText(join2(targetDir, "views/home.eta"), renderHomeView(projectName, layers));
4129
4868
  writeText(join2(targetDir, "views/layouts/app.eta"), renderLayout(layers, projectName));
4130
4869
  if (htmlAuthKit(layers.auth)) {
@@ -4138,6 +4877,7 @@ function writeGeneratedFiles(options) {
4138
4877
  if (layers.extras.mfa) {
4139
4878
  writeText(join2(targetDir, "views/auth/mfa-challenge.eta"), renderMfaChallengeView());
4140
4879
  writeText(join2(targetDir, "views/auth/mfa-setup.eta"), renderMfaSetupView());
4880
+ writeText(join2(targetDir, "views/auth/confirm-password.eta"), renderConfirmPasswordView());
4141
4881
  }
4142
4882
  }
4143
4883
  mkdirSync2(join2(targetDir, "storage"), { recursive: true });