@getstrata/starter 1.0.9 → 1.1.1
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 +1216 -493
- package/dist/templates/.env.example +1 -1
- package/dist/templates/docker-compose.yml +3 -2
- package/dist/templates/overlays/server-htmx/public/assets/htmx.min.js +1 -0
- package/dist/templates/overlays/server-htmx/public/assets/htmx.min.js.version +2 -0
- package/dist/templates/overlays/server-htmx/resources/views/layouts/app.eta +1 -1
- package/dist/templates/overlays/spa-react/frontend/src/App.tsx +2 -2
- package/dist/templates/overlays/spa-react/frontend/src/api/client.ts +32 -7
- package/dist/templates/overlays/spa-react/frontend/src/auth/AuthContext.tsx +11 -27
- package/dist/templates/overlays/spa-react/frontend/src/pages/HomePage.tsx +4 -5
- package/dist/templates/overlays/spa-react/frontend/src/pages/LoginPage.tsx +4 -4
- package/dist/templates/package.json +3 -3
- package/dist/templates/public/assets/htmx.min.js +1 -0
- package/dist/templates/public/assets/htmx.min.js.version +2 -0
- package/dist/templates/src/bootstrap/preload.ts +2 -1
- package/dist/templates/src/bootstrap/providers/auth.ts +11 -0
- package/package.json +1 -1
- package/dist/templates/overlays/spa-react/frontend/src/auth/tokenStorage.ts +0 -15
- package/dist/templates/src/bootstrap/createApp.ts +0 -95
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1197
|
-
|
|
1198
|
-
const
|
|
1199
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1253
|
-
const
|
|
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
|
|
1278
|
-
const
|
|
1279
|
-
|
|
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
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
);
|
|
1300
|
-
|
|
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
|
-
|
|
1319
|
-
|
|
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
|
-
|
|
1326
|
-
|
|
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
|
|
1330
|
-
|
|
1331
|
-
|
|
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
|
|
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
|
|
1344
|
-
const
|
|
1345
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
1387
|
-
if (!
|
|
1388
|
-
return jsonResponse({ error: "Invalid reset
|
|
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
|
|
1391
|
-
|
|
1392
|
-
|
|
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
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
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
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
);
|
|
1501
|
+
await runAuthWrite(id, async () => {
|
|
1502
|
+
const user = await User.find(id);
|
|
1503
|
+
await user?.update({ email_verified_at: ${nowTimestampLiteral(layers.database)} });
|
|
1504
|
+
});
|
|
1409
1505
|
return jsonResponse({ ok: true });
|
|
1506
|
+
}))),
|
|
1507
|
+
},` : "";
|
|
1508
|
+
const csrfRoute = `
|
|
1509
|
+
"/api/v1/auth/csrf": {
|
|
1510
|
+
GET: kernel.wrap("api", withErrorHandling(async (request) => {
|
|
1511
|
+
return jsonResponse({ token: resolveCsrfTokenForRequest(request) });
|
|
1512
|
+
})),
|
|
1513
|
+
},`;
|
|
1514
|
+
const cookieJsonAuth = cookie && !jsonApi ? `
|
|
1515
|
+
"/api/v1/auth/login": {
|
|
1516
|
+
POST: kernel.wrap("api", kernel.wrapLogin(withErrorHandling(async (request) => {
|
|
1517
|
+
const { email, password, mfa_code } = await parseJsonBody(request, parseLoginCredentials);
|
|
1518
|
+
const record = await starterAuthDirectory.findByEmail?.(email);
|
|
1519
|
+
if (!record?.password || !(await verifyPassword(password, record.password))) {
|
|
1520
|
+
return jsonResponse({ error: "Invalid credentials" }, { status: 422 });
|
|
1521
|
+
}
|
|
1522
|
+
const mfaResult = completePasswordLogin(record, { mfaCode: mfa_code });
|
|
1523
|
+
if (!mfaResult.ok) {
|
|
1524
|
+
return jsonResponse({ error: mfaResult.error }, { status: mfaResult.error === "mfa_required" ? 401 : 422 });
|
|
1525
|
+
}
|
|
1526
|
+
await persistAuthRecovery(record.id, record.mfa_recovery_codes, mfaResult.consumedRecoveryHash);
|
|
1527
|
+
const sessionAuth = dependencies.container.resolve<CookieSessionAuthManager>(CORE_AUTH_TOKEN);
|
|
1528
|
+
const { setCookie } = await sessionAuth.signIn(sessionUser(record));
|
|
1529
|
+
const payload = jsonResponse({ ok: true });
|
|
1530
|
+
payload.headers.append("set-cookie", setCookie);
|
|
1531
|
+
return payload;
|
|
1532
|
+
}))),
|
|
1533
|
+
},
|
|
1534
|
+
"/api/v1/auth/me": {
|
|
1535
|
+
GET: kernel.wrapApi(async (request) => {
|
|
1536
|
+
const user = await dependencies.container.resolve<CookieSessionAuthManager>(CORE_AUTH_TOKEN).requireUser(request);
|
|
1537
|
+
const record = await starterAuthDirectory.findByIdOrThrow(Number(user.id));
|
|
1538
|
+
return jsonResponse({
|
|
1539
|
+
id: record.id,
|
|
1540
|
+
name: record.name ?? record.email,
|
|
1541
|
+
email: record.email,
|
|
1542
|
+
role: record.role,
|
|
1543
|
+
});
|
|
1544
|
+
}),
|
|
1545
|
+
},
|
|
1546
|
+
"/api/v1/auth/logout": {
|
|
1547
|
+
POST: kernel.wrap("api", withErrorHandling(async (request) => {
|
|
1548
|
+
const sessionAuth = dependencies.container.resolve<CookieSessionAuthManager>(CORE_AUTH_TOKEN);
|
|
1549
|
+
const { setCookie } = await sessionAuth.signOut(request);
|
|
1550
|
+
const payload = jsonResponse({ ok: true });
|
|
1551
|
+
payload.headers.append("set-cookie", setCookie);
|
|
1552
|
+
return payload;
|
|
1410
1553
|
})),
|
|
1411
1554
|
},` : "";
|
|
1412
|
-
const
|
|
1555
|
+
const samlRoutes = cookie ? `
|
|
1556
|
+
"/auth/saml": {
|
|
1557
|
+
GET: kernel.wrap("api", withErrorHandling(async () => {
|
|
1558
|
+
if (process.env.FEATURE_SAML !== "true") {
|
|
1559
|
+
return new Response("Not found", { status: 404 });
|
|
1560
|
+
}
|
|
1561
|
+
const issued = createOAuthState();
|
|
1562
|
+
const url = await createSamlServiceProvider().authorizationUrl(issued.state);
|
|
1563
|
+
return new Response(null, { status: 302, headers: { location: url } });
|
|
1564
|
+
})),
|
|
1565
|
+
},
|
|
1566
|
+
"/auth/saml/acs": {
|
|
1567
|
+
POST: kernel.wrap("api", withErrorHandling(async (request) => {
|
|
1568
|
+
if (process.env.FEATURE_SAML !== "true") {
|
|
1569
|
+
return new Response("Not found", { status: 404 });
|
|
1570
|
+
}
|
|
1571
|
+
const form = await request.formData();
|
|
1572
|
+
const samlResponse = String(form.get("SAMLResponse") ?? "");
|
|
1573
|
+
const relayState = String(form.get("RelayState") ?? "");
|
|
1574
|
+
if (!verifyOAuthState(request, relayState)) {
|
|
1575
|
+
return jsonResponse({ error: "Invalid SAML state." }, { status: 403 });
|
|
1576
|
+
}
|
|
1577
|
+
const profile = await createSamlServiceProvider()
|
|
1578
|
+
.consumePost(samlResponse, relayState)
|
|
1579
|
+
.catch(() => null);
|
|
1580
|
+
if (!profile) {
|
|
1581
|
+
return jsonResponse({ error: "Invalid SAML response." }, { status: 400 });
|
|
1582
|
+
}
|
|
1583
|
+
let record = await starterAuthDirectory.findByEmail?.(profile.email);
|
|
1584
|
+
if (!record) {
|
|
1585
|
+
if ((process.env.FEATURE_REGISTRATION ?? "true") === "false") {
|
|
1586
|
+
return jsonResponse({ error: "SAML user is not provisioned." }, { status: 403 });
|
|
1587
|
+
}
|
|
1588
|
+
const hashed = await hashPassword(randomBytes(18).toString("hex"));
|
|
1589
|
+
try {
|
|
1590
|
+
await runAuthWrite(profile.email, async () => {
|
|
1591
|
+
await User.create({
|
|
1592
|
+
name: profile.name,
|
|
1593
|
+
email: profile.email,
|
|
1594
|
+
password: hashed,
|
|
1595
|
+
is_admin: false,${samlTenantCreateField}
|
|
1596
|
+
});
|
|
1597
|
+
});
|
|
1598
|
+
} catch {
|
|
1599
|
+
// Unique email: another request already provisioned this user.
|
|
1600
|
+
}
|
|
1601
|
+
record = await starterAuthDirectory.findByEmail?.(profile.email);
|
|
1602
|
+
}
|
|
1603
|
+
if (!record) {
|
|
1604
|
+
return jsonResponse({ error: "Could not complete SAML login." }, { status: 500 });
|
|
1605
|
+
}
|
|
1606
|
+
${mfa ? `if (record.mfa_enabled) {
|
|
1607
|
+
const pending = redirectTo("/login/mfa");
|
|
1608
|
+
pending.headers.append("set-cookie", pendingMfaSetCookie(record.id));
|
|
1609
|
+
return pending;
|
|
1610
|
+
}
|
|
1611
|
+
` : ""}const auth = dependencies.container.resolve<CookieSessionAuthManager>(CORE_AUTH_TOKEN);
|
|
1612
|
+
return auth.signInRedirect(sessionUser(record), "/");
|
|
1613
|
+
})),
|
|
1614
|
+
},` : "";
|
|
1615
|
+
const apiRoutes = jsonApi || cookie ? `
|
|
1413
1616
|
routes({ kernel, dependencies }) {
|
|
1414
|
-
return {${tokenLogin}${jsonRegister}${jwtLogin}${apiUser}${jsonPassword}${jsonVerify}
|
|
1617
|
+
return {${csrfRoute}${samlRoutes}${cookieJsonAuth}${tokenLogin}${jsonRegister}${jwtLogin}${apiUser}${jsonPassword}${jsonVerify}
|
|
1415
1618
|
};
|
|
1416
1619
|
},` : "";
|
|
1417
|
-
const mfaLoginBranch =
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
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
|
-
|
|
1425
|
-
{ level: "
|
|
1426
|
-
);` : `return
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1469
|
-
|
|
1470
|
-
const email = (fields.email ?? "").trim().toLowerCase();
|
|
1471
|
-
const password = fields.password ?? "";
|
|
1472
|
-
const errors: Record<string, string> = {};
|
|
1473
|
-
if (!name) {
|
|
1474
|
-
errors.name = "Name is required.";
|
|
1475
|
-
}
|
|
1476
|
-
if (!email) {
|
|
1477
|
-
errors.email = "Email is required.";
|
|
1478
|
-
}
|
|
1479
|
-
if (password.length < 8) {
|
|
1480
|
-
errors.password = "Use at least 8 characters.";
|
|
1481
|
-
}
|
|
1482
|
-
if (email && (await starterAuthDirectory.findByEmail?.(email))) {
|
|
1483
|
-
errors.email = "Email is already registered.";
|
|
1689
|
+
if ((process.env.FEATURE_REGISTRATION ?? "true") === "false") {
|
|
1690
|
+
return new Response("Not found", { status: 404 });
|
|
1484
1691
|
}
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
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
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
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
|
|
1727
|
+
const email = parseForgotPasswordEmail(fields);
|
|
1517
1728
|
const user = await starterAuthDirectory.findByEmail?.(email);
|
|
1518
1729
|
if (user) {
|
|
1519
|
-
await
|
|
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.
|
|
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
|
-
|
|
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: "
|
|
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
|
|
1550
|
-
|
|
1551
|
-
|
|
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 =
|
|
1565
|
-
if (
|
|
1566
|
-
await
|
|
1567
|
-
|
|
1568
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
1606
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1907
|
+
await runAuthWrite(Number(user.id), async () => {
|
|
1908
|
+
const record = await User.find(Number(user.id));
|
|
1909
|
+
await record?.update({
|
|
1910
|
+
mfa_secret: stored,
|
|
1911
|
+
mfa_enabled: true,
|
|
1912
|
+
mfa_recovery_codes: JSON.stringify(recoveryCodes.map((item) => hashRecoveryCode(item))),
|
|
1913
|
+
});
|
|
1914
|
+
});
|
|
1915
|
+
await revokeUserSessions(Number(user.id));
|
|
1673
1916
|
return renderPage(
|
|
1674
1917
|
"auth/mfa-setup.eta",
|
|
1675
1918
|
{
|
|
@@ -1700,15 +1943,13 @@ export default authModule;
|
|
|
1700
1943
|
function renderSiteModule(_layers) {
|
|
1701
1944
|
return `import type { AppModule } from "@getstrata/bootstrap/contracts";
|
|
1702
1945
|
import { withErrorHandling } from "@getstrata/core/http/response";
|
|
1703
|
-
import {
|
|
1704
|
-
import { plainText, renderPage } from "../../lib/view.ts";
|
|
1946
|
+
import { pingDatabase } from "../../bootstrap/database.ts";
|
|
1705
1947
|
import { Note } from "../../models/Note.ts";
|
|
1948
|
+
import { plainText, renderPage } from "../../lib/view.ts";
|
|
1706
1949
|
|
|
1707
|
-
// Proves the database answers and the schema is migrated. Point it at a table your app owns.
|
|
1708
1950
|
async function schemaReady(): Promise<boolean> {
|
|
1709
1951
|
try {
|
|
1710
|
-
|
|
1711
|
-
await Note.query().value("id");
|
|
1952
|
+
await Note.query().limit(1).get();
|
|
1712
1953
|
return true;
|
|
1713
1954
|
} catch {
|
|
1714
1955
|
return false;
|
|
@@ -1913,6 +2154,7 @@ function renderLayout(layers, projectName) {
|
|
|
1913
2154
|
<meta name="description" content="<%= it.layout.description %>" />
|
|
1914
2155
|
<% } %>
|
|
1915
2156
|
<link rel="stylesheet" href="/assets/site.css" />
|
|
2157
|
+
<script src="/assets/htmx.min.js"></script>
|
|
1916
2158
|
</head>
|
|
1917
2159
|
<body>
|
|
1918
2160
|
<header class="site-header">
|
|
@@ -1941,7 +2183,7 @@ function renderHomeView(projectName, layers) {
|
|
|
1941
2183
|
<a class="button" href="/register">Create account</a>
|
|
1942
2184
|
<a class="button button-secondary" href="/login">Sign in</a>
|
|
1943
2185
|
</div>
|
|
1944
|
-
<p class="muted">Seeded demo: <code>demo@example.com</code> / <code>
|
|
2186
|
+
<p class="muted">Seeded demo: <code>demo@example.com</code> / <code>StrataDemo!ChangeMe</code>.</p>
|
|
1945
2187
|
<% } else { %>
|
|
1946
2188
|
<p class="lede">You are signed in as <strong><%= it.currentUser.email %></strong>.</p>
|
|
1947
2189
|
<p>Add routes in <code>src/modules</code>. This homepage is yours to restyle.</p>
|
|
@@ -2017,6 +2259,9 @@ function renderVerifyEmailView() {
|
|
|
2017
2259
|
function renderMfaChallengeView() {
|
|
2018
2260
|
return renderFormView("Two-factor code", `${textField("code", "Authenticator or recovery code", "text", 'required autocomplete="one-time-code"')}`, "Continue", `<a href="/login">Cancel</a>`).replace('action="<%= it.action || "" %>"', 'action="/login/mfa"');
|
|
2019
2261
|
}
|
|
2262
|
+
function renderConfirmPasswordView() {
|
|
2263
|
+
return renderFormView("Confirm your password", textField("password", "Current password", "password", 'required autocomplete="current-password"'), "Confirm", `<a href="/">Cancel</a>`).replace('action="<%= it.action || "" %>"', 'action=""');
|
|
2264
|
+
}
|
|
2020
2265
|
function renderMfaSetupView() {
|
|
2021
2266
|
return `<section class="auth-card">
|
|
2022
2267
|
<h1>Authenticator app</h1>
|
|
@@ -2052,111 +2297,105 @@ function renderAuthDirectory(layers) {
|
|
|
2052
2297
|
return null;
|
|
2053
2298
|
}
|
|
2054
2299
|
const hashed = hashApiToken(token);
|
|
2055
|
-
const
|
|
2056
|
-
{
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
abilities: string;
|
|
2060
|
-
expires_at: Date | string | null;
|
|
2061
|
-
role?: string;
|
|
2062
|
-
is_admin?: number | boolean;
|
|
2063
|
-
email_verified_at?: Date | string | null;
|
|
2064
|
-
}
|
|
2065
|
-
>(
|
|
2066
|
-
\`SELECT t.id, t.user_id, t.abilities, t.expires_at, u.is_admin, u.email_verified_at
|
|
2067
|
-
FROM api_tokens t INNER JOIN users u ON u.id = t.user_id
|
|
2068
|
-
WHERE t.token_hash = ?\`,
|
|
2069
|
-
[hashed],
|
|
2070
|
-
);
|
|
2071
|
-
const row = rows[0];
|
|
2072
|
-
if (!row) {
|
|
2300
|
+
const tokenRow = await runWithMigrationBypassForIdentifier(hashed, async () => {
|
|
2301
|
+
return await ApiToken.where({ token_hash: hashed }).first();
|
|
2302
|
+
});
|
|
2303
|
+
if (!tokenRow) {
|
|
2073
2304
|
return null;
|
|
2074
2305
|
}
|
|
2075
|
-
|
|
2306
|
+
const expiresAt = tokenRow.get("expires_at");
|
|
2307
|
+
if (expiresAt && new Date(String(expiresAt)).getTime() <= Date.now()) {
|
|
2308
|
+
return null;
|
|
2309
|
+
}
|
|
2310
|
+
const user_id = Number(tokenRow.get("user_id"));
|
|
2311
|
+
const user = await runWithMigrationBypassForIdentifier(user_id, async () => {
|
|
2312
|
+
return await User.find(user_id);
|
|
2313
|
+
});
|
|
2314
|
+
if (!user) {
|
|
2076
2315
|
return null;
|
|
2077
2316
|
}
|
|
2078
2317
|
let abilities: string[] = [];
|
|
2079
2318
|
try {
|
|
2080
|
-
abilities = JSON.parse(String(
|
|
2319
|
+
abilities = JSON.parse(String(tokenRow.get("abilities") ?? "[]")) as string[];
|
|
2081
2320
|
} catch {
|
|
2082
|
-
abilities = [
|
|
2321
|
+
abilities = [];
|
|
2083
2322
|
}
|
|
2084
2323
|
return {
|
|
2085
|
-
id:
|
|
2086
|
-
role:
|
|
2324
|
+
id: user_id,
|
|
2325
|
+
role: mapRole(user.get("is_admin")),
|
|
2087
2326
|
abilities,
|
|
2088
|
-
tokenId: Number(
|
|
2089
|
-
emailVerifiedAt:
|
|
2327
|
+
tokenId: Number(tokenRow.get("id")),
|
|
2328
|
+
emailVerifiedAt: asTimestamp(user.get("email_verified_at")),
|
|
2090
2329
|
};
|
|
2091
2330
|
},` : `
|
|
2092
2331
|
async resolveUserFromToken() {
|
|
2093
2332
|
return null;
|
|
2094
2333
|
},`;
|
|
2095
|
-
const placeholder = layers.database === "postgres" ? "$1" : "?";
|
|
2096
2334
|
const hashImport = authUsesToken(layers.auth) ? `import { hashApiToken } from "@getstrata/core/auth/tokenHash";
|
|
2097
2335
|
` : "";
|
|
2098
|
-
const
|
|
2099
|
-
|
|
2100
|
-
mfa_enabled?: number | boolean;
|
|
2101
|
-
mfa_secret?: string | null;
|
|
2102
|
-
mfa_recovery_codes?: string | null;` : "";
|
|
2336
|
+
const apiTokenImport = authUsesToken(layers.auth) ? `import { ApiToken } from "../models/ApiToken.ts";
|
|
2337
|
+
` : "";
|
|
2103
2338
|
const mfaReturn = layers.extras.mfa ? `
|
|
2104
|
-
mfa_enabled:
|
|
2105
|
-
mfa_secret:
|
|
2106
|
-
mfa_recovery_codes:
|
|
2107
|
-
const
|
|
2339
|
+
mfa_enabled: user.get("mfa_enabled") === true || user.get("mfa_enabled") === 1,
|
|
2340
|
+
mfa_secret: asNullableString(user.get("mfa_secret")),
|
|
2341
|
+
mfa_recovery_codes: asNullableString(user.get("mfa_recovery_codes")),` : "";
|
|
2342
|
+
const sessionReturn = `
|
|
2343
|
+
session_valid_after: asTimestamp(user.get("session_valid_after")),`;
|
|
2344
|
+
const nullableStringHelper = layers.extras.mfa ? `
|
|
2345
|
+
function asNullableString(value: unknown): string | null {
|
|
2346
|
+
return typeof value === "string" ? value : null;
|
|
2347
|
+
}
|
|
2348
|
+
` : "";
|
|
2349
|
+
const mfaKeys = layers.extras.mfa ? ` | "mfa_enabled" | "mfa_secret" | "mfa_recovery_codes"` : "";
|
|
2108
2350
|
return `import type { AuthUser } from "@getstrata/core/auth/authContext";
|
|
2109
2351
|
import { verifyPassword } from "@getstrata/core/auth/password";
|
|
2110
|
-
${hashImport}import type { AuthUserDirectory } from "@getstrata/core/contracts/authUserDirectory";
|
|
2111
|
-
import {
|
|
2352
|
+
${hashImport}import type { AuthUserDirectory, AuthUserRecord } from "@getstrata/core/contracts/authUserDirectory";
|
|
2353
|
+
import { runWithMigrationBypassForIdentifier } from "@getstrata/core/tenant/databaseTenantContext";
|
|
2354
|
+
${apiTokenImport}import { User } from "../models/User.ts";
|
|
2112
2355
|
|
|
2113
|
-
type
|
|
2114
|
-
id:
|
|
2115
|
-
name: string;
|
|
2116
|
-
email: string;
|
|
2117
|
-
is_admin: number | boolean;
|
|
2118
|
-
email_verified_at: Date | string | null;
|
|
2119
|
-
password: string;${mfaColumns}
|
|
2356
|
+
type LoadedUser = {
|
|
2357
|
+
get(key: "id" | "name" | "email" | "is_admin" | "email_verified_at" | "password" | "session_valid_after"${mfaKeys}): unknown;
|
|
2120
2358
|
};
|
|
2121
2359
|
|
|
2122
2360
|
function mapRole(isAdmin: unknown): string {
|
|
2123
2361
|
return isAdmin === true || isAdmin === 1 || isAdmin === "1" ? "admin" : "member";
|
|
2124
2362
|
}
|
|
2125
2363
|
|
|
2126
|
-
function
|
|
2364
|
+
function asTimestamp(value: unknown): Date | string | null {
|
|
2365
|
+
return value instanceof Date || typeof value === "string" ? value : null;
|
|
2366
|
+
}
|
|
2367
|
+
${nullableStringHelper}
|
|
2368
|
+
function mapUserRow(user: LoadedUser): AuthUserRecord {
|
|
2127
2369
|
return {
|
|
2128
|
-
id: Number(
|
|
2129
|
-
name:
|
|
2130
|
-
email:
|
|
2131
|
-
role: mapRole(
|
|
2132
|
-
email_verified_at:
|
|
2133
|
-
password:
|
|
2370
|
+
id: Number(user.get("id")),
|
|
2371
|
+
name: String(user.get("name") ?? ""),
|
|
2372
|
+
email: String(user.get("email") ?? ""),
|
|
2373
|
+
role: mapRole(user.get("is_admin")),
|
|
2374
|
+
email_verified_at: asTimestamp(user.get("email_verified_at")),
|
|
2375
|
+
password: String(user.get("password") ?? ""),${mfaReturn}${sessionReturn}
|
|
2134
2376
|
};
|
|
2135
2377
|
}
|
|
2136
2378
|
|
|
2137
2379
|
async function findUserById(id: number) {
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
}
|
|
2146
|
-
return mapUserRow(row);
|
|
2380
|
+
return await runWithMigrationBypassForIdentifier(id, async () => {
|
|
2381
|
+
const user = await User.find(id);
|
|
2382
|
+
if (!user) {
|
|
2383
|
+
throw new Error(\`User \${id} not found.\`);
|
|
2384
|
+
}
|
|
2385
|
+
return mapUserRow(user);
|
|
2386
|
+
});
|
|
2147
2387
|
}
|
|
2148
2388
|
|
|
2149
2389
|
async function findUserByEmail(email: string) {
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
return row ? mapUserRow(row) : null;
|
|
2390
|
+
email = email.trim().toLowerCase();
|
|
2391
|
+
return await runWithMigrationBypassForIdentifier(email, async () => {
|
|
2392
|
+
const user = await User.where({ email }).first();
|
|
2393
|
+
return user ? mapUserRow(user) : null;
|
|
2394
|
+
});
|
|
2156
2395
|
}
|
|
2157
2396
|
|
|
2158
2397
|
export const starterAuthDirectory: AuthUserDirectory = {
|
|
2159
|
-
${tokenLookup
|
|
2398
|
+
${tokenLookup}
|
|
2160
2399
|
|
|
2161
2400
|
findByIdOrThrow: findUserById,
|
|
2162
2401
|
|
|
@@ -2167,6 +2406,9 @@ ${tokenLookup.replace("WHERE t.token_hash = ?", `WHERE t.token_hash = ${placehol
|
|
|
2167
2406
|
if (!user?.password || !(await verifyPassword(password, user.password))) {
|
|
2168
2407
|
return null;
|
|
2169
2408
|
}
|
|
2409
|
+
if ("mfa_enabled" in user && user.mfa_enabled) {
|
|
2410
|
+
return null;
|
|
2411
|
+
}
|
|
2170
2412
|
return {
|
|
2171
2413
|
id: user.id,
|
|
2172
2414
|
role: user.role,
|
|
@@ -2185,6 +2427,11 @@ import type { ServiceProvider } from "@getstrata/core/contracts/di";
|
|
|
2185
2427
|
import { envFlagEnabled } from "@getstrata/core/runtime/appEnv";
|
|
2186
2428
|
|
|
2187
2429
|
class StarterAuthManager {
|
|
2430
|
+
async resolveWithSource(request?: Request) {
|
|
2431
|
+
const user = await this.resolve(request);
|
|
2432
|
+
return { user, credentialSource: user ? "guest" as const : null };
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2188
2435
|
async resolve(request?: Request): Promise<AuthUser | null> {
|
|
2189
2436
|
if (!envFlagEnabled(process.env.AUTH_DEV_HEADERS)) {
|
|
2190
2437
|
return request ? null : currentAuthUser();
|
|
@@ -2230,13 +2477,13 @@ export default authProvider;
|
|
|
2230
2477
|
role: user.is_admin ? "admin" : "member",
|
|
2231
2478
|
...(user.email_verified_at !== undefined ? { emailVerifiedAt: user.email_verified_at } : {}),
|
|
2232
2479
|
}),
|
|
2233
|
-
});` : ` const fallback = ${authUsesToken(layers.auth) ? "new DatabaseTokenGuard(container)" : "new JwtGuard()"};
|
|
2480
|
+
});` : ` const fallback = ${authUsesToken(layers.auth) ? "new DatabaseTokenGuard(container)" : "new JwtGuard(container)"};
|
|
2234
2481
|
const auth = new AuthManager(fallback);`;
|
|
2235
2482
|
const tokenRegs = authUsesToken(layers.auth) ? ` const apiGuard = new DatabaseTokenGuard(container);
|
|
2236
2483
|
auth.registerGuard("api", apiGuard);
|
|
2237
2484
|
auth.registerGuard("access_token", apiGuard);
|
|
2238
2485
|
auth.registerGuard("token", apiGuard);` : "";
|
|
2239
|
-
const jwtReg = authUsesJwt(layers.auth) ? ` auth.registerGuard("jwt", new JwtGuard());` : "";
|
|
2486
|
+
const jwtReg = authUsesJwt(layers.auth) ? ` auth.registerGuard("jwt", new JwtGuard(container));` : "";
|
|
2240
2487
|
const basicReg = authUsesToken(layers.auth) || authUsesJwt(layers.auth) ? ` auth.registerGuard("basic", new BasicAuthGuard(container));` : "";
|
|
2241
2488
|
const ability = authUsesToken(layers.auth) ? ` container.set(CORE_ABILITY_CHECKER_TOKEN, createTokenAbilityChecker());` : "";
|
|
2242
2489
|
const imports = [`import { CORE_AUTH_TOKEN } from "@getstrata/bootstrap/config";`];
|
|
@@ -2296,18 +2543,75 @@ export default authProvider;
|
|
|
2296
2543
|
function envFlag(value) {
|
|
2297
2544
|
return value ? "true" : "false";
|
|
2298
2545
|
}
|
|
2546
|
+
var GENERATED_POSTGRES_APP_ROLE = "strata_app";
|
|
2547
|
+
var GENERATED_POSTGRES_APP_PASSWORD = "dev-strata-app-change-me";
|
|
2548
|
+
var GENERATED_POSTGRES_SUPERUSER_PASSWORD = "dev-postgres-change-me";
|
|
2549
|
+
var GENERATED_MYSQL_ROOT_PASSWORD = "dev-mysql-change-me";
|
|
2299
2550
|
function appDatabaseName(projectName) {
|
|
2300
2551
|
return projectName.replace(/[^A-Za-z0-9_]/g, "_");
|
|
2301
2552
|
}
|
|
2553
|
+
function usesComposePostgres(layers) {
|
|
2554
|
+
return selectedDockerServices(layers).includes("postgres");
|
|
2555
|
+
}
|
|
2302
2556
|
function defaultDatabaseUrl(layers, projectName) {
|
|
2303
2557
|
if (layers.database === "sqlite") {
|
|
2304
2558
|
return "sqlite:./storage/app.sqlite";
|
|
2305
2559
|
}
|
|
2306
2560
|
const database = appDatabaseName(projectName);
|
|
2307
2561
|
if (layers.database === "mysql") {
|
|
2308
|
-
return `mysql://root
|
|
2562
|
+
return `mysql://root:${GENERATED_MYSQL_ROOT_PASSWORD}@localhost:3306/${database}`;
|
|
2563
|
+
}
|
|
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;
|
|
2309
2569
|
}
|
|
2310
|
-
|
|
2570
|
+
const database = appDatabaseName(projectName);
|
|
2571
|
+
return `postgresql://postgres:${GENERATED_POSTGRES_SUPERUSER_PASSWORD}@localhost:5432/${database}`;
|
|
2572
|
+
}
|
|
2573
|
+
function postgresAppRoleEnvNotes(layers) {
|
|
2574
|
+
if (layers.database !== "postgres") {
|
|
2575
|
+
return [];
|
|
2576
|
+
}
|
|
2577
|
+
return [
|
|
2578
|
+
`# Runtime DATABASE_URL is ${GENERATED_POSTGRES_APP_ROLE} (NOSUPERUSER NOBYPASSRLS).`,
|
|
2579
|
+
"# MIGRATION_DATABASE_URL is the postgres superuser for CREATE ROLE / CREATE DATABASE / GRANT / migrate.",
|
|
2580
|
+
"# db/ensure-postgres-app-role.sql is repeatable on an existing volume, not only docker-entrypoint-initdb.d.",
|
|
2581
|
+
layers.tenancy === "rls" ? "# Production TENANCY_DRIVER=rls inspects pg_roles (rolsuper / rolbypassrls), not only the URL username." : "# This app is not TENANCY_DRIVER=rls; still do not point runtime traffic at the postgres superuser."
|
|
2582
|
+
];
|
|
2583
|
+
}
|
|
2584
|
+
function renderPostgresAppRoleInitSql(projectName, layers) {
|
|
2585
|
+
if (layers.database !== "postgres") {
|
|
2586
|
+
return null;
|
|
2587
|
+
}
|
|
2588
|
+
const database = appDatabaseName(projectName);
|
|
2589
|
+
const role = GENERATED_POSTGRES_APP_ROLE;
|
|
2590
|
+
const password = GENERATED_POSTGRES_APP_PASSWORD;
|
|
2591
|
+
return `-- Application login role. FORCE RLS applies because this role is NOSUPERUSER and NOBYPASSRLS.
|
|
2592
|
+
-- Superuser remains for CREATE ROLE / CREATE DATABASE / GRANT / migrate.
|
|
2593
|
+
-- This script is repeatable on an already-existing volume (not only docker-entrypoint-initdb.d).
|
|
2594
|
+
DO $$
|
|
2595
|
+
BEGIN
|
|
2596
|
+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}') THEN
|
|
2597
|
+
CREATE ROLE ${role} LOGIN PASSWORD '${password}'
|
|
2598
|
+
NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS;
|
|
2599
|
+
ELSE
|
|
2600
|
+
ALTER ROLE ${role} WITH LOGIN PASSWORD '${password}'
|
|
2601
|
+
NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS;
|
|
2602
|
+
END IF;
|
|
2603
|
+
END
|
|
2604
|
+
$$;
|
|
2605
|
+
|
|
2606
|
+
GRANT CONNECT ON DATABASE ${database} TO ${role};
|
|
2607
|
+
GRANT USAGE, CREATE ON SCHEMA public TO ${role};
|
|
2608
|
+
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ${role};
|
|
2609
|
+
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ${role};
|
|
2610
|
+
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO ${role};
|
|
2611
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO ${role};
|
|
2612
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO ${role};
|
|
2613
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT EXECUTE ON FUNCTIONS TO ${role};
|
|
2614
|
+
`;
|
|
2311
2615
|
}
|
|
2312
2616
|
function renderEnvExample(projectName, layers) {
|
|
2313
2617
|
const lines = [
|
|
@@ -2317,8 +2621,10 @@ function renderEnvExample(projectName, layers) {
|
|
|
2317
2621
|
"PORT=3000",
|
|
2318
2622
|
"APP_URL=http://localhost:3000",
|
|
2319
2623
|
`DATABASE_URL=${defaultDatabaseUrl(layers, projectName)}`,
|
|
2624
|
+
...postgresAppRoleEnvNotes(layers),
|
|
2625
|
+
...layers.database === "postgres" ? [`MIGRATION_DATABASE_URL=${defaultMigrationDatabaseUrl(layers, projectName)}`] : [],
|
|
2320
2626
|
...layers.database === "sqlite" ? [] : [
|
|
2321
|
-
"# Optional.
|
|
2627
|
+
"# Optional. Point runtime at a different database than DATABASE_URL.",
|
|
2322
2628
|
"# APP_DATABASE_URL="
|
|
2323
2629
|
],
|
|
2324
2630
|
`DB_CONNECTION=${layers.database === "postgres" ? "pgsql" : layers.database}`,
|
|
@@ -2330,14 +2636,23 @@ function renderEnvExample(projectName, layers) {
|
|
|
2330
2636
|
`MAIL_DRIVER=${layers.mail}`,
|
|
2331
2637
|
`AUTH_DEV_HEADERS=${envFlag(layers.auth === "headers")}`
|
|
2332
2638
|
];
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2639
|
+
lines.push("APP_DEBUG=false");
|
|
2640
|
+
lines.push("FEATURE_PUBLIC_READS=false");
|
|
2641
|
+
lines.push("FEATURE_SIEM_EXPORT=false");
|
|
2642
|
+
lines.push("FEATURE_REGISTRATION=true");
|
|
2643
|
+
lines.push("FEATURE_SAML=false");
|
|
2644
|
+
lines.push("# SAML_IDP_SSO_URL=");
|
|
2645
|
+
lines.push("# SAML_IDP_CERT=");
|
|
2646
|
+
lines.push("# SAML_SP_ENTITY_ID=");
|
|
2647
|
+
lines.push("# SAML_ACS_URL=");
|
|
2648
|
+
lines.push("# SAML_IDP_ISSUER=");
|
|
2649
|
+
lines.push("# SAML_WANT_RESPONSE_SIGNED=");
|
|
2650
|
+
lines.push("# SAML_DISABLE_REQUESTED_AUTHN_CONTEXT=");
|
|
2338
2651
|
if (needsRedis(layers)) {
|
|
2339
|
-
lines.push("
|
|
2652
|
+
lines.push("REDIS_PASSWORD=dev-redis-change-me");
|
|
2653
|
+
lines.push("REDIS_URL=redis://:dev-redis-change-me@127.0.0.1:6379");
|
|
2340
2654
|
} else {
|
|
2655
|
+
lines.push("# REDIS_PASSWORD=");
|
|
2341
2656
|
lines.push("# REDIS_URL=redis://127.0.0.1:6379");
|
|
2342
2657
|
}
|
|
2343
2658
|
if (authUsesCookie(layers.auth) || layers.frontend === "server-htmx" || layers.frontend === "hybrid") {
|
|
@@ -2359,6 +2674,11 @@ function renderEnvExample(projectName, layers) {
|
|
|
2359
2674
|
lines.push("# FEATURE_API_TOKENS=false");
|
|
2360
2675
|
}
|
|
2361
2676
|
lines.push(`FEATURE_MFA=${envFlag(layers.extras.mfa)}`);
|
|
2677
|
+
if (layers.extras.mfa) {
|
|
2678
|
+
lines.push("KMS_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef");
|
|
2679
|
+
} else {
|
|
2680
|
+
lines.push("# KMS_ENCRYPTION_KEY=");
|
|
2681
|
+
}
|
|
2362
2682
|
lines.push(`FEATURE_EMAIL_VERIFICATION=${envFlag(layers.extras.emailVerification)}`);
|
|
2363
2683
|
if (layers.extras.metrics) {
|
|
2364
2684
|
lines.push("METRICS_TOKEN=dev-metrics-token-change-me");
|
|
@@ -2404,22 +2724,24 @@ function renderDockerCompose(projectName, layers) {
|
|
|
2404
2724
|
image: postgres:16-alpine
|
|
2405
2725
|
environment:
|
|
2406
2726
|
POSTGRES_USER: postgres
|
|
2407
|
-
POSTGRES_PASSWORD:
|
|
2727
|
+
POSTGRES_PASSWORD: ${GENERATED_POSTGRES_SUPERUSER_PASSWORD}
|
|
2408
2728
|
POSTGRES_DB: ${database}
|
|
2409
2729
|
ports:
|
|
2410
|
-
|
|
2730
|
+
# Localhost only. Do not publish this port on a production host.
|
|
2731
|
+
- "127.0.0.1:5432:5432"
|
|
2411
2732
|
volumes:
|
|
2412
|
-
- pgdata:/var/lib/postgresql/data
|
|
2733
|
+
- pgdata:/var/lib/postgresql/data
|
|
2734
|
+
- ./docker/postgres-init:/docker-entrypoint-initdb.d:ro`);
|
|
2413
2735
|
}
|
|
2414
2736
|
if (selectedSet.has("mysql")) {
|
|
2415
2737
|
const database = appDatabaseName(projectName);
|
|
2416
2738
|
services.push(` mysql:
|
|
2417
2739
|
image: mysql:8.4
|
|
2418
2740
|
environment:
|
|
2419
|
-
MYSQL_ROOT_PASSWORD:
|
|
2741
|
+
MYSQL_ROOT_PASSWORD: ${GENERATED_MYSQL_ROOT_PASSWORD}
|
|
2420
2742
|
MYSQL_DATABASE: ${database}
|
|
2421
2743
|
ports:
|
|
2422
|
-
- "3306:3306"
|
|
2744
|
+
- "127.0.0.1:3306:3306"
|
|
2423
2745
|
volumes:
|
|
2424
2746
|
- mysqldata:/var/lib/mysql`);
|
|
2425
2747
|
}
|
|
@@ -2427,25 +2749,28 @@ function renderDockerCompose(projectName, layers) {
|
|
|
2427
2749
|
const server = selectedSet.has("mysql") ? "mysql" : "postgres";
|
|
2428
2750
|
services.push(` adminer:
|
|
2429
2751
|
image: adminer:5.4.2
|
|
2752
|
+
profiles:
|
|
2753
|
+
- debug
|
|
2430
2754
|
environment:
|
|
2431
2755
|
ADMINER_DEFAULT_SERVER: ${server}
|
|
2432
2756
|
depends_on:
|
|
2433
2757
|
- ${server}
|
|
2434
2758
|
ports:
|
|
2435
|
-
- "8080:8080"`);
|
|
2759
|
+
- "127.0.0.1:8080:8080"`);
|
|
2436
2760
|
}
|
|
2437
2761
|
if (selectedSet.has("redis")) {
|
|
2438
2762
|
services.push(` redis:
|
|
2439
2763
|
image: redis:7-alpine
|
|
2764
|
+
command: ["redis-server", "--requirepass", "\${REDIS_PASSWORD:-dev-redis-change-me}"]
|
|
2440
2765
|
ports:
|
|
2441
|
-
- "6379:6379"`);
|
|
2766
|
+
- "127.0.0.1:6379:6379"`);
|
|
2442
2767
|
}
|
|
2443
2768
|
if (selectedSet.has("mailpit")) {
|
|
2444
2769
|
services.push(` mailpit:
|
|
2445
2770
|
image: axllent/mailpit:latest
|
|
2446
2771
|
ports:
|
|
2447
|
-
- "1025:1025"
|
|
2448
|
-
- "8025:8025"`);
|
|
2772
|
+
- "127.0.0.1:1025:1025"
|
|
2773
|
+
- "127.0.0.1:8025:8025"`);
|
|
2449
2774
|
}
|
|
2450
2775
|
const volumes = [];
|
|
2451
2776
|
if (selectedSet.has("postgres")) {
|
|
@@ -2531,9 +2856,9 @@ function renderPackageJson(projectName, options = {}) {
|
|
|
2531
2856
|
"@getstrata/cli": "workspace:*",
|
|
2532
2857
|
"@getstrata/core": "workspace:*"
|
|
2533
2858
|
} : {
|
|
2534
|
-
"@getstrata/bootstrap": "^1.
|
|
2535
|
-
"@getstrata/cli": "^1.
|
|
2536
|
-
"@getstrata/core": "^1.
|
|
2859
|
+
"@getstrata/bootstrap": "^1.1.1",
|
|
2860
|
+
"@getstrata/cli": "^1.1.1",
|
|
2861
|
+
"@getstrata/core": "^1.1.1"
|
|
2537
2862
|
};
|
|
2538
2863
|
coreDeps.eta = "^4.6.0";
|
|
2539
2864
|
if (options.layers?.database === "mysql") {
|
|
@@ -2615,8 +2940,11 @@ function renderSupportingToolsReadme(layers) {
|
|
|
2615
2940
|
const system = mysql ? "MySQL" : "PostgreSQL";
|
|
2616
2941
|
const server = mysql ? "mysql" : "postgres";
|
|
2617
2942
|
const username = mysql ? "root" : "postgres";
|
|
2618
|
-
const password = mysql ?
|
|
2943
|
+
const password = mysql ? GENERATED_MYSQL_ROOT_PASSWORD : GENERATED_POSTGRES_SUPERUSER_PASSWORD;
|
|
2619
2944
|
lines.push(`Adminer: http://localhost:8080 (${system}, server \`${server}\`, username \`${username}\`, password \`${password}\`).`, "");
|
|
2945
|
+
if (!mysql) {
|
|
2946
|
+
lines.push("Adminer uses the Compose `postgres` superuser, which skips FORCE RLS. Application traffic should use `strata_app`.", "");
|
|
2947
|
+
}
|
|
2620
2948
|
}
|
|
2621
2949
|
}
|
|
2622
2950
|
if (localOn.length > 0) {
|
|
@@ -2630,13 +2958,13 @@ function renderSupportingToolsReadme(layers) {
|
|
|
2630
2958
|
}
|
|
2631
2959
|
function renderApiDocs(projectName, layers) {
|
|
2632
2960
|
const rows = ["| Method | Path | Notes |", "| --- | --- | --- |"];
|
|
2633
|
-
rows.push("| `GET` | `/health` | Plain text `ok` (200)
|
|
2961
|
+
rows.push("| `GET` | `/health` | Plain text `ok` (200) when the database ping succeeds and `notes` is readable under the request tenant (zero rows is still 200). `degraded` (503) when the ping or that read fails. Docker HEALTHCHECK uses this path. |", '| `GET` | `/ready` | JSON from the framework: `{"status":"ready","checks":{"database":"ok","redis":"skipped"}}` (200) or `not_ready` (503). `redis` is `ok` or `error` when `REDIS_URL` is set and `skipped` otherwise. It does not check the schema, so use `/health` as the deploy gate. |');
|
|
2634
2962
|
rows.push("| `GET` | `/` | Welcome page. Restyle or replace it. |");
|
|
2635
2963
|
if (authUsesToken(layers.auth)) {
|
|
2636
|
-
rows.push("| `POST` | `/api/v1/auth/login` | `{ email, password }` returns `{ token }`. |", "| `POST` | `/api/v1/auth/register` | Creates a user and returns a token. |", "| `GET` | `/api/v1/auth/me` | Requires `Authorization: Bearer <token>`. |");
|
|
2964
|
+
rows.push("| `POST` | `/api/v1/auth/login` | `{ email, password }` returns `{ token }`. Requires `GET /api/v1/auth/csrf` then `X-CSRF-Token`. |", "| `POST` | `/api/v1/auth/register` | Creates a user and returns a token. |", "| `GET` | `/api/v1/auth/me` | Requires `Authorization: Bearer <token>`. |");
|
|
2637
2965
|
}
|
|
2638
2966
|
if (authUsesJwt(layers.auth)) {
|
|
2639
|
-
rows.push("| `POST` | `/api/auth/token` | Mints a short-lived JWT. Not an HTML session. |");
|
|
2967
|
+
rows.push("| `POST` | `/api/auth/token` | Mints a short-lived JWT. Not an HTML session. Requires CSRF like JSON login. |");
|
|
2640
2968
|
}
|
|
2641
2969
|
if (authNeedsUsers(layers.auth)) {
|
|
2642
2970
|
rows.push("| `POST` | `/api/v1/auth/forgot-password` | Sends a signed reset link through the mail driver. |", "| `POST` | `/api/v1/auth/reset-password` | Consumes the signed link. |", "| `GET` | `/api/user` | Current user for the active guard. |");
|
|
@@ -2644,7 +2972,7 @@ function renderApiDocs(projectName, layers) {
|
|
|
2644
2972
|
if (layers.extras.metrics) {
|
|
2645
2973
|
rows.push("| `GET` | `/metrics` | Prometheus text. Production requires `Authorization: Bearer <METRICS_TOKEN>`. |");
|
|
2646
2974
|
}
|
|
2647
|
-
const authNote = layers.auth === "headers" ? `Auth is \`headers\`. Send \`x-authenticated-user-id\` (and optional \`x-authenticated-user-role\`) for local work and tests. There are no login endpoints and no \`users\` table. Production must set \`AUTH_DEV_HEADERS=false\`, which turns those headers off and leaves you without a guard, so pick another auth layer before you ship.` : authUsesToken(layers.auth) ? `Sign in with \`POST /api/v1/auth/login
|
|
2975
|
+
const authNote = layers.auth === "headers" ? `Auth is \`headers\`. Send \`x-authenticated-user-id\` (and optional \`x-authenticated-user-role\`) for local work and tests. There are no login endpoints and no \`users\` table. Production must set \`AUTH_DEV_HEADERS=false\`, which turns those headers off and leaves you without a guard, so pick another auth layer before you ship.` : authUsesToken(layers.auth) ? `Sign in with \`GET /api/v1/auth/csrf\` then \`POST /api/v1/auth/login\` (send \`X-CSRF-Token\`), then send \`Authorization: Bearer <token>\` on every request. Tokens are stored hashed in \`api_tokens\` and expire after \`API_TOKEN_DEFAULT_EXPIRY_DAYS\` (30 in \`.env.example\`). The response includes \`expires_at\`.` : `Mint a JWT with \`POST /api/auth/token\`, then send \`Authorization: Bearer <jwt>\`. JWTs expire; re-mint rather than refreshing in place.`;
|
|
2648
2976
|
return `# ${projectName} API
|
|
2649
2977
|
|
|
2650
2978
|
\`FRONTEND_MODE=${layers.frontend}\`. ${layers.frontend === "api" ? "No server-rendered views beyond the welcome page and no SPA assets." : `HTML is served alongside this API. The SPA is mounted at \`${layers.spaPrefix}\`.`}
|
|
@@ -2756,7 +3084,7 @@ ${next.join(`
|
|
|
2756
3084
|
Open http://localhost:3000. Health check: \`GET /health\`.
|
|
2757
3085
|
|
|
2758
3086
|
${renderSupportingToolsReadme(layers)}${layers.auth !== "headers" ? `
|
|
2759
|
-
Seeded login (password \`
|
|
3087
|
+
Seeded login (password \`StrataDemo!ChangeMe\`):
|
|
2760
3088
|
|
|
2761
3089
|
- \`demo@example.com\` (member)
|
|
2762
3090
|
- \`admin@example.test\` (admin)
|
|
@@ -2792,7 +3120,7 @@ Until \`frontend/dist\` exists, \`${layers.spaPrefix}\` answers 503. Use \`bun r
|
|
|
2792
3120
|
` : ""}${layers.database === "sqlite" ? "" : `
|
|
2793
3121
|
## Database
|
|
2794
3122
|
|
|
2795
|
-
The app uses the database named in \`DATABASE_URL\` and creates it on first migrate when the connection user may. Set \`APP_DATABASE_URL\` only when migrations and the app should target a different database than \`DATABASE_URL\`.
|
|
3123
|
+
The app uses the database named in \`DATABASE_URL\` and creates it on first migrate when the connection user may. Set \`APP_DATABASE_URL\` only when migrations and the app should target a different database than \`DATABASE_URL\`.${usesComposePostgres(layers) ? ` \`.env.example\` points \`DATABASE_URL\` at \`${GENERATED_POSTGRES_APP_ROLE}\` (\`NOSUPERUSER\` \`NOBYPASSRLS\`), including \`--no-docker\`. \`db/ensure-postgres-app-role.sql\` is repeatable on an existing volume; Compose init still runs on first empty volume. The \`postgres\` superuser is for CREATE ROLE / GRANT / migrate and Adminer.` : ""}
|
|
2796
3124
|
`}
|
|
2797
3125
|
## Deploy
|
|
2798
3126
|
|
|
@@ -2816,9 +3144,110 @@ The image sets \`APP_ENV=production\` and \`AUTH_DEV_HEADERS=false\`; everything
|
|
|
2816
3144
|
- Set \`FEATURE_PUBLIC_READS=false\`. ${layers.frontend === "api" ? "This app already ships `false`." : "This app ships `true` so the local welcome page reads without a login. Production requires `false`."}
|
|
2817
3145
|
- Cross-origin browser calls are off in production until you set \`CORS_ALLOWED_ORIGINS\` to explicit origins. A \`*\` entry is rejected. Non-browser clients are unaffected.
|
|
2818
3146
|
- Behind a reverse proxy or load balancer, set \`TRUST_FORWARDED_FOR=true\` so throttles and session records see the client address instead of the proxy. Only the rightmost public hop of \`X-Forwarded-For\` is trusted.
|
|
2819
|
-
${authUsesCookie(layers.auth) ? "- Set `SESSION_SECRET` to 32+ characters.\n" : ""}${authUsesToken(layers.auth) ? "- Set `TOKEN_HASH_PEPPER`.\n" : ""}${layers.extras.scim ? "- Set `SCIM_BEARER_TOKEN`.\n" : ""}${layers.extras.metrics ? "- Set `METRICS_TOKEN`.\n" : ""}
|
|
2820
|
-
|
|
3147
|
+
${authUsesCookie(layers.auth) ? "- Set `SESSION_SECRET` to 32+ characters.\n" : ""}${authUsesToken(layers.auth) ? "- Set `TOKEN_HASH_PEPPER`.\n" : ""}${layers.extras.scim ? "- Set `SCIM_BEARER_TOKEN`.\n" : ""}${layers.extras.metrics ? "- Set `METRICS_TOKEN`.\n" : ""}${layers.tenancy === "rls" ? `- \`DATABASE_URL\` must be a \`NOBYPASSRLS\` role, not the \`postgres\` superuser. Generated apps create \`${GENERATED_POSTGRES_APP_ROLE}\` via \`db/ensure-postgres-app-role.sql\` (and Compose init on first empty volume). \`MIGRATION_DATABASE_URL\` may stay the superuser for CREATE ROLE / GRANT / migrate. Production boot rejects username \`postgres\` or \`root\`, then inspects \`pg_roles\` for \`rolsuper\` / \`rolbypassrls\`.
|
|
3148
|
+
` : ""}
|
|
3149
|
+
\`strata start\` does not migrate when \`APP_ENV=production\`. Run \`bun run db:migrate\` as a deploy step. \`GET /health\` is 200 when \`notes\` is readable (including zero rows) and 503 when that read fails.
|
|
3150
|
+
`;
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
// ../../src/core/runtime/appEnv.ts
|
|
3154
|
+
var NON_PRODUCTION_APP_ENVS = new Set(["local", "development", "dev", "test", "testing", "ci"]);
|
|
3155
|
+
|
|
3156
|
+
// ../../src/core/tenant/enableTenantRls.ts
|
|
3157
|
+
function assertSafeIdentifier(identifier) {
|
|
3158
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
|
|
3159
|
+
throw new Error(`Invalid SQL identifier: ${identifier}`);
|
|
3160
|
+
}
|
|
3161
|
+
return identifier;
|
|
3162
|
+
}
|
|
3163
|
+
var RLS_HELPER_SQL = `
|
|
3164
|
+
CREATE OR REPLACE FUNCTION app_bypass_rls()
|
|
3165
|
+
RETURNS BOOLEAN AS $$
|
|
3166
|
+
BEGIN
|
|
3167
|
+
RETURN COALESCE(current_setting('app.bypass_rls', true), 'false') = 'true';
|
|
3168
|
+
EXCEPTION
|
|
3169
|
+
WHEN others THEN
|
|
3170
|
+
RETURN FALSE;
|
|
3171
|
+
END;
|
|
3172
|
+
$$ LANGUAGE plpgsql STABLE;
|
|
3173
|
+
|
|
3174
|
+
CREATE OR REPLACE FUNCTION app_bypass_identifier()
|
|
3175
|
+
RETURNS TEXT AS $$
|
|
3176
|
+
BEGIN
|
|
3177
|
+
RETURN NULLIF(current_setting('app.bypass_identifier', true), '');
|
|
3178
|
+
EXCEPTION
|
|
3179
|
+
WHEN others THEN
|
|
3180
|
+
RETURN NULL;
|
|
3181
|
+
END;
|
|
3182
|
+
$$ LANGUAGE plpgsql STABLE;
|
|
3183
|
+
|
|
3184
|
+
CREATE OR REPLACE FUNCTION app_current_tenant_id()
|
|
3185
|
+
RETURNS INTEGER AS $$
|
|
3186
|
+
BEGIN
|
|
3187
|
+
RETURN NULLIF(current_setting('app.tenant_id', true), '')::INTEGER;
|
|
3188
|
+
EXCEPTION
|
|
3189
|
+
WHEN others THEN
|
|
3190
|
+
RETURN NULL;
|
|
3191
|
+
END;
|
|
3192
|
+
$$ LANGUAGE plpgsql STABLE;
|
|
2821
3193
|
`;
|
|
3194
|
+
function tenantIsolationPolicy(table, predicate) {
|
|
3195
|
+
return `
|
|
3196
|
+
ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY;
|
|
3197
|
+
ALTER TABLE ${table} FORCE ROW LEVEL SECURITY;
|
|
3198
|
+
DROP POLICY IF EXISTS tenant_isolation ON ${table};
|
|
3199
|
+
CREATE POLICY tenant_isolation ON ${table}
|
|
3200
|
+
USING (
|
|
3201
|
+
app_bypass_rls()
|
|
3202
|
+
OR ${predicate}
|
|
3203
|
+
)
|
|
3204
|
+
WITH CHECK (
|
|
3205
|
+
app_bypass_rls()
|
|
3206
|
+
OR ${predicate}
|
|
3207
|
+
);
|
|
3208
|
+
`;
|
|
3209
|
+
}
|
|
3210
|
+
function enableTenantRlsSql(tableName) {
|
|
3211
|
+
const table = assertSafeIdentifier(tableName);
|
|
3212
|
+
return tenantIsolationPolicy(table, `tenant_id = app_current_tenant_id()`);
|
|
3213
|
+
}
|
|
3214
|
+
function enableUsersRlsSql() {
|
|
3215
|
+
return tenantIsolationPolicy("users", `tenant_id = app_current_tenant_id()
|
|
3216
|
+
OR (
|
|
3217
|
+
app_bypass_identifier() IS NOT NULL
|
|
3218
|
+
AND (
|
|
3219
|
+
id::text = app_bypass_identifier()
|
|
3220
|
+
OR email = app_bypass_identifier()
|
|
3221
|
+
)
|
|
3222
|
+
)`);
|
|
3223
|
+
}
|
|
3224
|
+
function userOwnedIdentifierPredicate(table, userIdColumn) {
|
|
3225
|
+
const userIdMatch = `${table}.${userIdColumn}::text = app_bypass_identifier()`;
|
|
3226
|
+
if (table === "sessions") {
|
|
3227
|
+
return `(app_bypass_identifier() IS NOT NULL AND (${table}.id::text = app_bypass_identifier() OR ${userIdMatch}))`;
|
|
3228
|
+
}
|
|
3229
|
+
if (table === "api_tokens" || table === "auth_one_time_tokens") {
|
|
3230
|
+
return `(app_bypass_identifier() IS NOT NULL AND (${table}.token_hash = app_bypass_identifier() OR ${userIdMatch}))`;
|
|
3231
|
+
}
|
|
3232
|
+
return `(app_bypass_identifier() IS NOT NULL AND ${userIdMatch})`;
|
|
3233
|
+
}
|
|
3234
|
+
function enableUserOwnedRlsSql(tableName, userIdColumn = "user_id") {
|
|
3235
|
+
const table = assertSafeIdentifier(tableName);
|
|
3236
|
+
const column = assertSafeIdentifier(userIdColumn);
|
|
3237
|
+
return tenantIsolationPolicy(table, `EXISTS (
|
|
3238
|
+
SELECT 1 FROM users u
|
|
3239
|
+
WHERE u.id = ${table}.${column}
|
|
3240
|
+
AND u.tenant_id = app_current_tenant_id()
|
|
3241
|
+
)
|
|
3242
|
+
OR ${userOwnedIdentifierPredicate(table, column)}`);
|
|
3243
|
+
}
|
|
3244
|
+
function generatedRlsBootstrapSql(tables, userOwnedTables = []) {
|
|
3245
|
+
return [
|
|
3246
|
+
RLS_HELPER_SQL,
|
|
3247
|
+
...tables.map((table) => table === "users" ? enableUsersRlsSql() : enableTenantRlsSql(table)),
|
|
3248
|
+
...userOwnedTables.map((table) => enableUserOwnedRlsSql(table))
|
|
3249
|
+
].join(`
|
|
3250
|
+
`);
|
|
2822
3251
|
}
|
|
2823
3252
|
|
|
2824
3253
|
// src/renderRuntime.ts
|
|
@@ -3053,17 +3482,19 @@ function renderMigrateTs(layers) {
|
|
|
3053
3482
|
statements.push(`CREATE TABLE IF NOT EXISTS tenant (
|
|
3054
3483
|
id ${d.id},
|
|
3055
3484
|
slug ${d.keyText} NOT NULL UNIQUE,
|
|
3056
|
-
plan ${d.defaultText} NOT NULL DEFAULT '
|
|
3485
|
+
plan ${d.defaultText} NOT NULL DEFAULT 'free',
|
|
3057
3486
|
region ${d.defaultText} NOT NULL DEFAULT 'eu'
|
|
3058
3487
|
)`);
|
|
3059
3488
|
}
|
|
3489
|
+
const notesTenantColumn = tenancyOn ? `
|
|
3490
|
+
tenant_id INTEGER NOT NULL DEFAULT 1,` : "";
|
|
3060
3491
|
statements.push(`CREATE TABLE IF NOT EXISTS notes (
|
|
3061
3492
|
id ${d.id},
|
|
3062
|
-
body ${d.text} NOT NULL
|
|
3493
|
+
body ${d.text} NOT NULL,${notesTenantColumn}
|
|
3063
3494
|
created_at ${d.timestamp}
|
|
3064
3495
|
)`);
|
|
3065
3496
|
if (authNeedsUsers(layers.auth)) {
|
|
3066
|
-
const tenantColumn = tenancyOn ? `
|
|
3497
|
+
const tenantColumn = tenancyOn || layers.extras.scim ? `
|
|
3067
3498
|
tenant_id INTEGER NOT NULL DEFAULT 1,` : "";
|
|
3068
3499
|
const mfaColumns = mfaOn ? `
|
|
3069
3500
|
mfa_secret ${d.text},
|
|
@@ -3075,8 +3506,17 @@ function renderMigrateTs(layers) {
|
|
|
3075
3506
|
email ${d.keyText} NOT NULL UNIQUE,
|
|
3076
3507
|
password ${d.text} NOT NULL,
|
|
3077
3508
|
is_admin ${d.bool},${tenantColumn}${mfaColumns}
|
|
3509
|
+
session_valid_after ${d.timestampNull},
|
|
3078
3510
|
email_verified_at ${d.timestampNull},
|
|
3079
3511
|
created_at ${d.timestamp}
|
|
3512
|
+
)`);
|
|
3513
|
+
statements.push(`CREATE TABLE IF NOT EXISTS auth_one_time_tokens (
|
|
3514
|
+
id ${d.id},
|
|
3515
|
+
purpose ${d.keyText} NOT NULL,
|
|
3516
|
+
user_id INTEGER NOT NULL,
|
|
3517
|
+
token_hash ${d.keyText} NOT NULL UNIQUE,
|
|
3518
|
+
expires_at ${d.timestamp},
|
|
3519
|
+
consumed_at ${d.timestampNull}
|
|
3080
3520
|
)`);
|
|
3081
3521
|
}
|
|
3082
3522
|
if (authUsesCookie(layers.auth)) {
|
|
@@ -3086,7 +3526,15 @@ function renderMigrateTs(layers) {
|
|
|
3086
3526
|
expires_at ${d.timestamp},
|
|
3087
3527
|
user_agent ${d.text},
|
|
3088
3528
|
ip_address ${d.text},
|
|
3089
|
-
last_active_at ${d.timestamp}
|
|
3529
|
+
last_active_at ${d.timestamp},
|
|
3530
|
+
created_at ${d.timestamp}
|
|
3531
|
+
)`);
|
|
3532
|
+
if (layers.database === "postgres") {
|
|
3533
|
+
statements.push("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()");
|
|
3534
|
+
}
|
|
3535
|
+
statements.push(`CREATE TABLE IF NOT EXISTS auth_saml_assertions (
|
|
3536
|
+
assertion_id ${d.keyText} PRIMARY KEY,
|
|
3537
|
+
consumed_at ${d.timestamp}
|
|
3090
3538
|
)`);
|
|
3091
3539
|
}
|
|
3092
3540
|
if (authUsesToken(layers.auth)) {
|
|
@@ -3101,16 +3549,26 @@ function renderMigrateTs(layers) {
|
|
|
3101
3549
|
created_at ${d.timestamp}
|
|
3102
3550
|
)`);
|
|
3103
3551
|
}
|
|
3104
|
-
const
|
|
3552
|
+
const rlsOn = layers.tenancy === "rls" && layers.database === "postgres";
|
|
3553
|
+
if (rlsOn) {
|
|
3554
|
+
const rlsTables = authNeedsUsers(layers.auth) ? ["notes", "users"] : ["notes"];
|
|
3555
|
+
const userOwnedTables = [];
|
|
3556
|
+
if (authNeedsUsers(layers.auth)) {
|
|
3557
|
+
userOwnedTables.push("auth_one_time_tokens");
|
|
3558
|
+
}
|
|
3559
|
+
if (authUsesCookie(layers.auth)) {
|
|
3560
|
+
userOwnedTables.push("sessions");
|
|
3561
|
+
}
|
|
3562
|
+
if (authUsesToken(layers.auth)) {
|
|
3563
|
+
userOwnedTables.push("api_tokens");
|
|
3564
|
+
}
|
|
3565
|
+
statements.push(generatedRlsBootstrapSql(rlsTables, userOwnedTables).trim());
|
|
3566
|
+
}
|
|
3567
|
+
const list = statements.map((sql) => ` \`${sql.replace(/`/g, "\\`")}\`,`).join(`
|
|
3105
3568
|
`);
|
|
3106
3569
|
const ph = layers.database === "postgres";
|
|
3107
3570
|
const verifyOn = layers.extras.emailVerification && authNeedsUsers(layers.auth);
|
|
3108
|
-
const userColumns = verifyOn ? "name, email, password, is_admin, email_verified_at" : "name, email, password, is_admin";
|
|
3109
|
-
const userPlaceholders = verifyOn ? ph ? "$1, $2, $3, $4, $5), ($6, $7, $8, $9, $10" : "?, ?, ?, ?, ?), (?, ?, ?, ?, ?" : ph ? "$1, $2, $3, $4), ($5, $6, $7, $8" : "?, ?, ?, ?), (?, ?, ?, ?";
|
|
3110
|
-
const adminFlag = ph ? "false" : "0";
|
|
3111
|
-
const adminTrue = ph ? "true" : "1";
|
|
3112
3571
|
const verifiedNow = nowTimestampLiteral(layers.database);
|
|
3113
|
-
const userValues = verifyOn ? `["Demo User", "demo@example.com", password, ${adminFlag}, ${verifiedNow}, "Admin User", "admin@example.test", password, ${adminTrue}, ${verifiedNow}]` : `["Demo User", "demo@example.com", password, ${adminFlag}, "Admin User", "admin@example.test", password, ${adminTrue}]`;
|
|
3114
3572
|
const seedTenant = tenancyOn ? `
|
|
3115
3573
|
const [{ count: tenantCount }] = await sql.unsafe<{ count: string | number }>(
|
|
3116
3574
|
"SELECT COUNT(*) AS count FROM tenant",
|
|
@@ -3118,45 +3576,80 @@ function renderMigrateTs(layers) {
|
|
|
3118
3576
|
if (Number(tenantCount) === 0) {
|
|
3119
3577
|
await sql.unsafe(
|
|
3120
3578
|
"INSERT INTO tenant (slug, plan, region) VALUES (${ph ? "$1, $2, $3" : "?, ?, ?"})",
|
|
3121
|
-
["default", "
|
|
3579
|
+
["default", "free", "eu"],
|
|
3122
3580
|
);
|
|
3123
3581
|
}` : "";
|
|
3124
3582
|
const seedUsers = authNeedsUsers(layers.auth) ? `
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
${
|
|
3133
|
-
);
|
|
3583
|
+
if ((await User.query().value("id")) === null) {
|
|
3584
|
+
const password = await hashPassword("StrataDemo!ChangeMe");
|
|
3585
|
+
await User.create({
|
|
3586
|
+
name: "Demo User",
|
|
3587
|
+
email: "demo@example.com",
|
|
3588
|
+
password,
|
|
3589
|
+
is_admin: false,${verifyOn ? `
|
|
3590
|
+
email_verified_at: ${verifiedNow},` : ""}
|
|
3591
|
+
});
|
|
3592
|
+
await User.create({
|
|
3593
|
+
name: "Admin User",
|
|
3594
|
+
email: "admin@example.test",
|
|
3595
|
+
password,
|
|
3596
|
+
is_admin: true,${verifyOn ? `
|
|
3597
|
+
email_verified_at: ${verifiedNow},` : ""}
|
|
3598
|
+
});
|
|
3134
3599
|
}` : "";
|
|
3135
3600
|
const hashImport = authNeedsUsers(layers.auth) ? `import { hashPassword } from "@getstrata/core/auth/password";
|
|
3136
3601
|
` : "";
|
|
3137
|
-
const
|
|
3138
|
-
|
|
3139
|
-
`
|
|
3602
|
+
const rlsBypassImport = layers.tenancy === "rls" && layers.database === "postgres" ? `import { runWithMigrationBypass } from "@getstrata/core/tenant/databaseTenantContext";
|
|
3603
|
+
` : "";
|
|
3604
|
+
const postgresRoleImport = layers.database === "postgres" ? `import { grantPostgresAppRolePrivileges, openPostgresAdminConnection, postgresDatabaseNameFromUrl } from "@getstrata/core/tenant/enableTenantRls";
|
|
3605
|
+
` : "";
|
|
3606
|
+
const bindSql = seedTenant || layers.tenancy === "rls" && layers.database === "postgres" ? ` const sql = getSql();
|
|
3607
|
+
` : "";
|
|
3608
|
+
const seedOpen = layers.tenancy === "rls" && layers.database === "postgres" ? ` await runWithMigrationBypass(async () => {
|
|
3609
|
+
` : "";
|
|
3610
|
+
const seedClose = layers.tenancy === "rls" && layers.database === "postgres" ? `
|
|
3611
|
+
});` : "";
|
|
3612
|
+
const noteCreate = tenancyOn ? `await Note.create({ body: "Welcome to Strata!", tenant_id: 1 });` : `await Note.create({ body: "Welcome to Strata!" });`;
|
|
3613
|
+
const userImport = authNeedsUsers(layers.auth) ? `import { User } from "../models/User.ts";
|
|
3614
|
+
` : "";
|
|
3615
|
+
const needsSqlClient = layers.database !== "postgres" || Boolean(seedTenant) || layers.tenancy === "rls" && layers.database === "postgres";
|
|
3616
|
+
const databaseImport = needsSqlClient ? `import { closeDatabase, getSql } from "../bootstrap/database.ts";
|
|
3617
|
+
` : `import { closeDatabase } from "../bootstrap/database.ts";
|
|
3140
3618
|
`;
|
|
3141
|
-
return `${hashImport}${ensureImport(layers)}import {
|
|
3142
|
-
|
|
3143
|
-
|
|
3619
|
+
return `${hashImport}${rlsBypassImport}${postgresRoleImport}${ensureImport(layers)}${databaseImport}import { Note } from "../models/Note.ts";
|
|
3620
|
+
${userImport}
|
|
3144
3621
|
const migrations = [
|
|
3145
3622
|
${list}
|
|
3146
3623
|
];
|
|
3147
3624
|
|
|
3148
3625
|
export async function seed() {
|
|
3149
|
-
${ensureCall(layers)}${bindSql}
|
|
3150
|
-
|
|
3151
|
-
|
|
3626
|
+
${ensureCall(layers)}${bindSql}${seedOpen}${seedTenant}
|
|
3627
|
+
if ((await Note.query().value("id")) === null) {
|
|
3628
|
+
${noteCreate}
|
|
3629
|
+
}${seedUsers}${seedClose}
|
|
3152
3630
|
}
|
|
3153
3631
|
|
|
3154
3632
|
export async function migrate() {
|
|
3155
|
-
${ensureCall(layers)} const
|
|
3633
|
+
${ensureCall(layers)}${layers.database === "postgres" ? ` const runtimeUrl = process.env.DATABASE_URL ?? "";
|
|
3634
|
+
const admin = await openPostgresAdminConnection({
|
|
3635
|
+
runtimeUrl,
|
|
3636
|
+
migrationUrl: process.env.MIGRATION_DATABASE_URL,
|
|
3637
|
+
});
|
|
3638
|
+
try {
|
|
3639
|
+
for (const statement of migrations) {
|
|
3640
|
+
await admin.unsafe(statement);
|
|
3641
|
+
}
|
|
3642
|
+
await grantPostgresAppRolePrivileges(admin, {
|
|
3643
|
+
database: postgresDatabaseNameFromUrl(runtimeUrl),
|
|
3644
|
+
});
|
|
3645
|
+
} finally {
|
|
3646
|
+
await admin.close?.();
|
|
3647
|
+
}
|
|
3648
|
+
` : ` const sql = getSql();
|
|
3156
3649
|
for (const statement of migrations) {
|
|
3157
3650
|
await sql.unsafe(statement);
|
|
3158
3651
|
}
|
|
3159
|
-
await seed();
|
|
3652
|
+
`} await seed();
|
|
3160
3653
|
}
|
|
3161
3654
|
|
|
3162
3655
|
/** The CLI calls this after migrate() so pooled drivers do not hold the process open. */
|
|
@@ -3172,21 +3665,26 @@ if (import.meta.main) {
|
|
|
3172
3665
|
}
|
|
3173
3666
|
`;
|
|
3174
3667
|
}
|
|
3175
|
-
function renderNoteModel() {
|
|
3668
|
+
function renderNoteModel(layers) {
|
|
3669
|
+
const tenancyOn = usesTenantTable(layers.tenancy);
|
|
3670
|
+
const tenantField = tenancyOn ? `
|
|
3671
|
+
tenant_id: number;` : "";
|
|
3672
|
+
const tenantColumn = tenancyOn ? ', "tenant_id"' : "";
|
|
3673
|
+
const fillable = tenancyOn ? '["body", "tenant_id"]' : '["body"]';
|
|
3176
3674
|
return `import { BaseRepository } from "@getstrata/core/database/baseRepository";
|
|
3177
3675
|
import { Model, registerModelRepository } from "@getstrata/core/database/model";
|
|
3178
3676
|
import { defineTable } from "@getstrata/core/database/table";
|
|
3179
3677
|
|
|
3180
3678
|
interface NoteRecord {
|
|
3181
3679
|
id: number;
|
|
3182
|
-
body: string
|
|
3680
|
+
body: string;${tenantField}
|
|
3183
3681
|
created_at: Date | string;
|
|
3184
3682
|
}
|
|
3185
3683
|
|
|
3186
3684
|
const notesTable = defineTable<NoteRecord, "id">({
|
|
3187
3685
|
name: "notes",
|
|
3188
3686
|
primaryKey: "id",
|
|
3189
|
-
columns: ["id", "body", "created_at"],
|
|
3687
|
+
columns: ["id", "body"${tenantColumn}, "created_at"],
|
|
3190
3688
|
defaultOrderBy: { column: "id", direction: "ASC" },
|
|
3191
3689
|
});
|
|
3192
3690
|
|
|
@@ -3197,7 +3695,7 @@ class NoteRepository extends BaseRepository<NoteRecord, "id"> {
|
|
|
3197
3695
|
}
|
|
3198
3696
|
|
|
3199
3697
|
class Note extends Model<NoteRecord, "id"> {
|
|
3200
|
-
static $fillable =
|
|
3698
|
+
static $fillable = ${fillable} as const;
|
|
3201
3699
|
// created_at uses the table default. Sending a JS Date from $timestamps
|
|
3202
3700
|
// is rejected by SQLite bindings.
|
|
3203
3701
|
static $timestamps = false;
|
|
@@ -3209,6 +3707,160 @@ export type { NoteRecord };
|
|
|
3209
3707
|
export { Note };
|
|
3210
3708
|
`;
|
|
3211
3709
|
}
|
|
3710
|
+
function renderUserModel(layers) {
|
|
3711
|
+
const tenancyOn = usesTenantTable(layers.tenancy) || layers.extras.scim;
|
|
3712
|
+
const mfaOn = Boolean(layers.extras.mfa);
|
|
3713
|
+
const tenantField = tenancyOn ? `
|
|
3714
|
+
tenant_id: number;` : "";
|
|
3715
|
+
const mfaFields = mfaOn ? `
|
|
3716
|
+
mfa_secret: string | null;
|
|
3717
|
+
mfa_enabled: number | boolean;
|
|
3718
|
+
mfa_recovery_codes: string | null;` : "";
|
|
3719
|
+
const columns = ["id", "name", "email", "password", "is_admin"];
|
|
3720
|
+
if (tenancyOn) {
|
|
3721
|
+
columns.push("tenant_id");
|
|
3722
|
+
}
|
|
3723
|
+
if (mfaOn) {
|
|
3724
|
+
columns.push("mfa_secret", "mfa_enabled", "mfa_recovery_codes");
|
|
3725
|
+
}
|
|
3726
|
+
columns.push("session_valid_after", "email_verified_at", "created_at");
|
|
3727
|
+
const fillable = ["name", "email", "password", "is_admin"];
|
|
3728
|
+
if (tenancyOn) {
|
|
3729
|
+
fillable.push("tenant_id");
|
|
3730
|
+
}
|
|
3731
|
+
if (mfaOn) {
|
|
3732
|
+
fillable.push("mfa_secret", "mfa_enabled", "mfa_recovery_codes");
|
|
3733
|
+
}
|
|
3734
|
+
fillable.push("session_valid_after", "email_verified_at");
|
|
3735
|
+
const hidden = ["password"];
|
|
3736
|
+
if (mfaOn) {
|
|
3737
|
+
hidden.push("mfa_secret", "mfa_recovery_codes");
|
|
3738
|
+
}
|
|
3739
|
+
const fillableLiteral = `[${fillable.map((column) => `"${column}"`).join(", ")}]`;
|
|
3740
|
+
const hiddenLiteral = `[${hidden.map((column) => `"${column}"`).join(", ")}]`;
|
|
3741
|
+
const columnsLiteral = columns.map((column) => `"${column}"`).join(", ");
|
|
3742
|
+
return `import { BaseRepository } from "@getstrata/core/database/baseRepository";
|
|
3743
|
+
import { Model, registerModelRepository } from "@getstrata/core/database/model";
|
|
3744
|
+
import { defineTable } from "@getstrata/core/database/table";
|
|
3745
|
+
|
|
3746
|
+
interface UserRecord {
|
|
3747
|
+
id: number;
|
|
3748
|
+
name: string;
|
|
3749
|
+
email: string;
|
|
3750
|
+
password: string;
|
|
3751
|
+
is_admin: number | boolean;${tenantField}${mfaFields}
|
|
3752
|
+
session_valid_after: Date | string | null;
|
|
3753
|
+
email_verified_at: Date | string | null;
|
|
3754
|
+
created_at: Date | string;
|
|
3755
|
+
}
|
|
3756
|
+
|
|
3757
|
+
const usersTable = defineTable<UserRecord, "id">({
|
|
3758
|
+
name: "users",
|
|
3759
|
+
primaryKey: "id",
|
|
3760
|
+
columns: [${columnsLiteral}],
|
|
3761
|
+
defaultOrderBy: { column: "id", direction: "ASC" },
|
|
3762
|
+
});
|
|
3763
|
+
|
|
3764
|
+
class UserRepository extends BaseRepository<UserRecord, "id"> {
|
|
3765
|
+
constructor() {
|
|
3766
|
+
super(usersTable);
|
|
3767
|
+
}
|
|
3768
|
+
}
|
|
3769
|
+
|
|
3770
|
+
class User extends Model<UserRecord, "id"> {
|
|
3771
|
+
static $fillable = ${fillableLiteral} as const;
|
|
3772
|
+
static $hidden = ${hiddenLiteral} as const;
|
|
3773
|
+
// created_at uses the table default. Sending a JS Date from $timestamps
|
|
3774
|
+
// is rejected by SQLite bindings.
|
|
3775
|
+
static $timestamps = false;
|
|
3776
|
+
}
|
|
3777
|
+
|
|
3778
|
+
registerModelRepository(User, new UserRepository());
|
|
3779
|
+
|
|
3780
|
+
export type { UserRecord };
|
|
3781
|
+
export { User };
|
|
3782
|
+
`;
|
|
3783
|
+
}
|
|
3784
|
+
function renderApiTokenModel() {
|
|
3785
|
+
return `import { BaseRepository } from "@getstrata/core/database/baseRepository";
|
|
3786
|
+
import { Model, registerModelRepository } from "@getstrata/core/database/model";
|
|
3787
|
+
import { defineTable } from "@getstrata/core/database/table";
|
|
3788
|
+
|
|
3789
|
+
interface ApiTokenRecord {
|
|
3790
|
+
id: number;
|
|
3791
|
+
user_id: number;
|
|
3792
|
+
name: string;
|
|
3793
|
+
token_hash: string;
|
|
3794
|
+
abilities: string;
|
|
3795
|
+
expires_at: Date | string | null;
|
|
3796
|
+
last_used_at: Date | string | null;
|
|
3797
|
+
created_at: Date | string;
|
|
3798
|
+
}
|
|
3799
|
+
|
|
3800
|
+
const apiTokensTable = defineTable<ApiTokenRecord, "id">({
|
|
3801
|
+
name: "api_tokens",
|
|
3802
|
+
primaryKey: "id",
|
|
3803
|
+
columns: ["id", "user_id", "name", "token_hash", "abilities", "expires_at", "last_used_at", "created_at"],
|
|
3804
|
+
defaultOrderBy: { column: "id", direction: "ASC" },
|
|
3805
|
+
});
|
|
3806
|
+
|
|
3807
|
+
class ApiTokenRepository extends BaseRepository<ApiTokenRecord, "id"> {
|
|
3808
|
+
constructor() {
|
|
3809
|
+
super(apiTokensTable);
|
|
3810
|
+
}
|
|
3811
|
+
}
|
|
3812
|
+
|
|
3813
|
+
class ApiToken extends Model<ApiTokenRecord, "id"> {
|
|
3814
|
+
static $fillable = ["user_id", "name", "token_hash", "abilities", "expires_at", "last_used_at"] as const;
|
|
3815
|
+
// created_at uses the table default. Sending a JS Date from $timestamps
|
|
3816
|
+
// is rejected by SQLite bindings.
|
|
3817
|
+
static $timestamps = false;
|
|
3818
|
+
}
|
|
3819
|
+
|
|
3820
|
+
registerModelRepository(ApiToken, new ApiTokenRepository());
|
|
3821
|
+
|
|
3822
|
+
export type { ApiTokenRecord };
|
|
3823
|
+
export { ApiToken };
|
|
3824
|
+
`;
|
|
3825
|
+
}
|
|
3826
|
+
function renderAuthOneTimeTokenModel() {
|
|
3827
|
+
return `import { BaseRepository } from "@getstrata/core/database/baseRepository";
|
|
3828
|
+
import { Model, registerModelRepository } from "@getstrata/core/database/model";
|
|
3829
|
+
import { defineTable } from "@getstrata/core/database/table";
|
|
3830
|
+
|
|
3831
|
+
interface AuthOneTimeTokenRecord {
|
|
3832
|
+
id: number;
|
|
3833
|
+
purpose: string;
|
|
3834
|
+
user_id: number;
|
|
3835
|
+
token_hash: string;
|
|
3836
|
+
expires_at: Date | string;
|
|
3837
|
+
consumed_at: Date | string | null;
|
|
3838
|
+
}
|
|
3839
|
+
|
|
3840
|
+
const authOneTimeTokensTable = defineTable<AuthOneTimeTokenRecord, "id">({
|
|
3841
|
+
name: "auth_one_time_tokens",
|
|
3842
|
+
primaryKey: "id",
|
|
3843
|
+
columns: ["id", "purpose", "user_id", "token_hash", "expires_at", "consumed_at"],
|
|
3844
|
+
defaultOrderBy: { column: "id", direction: "ASC" },
|
|
3845
|
+
});
|
|
3846
|
+
|
|
3847
|
+
class AuthOneTimeTokenRepository extends BaseRepository<AuthOneTimeTokenRecord, "id"> {
|
|
3848
|
+
constructor() {
|
|
3849
|
+
super(authOneTimeTokensTable);
|
|
3850
|
+
}
|
|
3851
|
+
}
|
|
3852
|
+
|
|
3853
|
+
class AuthOneTimeToken extends Model<AuthOneTimeTokenRecord, "id"> {
|
|
3854
|
+
static $fillable = ["purpose", "user_id", "token_hash", "expires_at", "consumed_at"] as const;
|
|
3855
|
+
static $timestamps = false;
|
|
3856
|
+
}
|
|
3857
|
+
|
|
3858
|
+
registerModelRepository(AuthOneTimeToken, new AuthOneTimeTokenRepository());
|
|
3859
|
+
|
|
3860
|
+
export type { AuthOneTimeTokenRecord };
|
|
3861
|
+
export { AuthOneTimeToken };
|
|
3862
|
+
`;
|
|
3863
|
+
}
|
|
3212
3864
|
function dropTables(layers) {
|
|
3213
3865
|
const ordered = [];
|
|
3214
3866
|
if (authUsesToken(layers.auth)) {
|
|
@@ -3216,8 +3868,10 @@ function dropTables(layers) {
|
|
|
3216
3868
|
}
|
|
3217
3869
|
if (authUsesCookie(layers.auth)) {
|
|
3218
3870
|
ordered.push("sessions");
|
|
3871
|
+
ordered.push("auth_saml_assertions");
|
|
3219
3872
|
}
|
|
3220
3873
|
if (authNeedsUsers(layers.auth)) {
|
|
3874
|
+
ordered.push("auth_one_time_tokens");
|
|
3221
3875
|
ordered.push("users");
|
|
3222
3876
|
}
|
|
3223
3877
|
ordered.push("notes");
|
|
@@ -3226,20 +3880,31 @@ function dropTables(layers) {
|
|
|
3226
3880
|
}
|
|
3227
3881
|
return ordered;
|
|
3228
3882
|
}
|
|
3883
|
+
function renderPostgresAdminDropCall() {
|
|
3884
|
+
return ` await dropPostgresTablesAsAdmin(tables, {
|
|
3885
|
+
runtimeUrl: process.env.DATABASE_URL ?? "",
|
|
3886
|
+
migrationUrl: process.env.MIGRATION_DATABASE_URL,
|
|
3887
|
+
});
|
|
3888
|
+
`;
|
|
3889
|
+
}
|
|
3229
3890
|
function renderFreshTs(layers) {
|
|
3230
3891
|
const tables = dropTables(layers);
|
|
3231
3892
|
const cascade = layers.database === "sqlite" ? "" : " CASCADE";
|
|
3232
|
-
|
|
3233
|
-
import {
|
|
3893
|
+
const imports = layers.database === "postgres" ? `import { dropPostgresTablesAsAdmin } from "@getstrata/core/tenant/enableTenantRls";
|
|
3894
|
+
${ensureImport(layers)}import { closeDatabase } from "../bootstrap/database.ts";
|
|
3895
|
+
import { migrate } from "./migrate.ts";` : `${ensureImport(layers)}import { closeDatabase, getSql } from "../bootstrap/database.ts";
|
|
3896
|
+
import { migrate } from "./migrate.ts";`;
|
|
3897
|
+
const dropBody = layers.database === "postgres" ? `${renderPostgresAdminDropCall()} await migrate();` : ` const sql = getSql();
|
|
3898
|
+
for (const table of tables) {
|
|
3899
|
+
await sql.unsafe(\`DROP TABLE IF EXISTS \${table}${cascade}\`);
|
|
3900
|
+
}
|
|
3901
|
+
await migrate();`;
|
|
3902
|
+
return `${imports}
|
|
3234
3903
|
|
|
3235
3904
|
const tables = ${JSON.stringify(tables)};
|
|
3236
3905
|
|
|
3237
3906
|
export async function fresh() {
|
|
3238
|
-
${ensureCall(layers)}
|
|
3239
|
-
for (const table of tables) {
|
|
3240
|
-
await sql.unsafe(\`DROP TABLE IF EXISTS \${table}${cascade}\`);
|
|
3241
|
-
}
|
|
3242
|
-
await migrate();
|
|
3907
|
+
${ensureCall(layers)}${dropBody}
|
|
3243
3908
|
}
|
|
3244
3909
|
|
|
3245
3910
|
/** The CLI calls this after fresh() so pooled drivers do not hold the process open. */
|
|
@@ -3297,16 +3962,21 @@ if (import.meta.main) {
|
|
|
3297
3962
|
function renderRollbackTs(layers) {
|
|
3298
3963
|
const tables = dropTables(layers);
|
|
3299
3964
|
const cascade = layers.database === "sqlite" ? "" : " CASCADE";
|
|
3300
|
-
|
|
3965
|
+
const imports = layers.database === "postgres" ? `import { dropPostgresTablesAsAdmin } from "@getstrata/core/tenant/enableTenantRls";
|
|
3966
|
+
${ensureImport(layers)}` : `${ensureImport(layers)}import { getSql } from "../bootstrap/database.ts";`;
|
|
3967
|
+
const dropBody = layers.database === "postgres" ? `${renderPostgresAdminDropCall()} for (const table of tables) {
|
|
3968
|
+
console.log(\`dropped \${table}\`);
|
|
3969
|
+
}` : ` const sql = getSql();
|
|
3970
|
+
for (const table of tables) {
|
|
3971
|
+
await sql.unsafe(\`DROP TABLE IF EXISTS \${table}${cascade}\`);
|
|
3972
|
+
console.log(\`dropped \${table}\`);
|
|
3973
|
+
}`;
|
|
3974
|
+
return `${imports}
|
|
3301
3975
|
|
|
3302
3976
|
const tables = ${JSON.stringify(tables)};
|
|
3303
3977
|
|
|
3304
3978
|
export async function rollback() {
|
|
3305
|
-
${ensureCall(layers)}
|
|
3306
|
-
for (const table of tables) {
|
|
3307
|
-
await sql.unsafe(\`DROP TABLE IF EXISTS \${table}${cascade}\`);
|
|
3308
|
-
console.log(\`dropped \${table}\`);
|
|
3309
|
-
}
|
|
3979
|
+
${ensureCall(layers)}${dropBody}
|
|
3310
3980
|
}
|
|
3311
3981
|
|
|
3312
3982
|
if (import.meta.main) {
|
|
@@ -3317,13 +3987,15 @@ if (import.meta.main) {
|
|
|
3317
3987
|
`;
|
|
3318
3988
|
}
|
|
3319
3989
|
function renderPreloadTs(layers, projectName) {
|
|
3320
|
-
const
|
|
3321
|
-
const
|
|
3990
|
+
const fallback = defaultDatabaseUrl(layers, projectName);
|
|
3991
|
+
const migrationFallback = defaultMigrationDatabaseUrl(layers, projectName);
|
|
3992
|
+
const migrationLine = migrationFallback ? `process.env.MIGRATION_DATABASE_URL ??= ${JSON.stringify(migrationFallback)};
|
|
3993
|
+
` : "";
|
|
3322
3994
|
return `import { join } from "node:path";
|
|
3323
3995
|
import { configureModulesDirectory } from "@getstrata/bootstrap/discoverModules";
|
|
3324
3996
|
|
|
3325
3997
|
process.env.DATABASE_URL ??= ${JSON.stringify(fallback)};
|
|
3326
|
-
process.env.FRONTEND_MODE ??= ${JSON.stringify(layers.frontend)};
|
|
3998
|
+
${migrationLine}process.env.FRONTEND_MODE ??= ${JSON.stringify(layers.frontend)};
|
|
3327
3999
|
process.env.SPA_PREFIX ??= ${JSON.stringify(layers.spaPrefix)};
|
|
3328
4000
|
process.env.CACHE_DRIVER ??= ${JSON.stringify(layers.cache)};
|
|
3329
4001
|
process.env.QUEUE_DRIVER ??= ${JSON.stringify(layers.queue)};
|
|
@@ -3395,7 +4067,8 @@ export default configProvider;
|
|
|
3395
4067
|
`;
|
|
3396
4068
|
}
|
|
3397
4069
|
function renderQueueProvider() {
|
|
3398
|
-
return `import
|
|
4070
|
+
return `import { registerDefaultJobs } from "@getstrata/bootstrap/queue/defaultJobs";
|
|
4071
|
+
import type { ServiceProvider } from "@getstrata/core/contracts/di";
|
|
3399
4072
|
import { CORE_QUEUE_TOKEN } from "@getstrata/core/contracts/serviceTokens";
|
|
3400
4073
|
import {
|
|
3401
4074
|
createAppQueue,
|
|
@@ -3409,7 +4082,10 @@ const queueProvider: ServiceProvider = {
|
|
|
3409
4082
|
const driver = (process.env.QUEUE_DRIVER ?? "sync") as "sync" | "async" | "redis";
|
|
3410
4083
|
const failedJobs = createFailedJobService();
|
|
3411
4084
|
container.set(FAILED_JOB_SERVICE_TOKEN, failedJobs);
|
|
3412
|
-
container.set(
|
|
4085
|
+
container.set(
|
|
4086
|
+
CORE_QUEUE_TOKEN,
|
|
4087
|
+
createAppQueue(driver, process.env.REDIS_URL, failedJobs, registerDefaultJobs),
|
|
4088
|
+
);
|
|
3413
4089
|
},
|
|
3414
4090
|
};
|
|
3415
4091
|
|
|
@@ -3420,11 +4096,10 @@ function renderEnsureDatabaseTs(layers, projectName) {
|
|
|
3420
4096
|
if (layers.database === "sqlite") {
|
|
3421
4097
|
return null;
|
|
3422
4098
|
}
|
|
3423
|
-
const
|
|
3424
|
-
const fallback = layers.database === "mysql" ? `mysql://root:root@localhost:3306/${database}` : `postgresql://postgres:postgres@localhost:5432/${database}`;
|
|
4099
|
+
const fallback = defaultDatabaseUrl(layers, projectName);
|
|
3425
4100
|
const resolveUrl = `/**
|
|
3426
|
-
*
|
|
3427
|
-
*
|
|
4101
|
+
* Runtime URL. Superuser CREATE ROLE / CREATE DATABASE / GRANT / migrate uses
|
|
4102
|
+
* MIGRATION_DATABASE_URL when set.
|
|
3428
4103
|
*/
|
|
3429
4104
|
function resolveAppDatabaseUrl(): string {
|
|
3430
4105
|
const explicit = process.env.APP_DATABASE_URL?.trim();
|
|
@@ -3433,8 +4108,8 @@ function resolveAppDatabaseUrl(): string {
|
|
|
3433
4108
|
}
|
|
3434
4109
|
return process.env.DATABASE_URL?.trim() || DEFAULT_DATABASE_URL;
|
|
3435
4110
|
}
|
|
3436
|
-
|
|
3437
|
-
|
|
4111
|
+
`;
|
|
4112
|
+
const safeName = `/** Reject anything we would have to quote before interpolating into DDL. */
|
|
3438
4113
|
function safeDatabaseName(url: string): string {
|
|
3439
4114
|
let name = "";
|
|
3440
4115
|
try {
|
|
@@ -3456,7 +4131,7 @@ function safeDatabaseName(url: string): string {
|
|
|
3456
4131
|
|
|
3457
4132
|
const DEFAULT_DATABASE_URL = ${JSON.stringify(fallback)};
|
|
3458
4133
|
|
|
3459
|
-
${resolveUrl}
|
|
4134
|
+
${resolveUrl}${safeName}
|
|
3460
4135
|
export async function ensureAppDatabase(): Promise<string> {
|
|
3461
4136
|
const url = resolveAppDatabaseUrl();
|
|
3462
4137
|
const name = safeDatabaseName(url);
|
|
@@ -3475,58 +4150,21 @@ export async function ensureAppDatabase(): Promise<string> {
|
|
|
3475
4150
|
}
|
|
3476
4151
|
`;
|
|
3477
4152
|
}
|
|
3478
|
-
return `
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
const names = ["postgres", "template1"];
|
|
3483
|
-
try {
|
|
3484
|
-
const current = decodeURIComponent(new URL(url).pathname.replace(/^\\//, ""));
|
|
3485
|
-
if (current && !names.includes(current)) {
|
|
3486
|
-
names.push(current);
|
|
3487
|
-
}
|
|
3488
|
-
} catch {
|
|
3489
|
-
}
|
|
3490
|
-
return names.map((name) => {
|
|
3491
|
-
const admin = new URL(url);
|
|
3492
|
-
admin.pathname = \`/\${name}\`;
|
|
3493
|
-
return admin.toString();
|
|
3494
|
-
});
|
|
3495
|
-
}
|
|
4153
|
+
return `import {
|
|
4154
|
+
ensurePostgresDatabaseAndAppRole,
|
|
4155
|
+
POSTGRES_APP_ROLE_PASSWORD,
|
|
4156
|
+
} from "@getstrata/core/tenant/enableTenantRls";
|
|
3496
4157
|
|
|
3497
|
-
|
|
3498
|
-
let lastError: unknown;
|
|
3499
|
-
for (const candidate of adminCandidateUrls(url)) {
|
|
3500
|
-
const adminSql = new Bun.SQL(candidate);
|
|
3501
|
-
try {
|
|
3502
|
-
await adminSql\`SELECT 1\`;
|
|
3503
|
-
return adminSql;
|
|
3504
|
-
} catch (error) {
|
|
3505
|
-
lastError = error;
|
|
3506
|
-
await adminSql.close().catch(() => undefined);
|
|
3507
|
-
}
|
|
3508
|
-
}
|
|
3509
|
-
throw lastError instanceof Error
|
|
3510
|
-
? lastError
|
|
3511
|
-
: new Error("Could not open an admin connection to create the app database.");
|
|
3512
|
-
}
|
|
4158
|
+
const DEFAULT_DATABASE_URL = ${JSON.stringify(fallback)};
|
|
3513
4159
|
|
|
4160
|
+
${resolveUrl}
|
|
3514
4161
|
export async function ensureAppDatabase(): Promise<string> {
|
|
3515
4162
|
const url = resolveAppDatabaseUrl();
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
SELECT 1 AS ok FROM pg_database WHERE datname = \${name}
|
|
3522
|
-
\`;
|
|
3523
|
-
if (rows.length === 0) {
|
|
3524
|
-
await adminSql.unsafe(\`CREATE DATABASE \${name}\`);
|
|
3525
|
-
}
|
|
3526
|
-
} finally {
|
|
3527
|
-
await adminSql.close();
|
|
3528
|
-
}
|
|
3529
|
-
|
|
4163
|
+
await ensurePostgresDatabaseAndAppRole({
|
|
4164
|
+
runtimeUrl: url,
|
|
4165
|
+
migrationUrl: process.env.MIGRATION_DATABASE_URL,
|
|
4166
|
+
password: process.env.STRATA_APP_PASSWORD ?? POSTGRES_APP_ROLE_PASSWORD,
|
|
4167
|
+
});
|
|
3530
4168
|
process.env.DATABASE_URL = url;
|
|
3531
4169
|
return url;
|
|
3532
4170
|
}
|
|
@@ -3535,20 +4173,64 @@ export async function ensureAppDatabase(): Promise<string> {
|
|
|
3535
4173
|
function renderSidecarsTs(_layers) {
|
|
3536
4174
|
return null;
|
|
3537
4175
|
}
|
|
4176
|
+
function renderPolicyProvider() {
|
|
4177
|
+
return `import { CORE_POLICY_GATE_TOKEN } from "@getstrata/bootstrap/config";
|
|
4178
|
+
import { PolicyGate } from "@getstrata/core/auth/policy";
|
|
4179
|
+
import type { ServiceProvider } from "@getstrata/core/contracts/di";
|
|
4180
|
+
|
|
4181
|
+
const policyProvider: ServiceProvider = {
|
|
4182
|
+
name: "core.policy",
|
|
4183
|
+
register({ container }) {
|
|
4184
|
+
container.set(CORE_POLICY_GATE_TOKEN, new PolicyGate());
|
|
4185
|
+
},
|
|
4186
|
+
};
|
|
4187
|
+
|
|
4188
|
+
export default policyProvider;
|
|
4189
|
+
`;
|
|
4190
|
+
}
|
|
3538
4191
|
function renderProvidersIndex() {
|
|
3539
|
-
return `import
|
|
4192
|
+
return `import { discoverListeners } from "@getstrata/bootstrap/discoverListeners";
|
|
4193
|
+
import { registerInvalidateCacheOnModelWriteListeners } from "@getstrata/bootstrap/listeners/invalidateCacheOnModelWrite";
|
|
4194
|
+
import type { ServiceProvider } from "@getstrata/core/contracts/di";
|
|
3540
4195
|
import authProvider from "./auth.ts";
|
|
3541
4196
|
import cacheProvider from "./cache.ts";
|
|
3542
4197
|
import configProvider from "./config.ts";
|
|
4198
|
+
import policyProvider from "./policy.ts";
|
|
3543
4199
|
import queueProvider from "./queue.ts";
|
|
3544
4200
|
import storageProvider from "./storage.ts";
|
|
3545
4201
|
|
|
4202
|
+
const registeredListenerGroups = new Set<string>();
|
|
4203
|
+
|
|
4204
|
+
function registerListenerGroup(name: string, register: () => void): void {
|
|
4205
|
+
if (registeredListenerGroups.has(name)) {
|
|
4206
|
+
return;
|
|
4207
|
+
}
|
|
4208
|
+
|
|
4209
|
+
registeredListenerGroups.add(name);
|
|
4210
|
+
register();
|
|
4211
|
+
}
|
|
4212
|
+
|
|
4213
|
+
const listenersProvider: ServiceProvider = {
|
|
4214
|
+
name: "starter.listeners",
|
|
4215
|
+
boot() {
|
|
4216
|
+
registerListenerGroup("cache.invalidate-on-model-write", () => {
|
|
4217
|
+
registerInvalidateCacheOnModelWriteListeners();
|
|
4218
|
+
});
|
|
4219
|
+
|
|
4220
|
+
for (const registerListener of discoverListeners()) {
|
|
4221
|
+
registerListener();
|
|
4222
|
+
}
|
|
4223
|
+
},
|
|
4224
|
+
};
|
|
4225
|
+
|
|
3546
4226
|
const starterProviders: ServiceProvider[] = [
|
|
3547
4227
|
configProvider,
|
|
3548
4228
|
cacheProvider,
|
|
3549
4229
|
storageProvider,
|
|
3550
4230
|
queueProvider,
|
|
3551
4231
|
authProvider,
|
|
4232
|
+
policyProvider,
|
|
4233
|
+
listenersProvider,
|
|
3552
4234
|
];
|
|
3553
4235
|
|
|
3554
4236
|
export { starterProviders };
|
|
@@ -3577,13 +4259,15 @@ import {
|
|
|
3577
4259
|
import { mergeSpaRoutes } from "@getstrata/bootstrap/createSpaRoutes";
|
|
3578
4260
|
import {
|
|
3579
4261
|
configureModulesDirectory,
|
|
4262
|
+
discoverModules,
|
|
3580
4263
|
ensureModulesLoaded,
|
|
3581
4264
|
} from "@getstrata/bootstrap/discoverModules";
|
|
3582
4265
|
import { createHealthRoutes } from "@getstrata/bootstrap/health";
|
|
3583
|
-
${metricsImport}import { assertProductionSecrets } from "@getstrata/bootstrap/secretsGuard";
|
|
4266
|
+
${metricsImport}import { assertProductionSecrets, assertRlsLiveDatabaseRole } from "@getstrata/bootstrap/secretsGuard";
|
|
3584
4267
|
import { createWebServer } from "@getstrata/bootstrap/web/server";
|
|
3585
4268
|
import { setActiveApplicationContext } from "@getstrata/core/runtime/applicationRegistry";
|
|
3586
4269
|
import { isProductionEnv } from "@getstrata/core/runtime/appEnv";
|
|
4270
|
+
import { isRlsTenancy } from "@getstrata/core/tenant/tenancyConfig";
|
|
3587
4271
|
import { migrate } from "../db/migrate.ts";
|
|
3588
4272
|
import { buildRoutes } from "../routes.ts";
|
|
3589
4273
|
import { loadConfig } from "./config.ts";
|
|
@@ -3634,6 +4318,10 @@ function createAppContext(): AppContext {
|
|
|
3634
4318
|
runProviderPhase(starterProviders, "register", context);
|
|
3635
4319
|
runProviderPhase(starterProviders, "boot", context);
|
|
3636
4320
|
|
|
4321
|
+
const moduleProviders = discoverModules().flatMap((module) => module.providers ?? []);
|
|
4322
|
+
runProviderPhase(moduleProviders, "register", context);
|
|
4323
|
+
runProviderPhase(moduleProviders, "boot", context);
|
|
4324
|
+
|
|
3637
4325
|
assertAppDependenciesComplete(dependencies);
|
|
3638
4326
|
|
|
3639
4327
|
const appContext = { container, config, dependencies: dependencies as AppDependencies };
|
|
@@ -3654,6 +4342,9 @@ export async function bootstrapApp(options: BootstrapOptions = {}): Promise<Boot
|
|
|
3654
4342
|
${needsEnsure(layers) ? ` await ensureAppDatabase();
|
|
3655
4343
|
` : ""} const appConfig = loadConfig();
|
|
3656
4344
|
getSql();
|
|
4345
|
+
if (isRlsTenancy()) {
|
|
4346
|
+
await assertRlsLiveDatabaseRole();
|
|
4347
|
+
}
|
|
3657
4348
|
configureModulesDirectory(join(import.meta.dir, "../modules"));
|
|
3658
4349
|
await ensureModulesLoaded();
|
|
3659
4350
|
const context = createAppContext();
|
|
@@ -3754,36 +4445,22 @@ export function plainText(body: string, status = 200): Response {
|
|
|
3754
4445
|
}
|
|
3755
4446
|
|
|
3756
4447
|
// src/renderScim.ts
|
|
3757
|
-
function ph2(layers, count, start = 1) {
|
|
3758
|
-
if (layers.database === "postgres") {
|
|
3759
|
-
return Array.from({ length: count }, (_, index) => `$${start + index}`).join(", ");
|
|
3760
|
-
}
|
|
3761
|
-
return Array.from({ length: count }, () => "?").join(", ");
|
|
3762
|
-
}
|
|
3763
|
-
function sqlFalse2(layers) {
|
|
3764
|
-
return layers.database === "postgres" ? "false" : "0";
|
|
3765
|
-
}
|
|
3766
4448
|
function renderScimModule(layers) {
|
|
3767
4449
|
if (!layers.extras.scim || !authNeedsUsers(layers.auth)) {
|
|
3768
4450
|
return null;
|
|
3769
4451
|
}
|
|
3770
|
-
const tenantOn = usesTenantTable(layers.tenancy);
|
|
3771
|
-
const insertCols = tenantOn ? "name, email, password, is_admin, tenant_id" : "name, email, password, is_admin";
|
|
3772
|
-
const insertPh = tenantOn ? ph2(layers, 5) : ph2(layers, 4);
|
|
3773
|
-
const insertTail = tenantOn ? `, ${sqlFalse2(layers)}, tenantId` : `, ${sqlFalse2(layers)}`;
|
|
3774
|
-
const emailPh = ph2(layers, 1);
|
|
3775
|
-
const idPh = ph2(layers, 1);
|
|
3776
|
-
const updatePh = `${ph2(layers, 1)}, ${ph2(layers, 1, 2)}, ${ph2(layers, 1, 3)}`;
|
|
3777
4452
|
return `import { randomBytes } from "node:crypto";
|
|
3778
4453
|
import type { AppModule } from "@getstrata/bootstrap/contracts";
|
|
3779
4454
|
import { routeParams } from "@getstrata/bootstrap/web/routing";
|
|
3780
4455
|
import { createScimAuthMiddleware } from "@getstrata/core/auth/scimAuthMiddleware";
|
|
3781
4456
|
import { hashPassword } from "@getstrata/core/auth/password";
|
|
4457
|
+
import { BadRequestError } from "@getstrata/core/errors/http";
|
|
4458
|
+
import { parsePositiveIntParam } from "@getstrata/core/http/validation";
|
|
3782
4459
|
import { withErrorHandling } from "@getstrata/core/http/response";
|
|
3783
4460
|
import { withMiddleware } from "@getstrata/core/http/routeMiddleware";
|
|
3784
4461
|
import { createScimThrottleMiddleware } from "@getstrata/core/http/scimThrottleMiddleware";
|
|
3785
4462
|
import { currentTenant } from "@getstrata/core/tenant/tenantContext";
|
|
3786
|
-
import {
|
|
4463
|
+
import { User } from "../../models/User.ts";
|
|
3787
4464
|
|
|
3788
4465
|
const USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User";
|
|
3789
4466
|
const LIST_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
|
|
@@ -3791,12 +4468,29 @@ const ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error";
|
|
|
3791
4468
|
const PATCH_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
|
|
3792
4469
|
const CONFIG_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig";
|
|
3793
4470
|
|
|
3794
|
-
type UserRow = { id: number; name: string; email: string };
|
|
3795
|
-
|
|
3796
4471
|
function scimEnabled(): boolean {
|
|
3797
4472
|
return (process.env.FEATURE_SCIM ?? "false") === "true";
|
|
3798
4473
|
}
|
|
3799
4474
|
|
|
4475
|
+
function tenantId(): number {
|
|
4476
|
+
const tenant = currentTenant();
|
|
4477
|
+
if (!tenant) {
|
|
4478
|
+
throw new Error("SCIM requires a tenant context.");
|
|
4479
|
+
}
|
|
4480
|
+
return tenant.id;
|
|
4481
|
+
}
|
|
4482
|
+
|
|
4483
|
+
function readScimUserId(request: Request): number | Response {
|
|
4484
|
+
try {
|
|
4485
|
+
return parsePositiveIntParam(routeParams(request).id ?? "", "id");
|
|
4486
|
+
} catch (error) {
|
|
4487
|
+
if (error instanceof BadRequestError) {
|
|
4488
|
+
return scimError("Invalid id.", 400);
|
|
4489
|
+
}
|
|
4490
|
+
throw error;
|
|
4491
|
+
}
|
|
4492
|
+
}
|
|
4493
|
+
|
|
3800
4494
|
function scimJson(body: unknown, status = 200): Response {
|
|
3801
4495
|
return Response.json(body, {
|
|
3802
4496
|
status,
|
|
@@ -3808,13 +4502,15 @@ function scimError(detail: string, status: number): Response {
|
|
|
3808
4502
|
return scimJson({ schemas: [ERROR_SCHEMA], detail, status: String(status) }, status);
|
|
3809
4503
|
}
|
|
3810
4504
|
|
|
3811
|
-
function toScimUser(
|
|
4505
|
+
function toScimUser(user: { get(key: "id" | "name" | "email"): unknown }) {
|
|
4506
|
+
const email = String(user.get("email") ?? "");
|
|
4507
|
+
const name = String(user.get("name") ?? "");
|
|
3812
4508
|
return {
|
|
3813
4509
|
schemas: [USER_SCHEMA],
|
|
3814
|
-
id: String(
|
|
3815
|
-
userName:
|
|
3816
|
-
name: { formatted:
|
|
3817
|
-
emails: [{ value:
|
|
4510
|
+
id: String(user.get("id")),
|
|
4511
|
+
userName: email,
|
|
4512
|
+
name: { formatted: name },
|
|
4513
|
+
emails: [{ value: email, primary: true }],
|
|
3818
4514
|
active: true,
|
|
3819
4515
|
meta: { resourceType: "User" },
|
|
3820
4516
|
};
|
|
@@ -3904,24 +4600,29 @@ const scimModule: AppModule = {
|
|
|
3904
4600
|
const url = new URL(request.url);
|
|
3905
4601
|
const filter = url.searchParams.get("filter") ?? "";
|
|
3906
4602
|
const match = /userName\\s+eq\\s+"([^"]+)"/i.exec(filter);
|
|
3907
|
-
|
|
4603
|
+
const startIndex = Math.max(1, Number.parseInt(url.searchParams.get("startIndex") ?? "1", 10) || 1);
|
|
4604
|
+
const count = Math.min(200, Math.max(1, Number.parseInt(url.searchParams.get("count") ?? "200", 10) || 200));
|
|
3908
4605
|
if (match?.[1]) {
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
);
|
|
3913
|
-
|
|
3914
|
-
|
|
4606
|
+
const email = match[1].trim().toLowerCase();
|
|
4607
|
+
const user = await User.where({ email, tenant_id: tenantId() }).first();
|
|
4608
|
+
const users = user ? [user] : [];
|
|
4609
|
+
const slice = users.slice(startIndex - 1, startIndex - 1 + count);
|
|
4610
|
+
return scimJson({
|
|
4611
|
+
schemas: [LIST_SCHEMA],
|
|
4612
|
+
totalResults: users.length,
|
|
4613
|
+
startIndex,
|
|
4614
|
+
itemsPerPage: slice.length,
|
|
4615
|
+
Resources: slice.map(toScimUser),
|
|
4616
|
+
});
|
|
3915
4617
|
}
|
|
3916
|
-
const
|
|
3917
|
-
const
|
|
3918
|
-
const slice = rows.slice(startIndex - 1, startIndex - 1 + count);
|
|
4618
|
+
const totalResults = await User.where({ tenant_id: tenantId() }).count();
|
|
4619
|
+
const page = await User.where({ tenant_id: tenantId() }).offset(startIndex - 1).limit(count).get();
|
|
3919
4620
|
return scimJson({
|
|
3920
4621
|
schemas: [LIST_SCHEMA],
|
|
3921
|
-
totalResults
|
|
4622
|
+
totalResults,
|
|
3922
4623
|
startIndex,
|
|
3923
|
-
itemsPerPage:
|
|
3924
|
-
Resources:
|
|
4624
|
+
itemsPerPage: page.length,
|
|
4625
|
+
Resources: page.map(toScimUser),
|
|
3925
4626
|
});
|
|
3926
4627
|
}),
|
|
3927
4628
|
),
|
|
@@ -3934,28 +4635,16 @@ const scimModule: AppModule = {
|
|
|
3934
4635
|
if (!email) {
|
|
3935
4636
|
return scimError("userName is required.", 400);
|
|
3936
4637
|
}
|
|
3937
|
-
const existing = await
|
|
3938
|
-
|
|
3939
|
-
[email],
|
|
3940
|
-
);
|
|
3941
|
-
if (existing[0]) {
|
|
4638
|
+
const existing = await User.where({ email, tenant_id: tenantId() }).first();
|
|
4639
|
+
if (existing) {
|
|
3942
4640
|
return scimError("User already exists.", 409);
|
|
3943
4641
|
}
|
|
3944
4642
|
const hashed = await hashPassword(randomBytes(18).toString("hex"));
|
|
3945
|
-
const
|
|
3946
|
-
|
|
3947
|
-
"INSERT INTO users (${insertCols}) VALUES (${insertPh})",
|
|
3948
|
-
[name, email, hashed${insertTail}],
|
|
3949
|
-
);
|
|
3950
|
-
const created = await getSql().unsafe<UserRow>(
|
|
3951
|
-
"SELECT id, name, email FROM users WHERE email = ${emailPh}",
|
|
3952
|
-
[email],
|
|
3953
|
-
);
|
|
3954
|
-
const row = created[0];
|
|
3955
|
-
if (!row) {
|
|
4643
|
+
const created = await User.create({ name, email, password: hashed, is_admin: false, tenant_id: tenantId() });
|
|
4644
|
+
if (!created) {
|
|
3956
4645
|
return scimError("Could not create user.", 500);
|
|
3957
4646
|
}
|
|
3958
|
-
return scimJson(toScimUser(
|
|
4647
|
+
return scimJson(toScimUser(created), 201);
|
|
3959
4648
|
}),
|
|
3960
4649
|
),
|
|
3961
4650
|
},
|
|
@@ -3963,61 +4652,55 @@ const scimModule: AppModule = {
|
|
|
3963
4652
|
GET: kernel.wrap(
|
|
3964
4653
|
"api",
|
|
3965
4654
|
wrapScim(async (request) => {
|
|
3966
|
-
const id =
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
);
|
|
3971
|
-
|
|
3972
|
-
if (!row) {
|
|
4655
|
+
const id = readScimUserId(request);
|
|
4656
|
+
if (id instanceof Response) {
|
|
4657
|
+
return id;
|
|
4658
|
+
}
|
|
4659
|
+
const user = await User.where({ id, tenant_id: tenantId() }).first();
|
|
4660
|
+
if (!user) {
|
|
3973
4661
|
return scimError("User not found.", 404);
|
|
3974
4662
|
}
|
|
3975
|
-
return scimJson(toScimUser(
|
|
4663
|
+
return scimJson(toScimUser(user));
|
|
3976
4664
|
}),
|
|
3977
4665
|
),
|
|
3978
4666
|
PUT: kernel.wrap(
|
|
3979
4667
|
"api",
|
|
3980
4668
|
wrapScim(async (request) => {
|
|
3981
|
-
const id =
|
|
4669
|
+
const id = readScimUserId(request);
|
|
4670
|
+
if (id instanceof Response) {
|
|
4671
|
+
return id;
|
|
4672
|
+
}
|
|
3982
4673
|
const body = (await request.json()) as Record<string, unknown>;
|
|
3983
4674
|
const email = readUserName(body);
|
|
3984
4675
|
const name = readName(body, email);
|
|
3985
4676
|
if (!email || !name) {
|
|
3986
4677
|
return scimError("userName and name are required.", 400);
|
|
3987
4678
|
}
|
|
3988
|
-
await
|
|
3989
|
-
|
|
3990
|
-
[name, email, id],
|
|
3991
|
-
);
|
|
3992
|
-
const rows = await getSql().unsafe<UserRow>(
|
|
3993
|
-
"SELECT id, name, email FROM users WHERE id = ${idPh}",
|
|
3994
|
-
[id],
|
|
3995
|
-
);
|
|
3996
|
-
const row = rows[0];
|
|
3997
|
-
if (!row) {
|
|
4679
|
+
const user = await User.where({ id, tenant_id: tenantId() }).first();
|
|
4680
|
+
if (!user) {
|
|
3998
4681
|
return scimError("User not found.", 404);
|
|
3999
4682
|
}
|
|
4000
|
-
|
|
4683
|
+
await user.update({ name, email });
|
|
4684
|
+
return scimJson(toScimUser(user));
|
|
4001
4685
|
}),
|
|
4002
4686
|
),
|
|
4003
4687
|
PATCH: kernel.wrap(
|
|
4004
4688
|
"api",
|
|
4005
4689
|
wrapScim(async (request) => {
|
|
4006
|
-
const id =
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
);
|
|
4011
|
-
|
|
4012
|
-
if (!row) {
|
|
4690
|
+
const id = readScimUserId(request);
|
|
4691
|
+
if (id instanceof Response) {
|
|
4692
|
+
return id;
|
|
4693
|
+
}
|
|
4694
|
+
const user = await User.where({ id, tenant_id: tenantId() }).first();
|
|
4695
|
+
if (!user) {
|
|
4013
4696
|
return scimError("User not found.", 404);
|
|
4014
4697
|
}
|
|
4015
4698
|
const body = (await request.json()) as { schemas?: string[]; Operations?: Array<{ op?: string; path?: string; value?: unknown }> };
|
|
4016
4699
|
if (body.schemas && !body.schemas.includes(PATCH_SCHEMA)) {
|
|
4017
4700
|
return scimError("Unsupported patch schema.", 400);
|
|
4018
4701
|
}
|
|
4019
|
-
let name =
|
|
4020
|
-
let email =
|
|
4702
|
+
let name = String(user.get("name") ?? "");
|
|
4703
|
+
let email = String(user.get("email") ?? "");
|
|
4021
4704
|
for (const operation of body.Operations ?? []) {
|
|
4022
4705
|
const op = (operation.op ?? "replace").toLowerCase();
|
|
4023
4706
|
if (op !== "replace" && op !== "add") {
|
|
@@ -4038,18 +4721,21 @@ const scimModule: AppModule = {
|
|
|
4038
4721
|
name = readName(value, name);
|
|
4039
4722
|
}
|
|
4040
4723
|
}
|
|
4041
|
-
await
|
|
4042
|
-
|
|
4043
|
-
[name, email, id],
|
|
4044
|
-
);
|
|
4045
|
-
return scimJson(toScimUser({ id: row.id, name, email }));
|
|
4724
|
+
await user.update({ name, email });
|
|
4725
|
+
return scimJson(toScimUser(user));
|
|
4046
4726
|
}),
|
|
4047
4727
|
),
|
|
4048
4728
|
DELETE: kernel.wrap(
|
|
4049
4729
|
"api",
|
|
4050
4730
|
wrapScim(async (request) => {
|
|
4051
|
-
const id =
|
|
4052
|
-
|
|
4731
|
+
const id = readScimUserId(request);
|
|
4732
|
+
if (id instanceof Response) {
|
|
4733
|
+
return id;
|
|
4734
|
+
}
|
|
4735
|
+
const user = await User.where({ id, tenant_id: tenantId() }).first();
|
|
4736
|
+
if (user) {
|
|
4737
|
+
await user.delete();
|
|
4738
|
+
}
|
|
4053
4739
|
return new Response(null, { status: 204 });
|
|
4054
4740
|
}),
|
|
4055
4741
|
),
|
|
@@ -4131,6 +4817,18 @@ function writeGeneratedFiles(options) {
|
|
|
4131
4817
|
} else {
|
|
4132
4818
|
removeIfExists(join2(targetDir, "docker-compose.yml"));
|
|
4133
4819
|
}
|
|
4820
|
+
const postgresInit = renderPostgresAppRoleInitSql(projectName, layers);
|
|
4821
|
+
if (postgresInit) {
|
|
4822
|
+
writeText(join2(targetDir, "db/ensure-postgres-app-role.sql"), postgresInit);
|
|
4823
|
+
if (usesComposePostgres(layers)) {
|
|
4824
|
+
writeText(join2(targetDir, "docker/postgres-init/01-strata-app-role.sql"), postgresInit);
|
|
4825
|
+
} else {
|
|
4826
|
+
removeIfExists(join2(targetDir, "docker/postgres-init"));
|
|
4827
|
+
}
|
|
4828
|
+
} else {
|
|
4829
|
+
removeIfExists(join2(targetDir, "db/ensure-postgres-app-role.sql"));
|
|
4830
|
+
removeIfExists(join2(targetDir, "docker/postgres-init"));
|
|
4831
|
+
}
|
|
4134
4832
|
writeText(join2(src, "routes.ts"), renderRoutesTs());
|
|
4135
4833
|
writeText(join2(src, "lib/view.ts"), renderViewTs(layers));
|
|
4136
4834
|
writeText(join2(src, "bootstrap/config.ts"), renderConfigTs());
|
|
@@ -4145,9 +4843,22 @@ function writeGeneratedFiles(options) {
|
|
|
4145
4843
|
writeText(join2(src, "bootstrap/createApp.ts"), renderCreateAppTs(layers));
|
|
4146
4844
|
writeText(join2(src, "bootstrap/providers/config.ts"), renderConfigProvider(layers));
|
|
4147
4845
|
writeText(join2(src, "bootstrap/providers/queue.ts"), renderQueueProvider());
|
|
4846
|
+
writeText(join2(src, "bootstrap/providers/policy.ts"), renderPolicyProvider());
|
|
4148
4847
|
writeText(join2(src, "bootstrap/providers/index.ts"), renderProvidersIndex());
|
|
4149
4848
|
writeText(join2(src, "bootstrap/providers/auth.ts"), renderAuthProvider(layers));
|
|
4150
|
-
writeText(join2(src, "models/Note.ts"), renderNoteModel());
|
|
4849
|
+
writeText(join2(src, "models/Note.ts"), renderNoteModel(layers));
|
|
4850
|
+
if (authNeedsUsers(layers.auth)) {
|
|
4851
|
+
writeText(join2(src, "models/User.ts"), renderUserModel(layers));
|
|
4852
|
+
writeText(join2(src, "models/AuthOneTimeToken.ts"), renderAuthOneTimeTokenModel());
|
|
4853
|
+
} else {
|
|
4854
|
+
removeIfExists(join2(src, "models/User.ts"));
|
|
4855
|
+
removeIfExists(join2(src, "models/AuthOneTimeToken.ts"));
|
|
4856
|
+
}
|
|
4857
|
+
if (authUsesToken(layers.auth)) {
|
|
4858
|
+
writeText(join2(src, "models/ApiToken.ts"), renderApiTokenModel());
|
|
4859
|
+
} else {
|
|
4860
|
+
removeIfExists(join2(src, "models/ApiToken.ts"));
|
|
4861
|
+
}
|
|
4151
4862
|
writeText(join2(src, "db/migrate.ts"), renderMigrateTs(layers));
|
|
4152
4863
|
writeText(join2(src, "db/fresh.ts"), renderFreshTs(layers));
|
|
4153
4864
|
writeText(join2(src, "db/seed.ts"), renderSeedTs());
|
|
@@ -4178,6 +4889,17 @@ function writeGeneratedFiles(options) {
|
|
|
4178
4889
|
removeIfExists(join2(src, "bootstrap/pendingMfa.ts"));
|
|
4179
4890
|
}
|
|
4180
4891
|
writeText(join2(targetDir, "public/assets/site.css"), renderSiteCss());
|
|
4892
|
+
const htmxSource = join2(options.templateRoot ?? starterPackageRoot(), "public/assets/htmx.min.js");
|
|
4893
|
+
const htmxFallback = join2(starterPackageRoot(), "templates/public/assets/htmx.min.js");
|
|
4894
|
+
const htmxPath = existsSync2(htmxSource) ? htmxSource : htmxFallback;
|
|
4895
|
+
if (existsSync2(htmxPath)) {
|
|
4896
|
+
mkdirSync2(join2(targetDir, "public/assets"), { recursive: true });
|
|
4897
|
+
copyFileSync(htmxPath, join2(targetDir, "public/assets/htmx.min.js"));
|
|
4898
|
+
const versionSidecar = `${htmxPath}.version`;
|
|
4899
|
+
if (existsSync2(versionSidecar)) {
|
|
4900
|
+
copyFileSync(versionSidecar, join2(targetDir, "public/assets/htmx.min.js.version"));
|
|
4901
|
+
}
|
|
4902
|
+
}
|
|
4181
4903
|
writeText(join2(targetDir, "views/home.eta"), renderHomeView(projectName, layers));
|
|
4182
4904
|
writeText(join2(targetDir, "views/layouts/app.eta"), renderLayout(layers, projectName));
|
|
4183
4905
|
if (htmlAuthKit(layers.auth)) {
|
|
@@ -4191,6 +4913,7 @@ function writeGeneratedFiles(options) {
|
|
|
4191
4913
|
if (layers.extras.mfa) {
|
|
4192
4914
|
writeText(join2(targetDir, "views/auth/mfa-challenge.eta"), renderMfaChallengeView());
|
|
4193
4915
|
writeText(join2(targetDir, "views/auth/mfa-setup.eta"), renderMfaSetupView());
|
|
4916
|
+
writeText(join2(targetDir, "views/auth/confirm-password.eta"), renderConfirmPasswordView());
|
|
4194
4917
|
}
|
|
4195
4918
|
}
|
|
4196
4919
|
mkdirSync2(join2(targetDir, "storage"), { recursive: true });
|