@getstrata/starter 1.0.9 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1177 -490
- 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/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}`;
|
|
2309
2563
|
}
|
|
2310
|
-
return `postgresql
|
|
2564
|
+
return `postgresql://${GENERATED_POSTGRES_APP_ROLE}:${GENERATED_POSTGRES_APP_PASSWORD}@localhost:5432/${database}`;
|
|
2565
|
+
}
|
|
2566
|
+
function defaultMigrationDatabaseUrl(layers, projectName) {
|
|
2567
|
+
if (layers.database !== "postgres") {
|
|
2568
|
+
return null;
|
|
2569
|
+
}
|
|
2570
|
+
const database = appDatabaseName(projectName);
|
|
2571
|
+
return `postgresql://postgres:${GENERATED_POSTGRES_SUPERUSER_PASSWORD}@localhost:5432/${database}`;
|
|
2572
|
+
}
|
|
2573
|
+
function postgresAppRoleEnvNotes(layers) {
|
|
2574
|
+
if (layers.database !== "postgres") {
|
|
2575
|
+
return [];
|
|
2576
|
+
}
|
|
2577
|
+
return [
|
|
2578
|
+
`# Runtime DATABASE_URL is ${GENERATED_POSTGRES_APP_ROLE} (NOSUPERUSER NOBYPASSRLS).`,
|
|
2579
|
+
"# MIGRATION_DATABASE_URL is the postgres superuser for CREATE ROLE / CREATE DATABASE / GRANT / migrate.",
|
|
2580
|
+
"# db/ensure-postgres-app-role.sql is repeatable on an existing volume, not only docker-entrypoint-initdb.d.",
|
|
2581
|
+
layers.tenancy === "rls" ? "# Production TENANCY_DRIVER=rls inspects pg_roles (rolsuper / rolbypassrls), not only the URL username." : "# This app is not TENANCY_DRIVER=rls; still do not point runtime traffic at the postgres superuser."
|
|
2582
|
+
];
|
|
2583
|
+
}
|
|
2584
|
+
function renderPostgresAppRoleInitSql(projectName, layers) {
|
|
2585
|
+
if (layers.database !== "postgres") {
|
|
2586
|
+
return null;
|
|
2587
|
+
}
|
|
2588
|
+
const database = appDatabaseName(projectName);
|
|
2589
|
+
const role = GENERATED_POSTGRES_APP_ROLE;
|
|
2590
|
+
const password = GENERATED_POSTGRES_APP_PASSWORD;
|
|
2591
|
+
return `-- Application login role. FORCE RLS applies because this role is NOSUPERUSER and NOBYPASSRLS.
|
|
2592
|
+
-- Superuser remains for CREATE ROLE / CREATE DATABASE / GRANT / migrate.
|
|
2593
|
+
-- This script is repeatable on an already-existing volume (not only docker-entrypoint-initdb.d).
|
|
2594
|
+
DO $$
|
|
2595
|
+
BEGIN
|
|
2596
|
+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}') THEN
|
|
2597
|
+
CREATE ROLE ${role} LOGIN PASSWORD '${password}'
|
|
2598
|
+
NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS;
|
|
2599
|
+
ELSE
|
|
2600
|
+
ALTER ROLE ${role} WITH LOGIN PASSWORD '${password}'
|
|
2601
|
+
NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS;
|
|
2602
|
+
END IF;
|
|
2603
|
+
END
|
|
2604
|
+
$$;
|
|
2605
|
+
|
|
2606
|
+
GRANT CONNECT ON DATABASE ${database} TO ${role};
|
|
2607
|
+
GRANT USAGE, CREATE ON SCHEMA public TO ${role};
|
|
2608
|
+
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ${role};
|
|
2609
|
+
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ${role};
|
|
2610
|
+
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO ${role};
|
|
2611
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO ${role};
|
|
2612
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO ${role};
|
|
2613
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT EXECUTE ON FUNCTIONS TO ${role};
|
|
2614
|
+
`;
|
|
2311
2615
|
}
|
|
2312
2616
|
function renderEnvExample(projectName, layers) {
|
|
2313
2617
|
const lines = [
|
|
@@ -2317,8 +2621,10 @@ function renderEnvExample(projectName, layers) {
|
|
|
2317
2621
|
"PORT=3000",
|
|
2318
2622
|
"APP_URL=http://localhost:3000",
|
|
2319
2623
|
`DATABASE_URL=${defaultDatabaseUrl(layers, projectName)}`,
|
|
2624
|
+
...postgresAppRoleEnvNotes(layers),
|
|
2625
|
+
...layers.database === "postgres" ? [`MIGRATION_DATABASE_URL=${defaultMigrationDatabaseUrl(layers, projectName)}`] : [],
|
|
2320
2626
|
...layers.database === "sqlite" ? [] : [
|
|
2321
|
-
"# Optional.
|
|
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.0
|
|
2535
|
-
"@getstrata/cli": "^1.0
|
|
2536
|
-
"@getstrata/core": "^1.0
|
|
2859
|
+
"@getstrata/bootstrap": "^1.1.0",
|
|
2860
|
+
"@getstrata/cli": "^1.1.0",
|
|
2861
|
+
"@getstrata/core": "^1.1.0"
|
|
2537
2862
|
};
|
|
2538
2863
|
coreDeps.eta = "^4.6.0";
|
|
2539
2864
|
if (options.layers?.database === "mysql") {
|
|
@@ -2615,8 +2940,11 @@ function renderSupportingToolsReadme(layers) {
|
|
|
2615
2940
|
const system = mysql ? "MySQL" : "PostgreSQL";
|
|
2616
2941
|
const server = mysql ? "mysql" : "postgres";
|
|
2617
2942
|
const username = mysql ? "root" : "postgres";
|
|
2618
|
-
const password = mysql ?
|
|
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)};
|
|
@@ -3420,11 +4092,10 @@ function renderEnsureDatabaseTs(layers, projectName) {
|
|
|
3420
4092
|
if (layers.database === "sqlite") {
|
|
3421
4093
|
return null;
|
|
3422
4094
|
}
|
|
3423
|
-
const
|
|
3424
|
-
const fallback = layers.database === "mysql" ? `mysql://root:root@localhost:3306/${database}` : `postgresql://postgres:postgres@localhost:5432/${database}`;
|
|
4095
|
+
const fallback = defaultDatabaseUrl(layers, projectName);
|
|
3425
4096
|
const resolveUrl = `/**
|
|
3426
|
-
*
|
|
3427
|
-
*
|
|
4097
|
+
* Runtime URL. Superuser CREATE ROLE / CREATE DATABASE / GRANT / migrate uses
|
|
4098
|
+
* MIGRATION_DATABASE_URL when set.
|
|
3428
4099
|
*/
|
|
3429
4100
|
function resolveAppDatabaseUrl(): string {
|
|
3430
4101
|
const explicit = process.env.APP_DATABASE_URL?.trim();
|
|
@@ -3433,8 +4104,8 @@ function resolveAppDatabaseUrl(): string {
|
|
|
3433
4104
|
}
|
|
3434
4105
|
return process.env.DATABASE_URL?.trim() || DEFAULT_DATABASE_URL;
|
|
3435
4106
|
}
|
|
3436
|
-
|
|
3437
|
-
|
|
4107
|
+
`;
|
|
4108
|
+
const safeName = `/** Reject anything we would have to quote before interpolating into DDL. */
|
|
3438
4109
|
function safeDatabaseName(url: string): string {
|
|
3439
4110
|
let name = "";
|
|
3440
4111
|
try {
|
|
@@ -3456,7 +4127,7 @@ function safeDatabaseName(url: string): string {
|
|
|
3456
4127
|
|
|
3457
4128
|
const DEFAULT_DATABASE_URL = ${JSON.stringify(fallback)};
|
|
3458
4129
|
|
|
3459
|
-
${resolveUrl}
|
|
4130
|
+
${resolveUrl}${safeName}
|
|
3460
4131
|
export async function ensureAppDatabase(): Promise<string> {
|
|
3461
4132
|
const url = resolveAppDatabaseUrl();
|
|
3462
4133
|
const name = safeDatabaseName(url);
|
|
@@ -3475,58 +4146,21 @@ export async function ensureAppDatabase(): Promise<string> {
|
|
|
3475
4146
|
}
|
|
3476
4147
|
`;
|
|
3477
4148
|
}
|
|
3478
|
-
return `
|
|
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
|
-
}
|
|
4149
|
+
return `import {
|
|
4150
|
+
ensurePostgresDatabaseAndAppRole,
|
|
4151
|
+
POSTGRES_APP_ROLE_PASSWORD,
|
|
4152
|
+
} from "@getstrata/core/tenant/enableTenantRls";
|
|
3496
4153
|
|
|
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
|
-
}
|
|
4154
|
+
const DEFAULT_DATABASE_URL = ${JSON.stringify(fallback)};
|
|
3513
4155
|
|
|
4156
|
+
${resolveUrl}
|
|
3514
4157
|
export async function ensureAppDatabase(): Promise<string> {
|
|
3515
4158
|
const url = resolveAppDatabaseUrl();
|
|
3516
|
-
|
|
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
|
-
|
|
4159
|
+
await ensurePostgresDatabaseAndAppRole({
|
|
4160
|
+
runtimeUrl: url,
|
|
4161
|
+
migrationUrl: process.env.MIGRATION_DATABASE_URL,
|
|
4162
|
+
password: process.env.STRATA_APP_PASSWORD ?? POSTGRES_APP_ROLE_PASSWORD,
|
|
4163
|
+
});
|
|
3530
4164
|
process.env.DATABASE_URL = url;
|
|
3531
4165
|
return url;
|
|
3532
4166
|
}
|
|
@@ -3535,11 +4169,27 @@ export async function ensureAppDatabase(): Promise<string> {
|
|
|
3535
4169
|
function renderSidecarsTs(_layers) {
|
|
3536
4170
|
return null;
|
|
3537
4171
|
}
|
|
4172
|
+
function renderPolicyProvider() {
|
|
4173
|
+
return `import { CORE_POLICY_GATE_TOKEN } from "@getstrata/bootstrap/config";
|
|
4174
|
+
import { PolicyGate } from "@getstrata/core/auth/policy";
|
|
4175
|
+
import type { ServiceProvider } from "@getstrata/core/contracts/di";
|
|
4176
|
+
|
|
4177
|
+
const policyProvider: ServiceProvider = {
|
|
4178
|
+
name: "core.policy",
|
|
4179
|
+
register({ container }) {
|
|
4180
|
+
container.set(CORE_POLICY_GATE_TOKEN, new PolicyGate());
|
|
4181
|
+
},
|
|
4182
|
+
};
|
|
4183
|
+
|
|
4184
|
+
export default policyProvider;
|
|
4185
|
+
`;
|
|
4186
|
+
}
|
|
3538
4187
|
function renderProvidersIndex() {
|
|
3539
4188
|
return `import type { ServiceProvider } from "@getstrata/core/contracts/di";
|
|
3540
4189
|
import authProvider from "./auth.ts";
|
|
3541
4190
|
import cacheProvider from "./cache.ts";
|
|
3542
4191
|
import configProvider from "./config.ts";
|
|
4192
|
+
import policyProvider from "./policy.ts";
|
|
3543
4193
|
import queueProvider from "./queue.ts";
|
|
3544
4194
|
import storageProvider from "./storage.ts";
|
|
3545
4195
|
|
|
@@ -3549,6 +4199,7 @@ const starterProviders: ServiceProvider[] = [
|
|
|
3549
4199
|
storageProvider,
|
|
3550
4200
|
queueProvider,
|
|
3551
4201
|
authProvider,
|
|
4202
|
+
policyProvider,
|
|
3552
4203
|
];
|
|
3553
4204
|
|
|
3554
4205
|
export { starterProviders };
|
|
@@ -3580,10 +4231,11 @@ import {
|
|
|
3580
4231
|
ensureModulesLoaded,
|
|
3581
4232
|
} from "@getstrata/bootstrap/discoverModules";
|
|
3582
4233
|
import { createHealthRoutes } from "@getstrata/bootstrap/health";
|
|
3583
|
-
${metricsImport}import { assertProductionSecrets } from "@getstrata/bootstrap/secretsGuard";
|
|
4234
|
+
${metricsImport}import { assertProductionSecrets, assertRlsLiveDatabaseRole } from "@getstrata/bootstrap/secretsGuard";
|
|
3584
4235
|
import { createWebServer } from "@getstrata/bootstrap/web/server";
|
|
3585
4236
|
import { setActiveApplicationContext } from "@getstrata/core/runtime/applicationRegistry";
|
|
3586
4237
|
import { isProductionEnv } from "@getstrata/core/runtime/appEnv";
|
|
4238
|
+
import { isRlsTenancy } from "@getstrata/core/tenant/tenancyConfig";
|
|
3587
4239
|
import { migrate } from "../db/migrate.ts";
|
|
3588
4240
|
import { buildRoutes } from "../routes.ts";
|
|
3589
4241
|
import { loadConfig } from "./config.ts";
|
|
@@ -3654,6 +4306,9 @@ export async function bootstrapApp(options: BootstrapOptions = {}): Promise<Boot
|
|
|
3654
4306
|
${needsEnsure(layers) ? ` await ensureAppDatabase();
|
|
3655
4307
|
` : ""} const appConfig = loadConfig();
|
|
3656
4308
|
getSql();
|
|
4309
|
+
if (isRlsTenancy()) {
|
|
4310
|
+
await assertRlsLiveDatabaseRole();
|
|
4311
|
+
}
|
|
3657
4312
|
configureModulesDirectory(join(import.meta.dir, "../modules"));
|
|
3658
4313
|
await ensureModulesLoaded();
|
|
3659
4314
|
const context = createAppContext();
|
|
@@ -3754,36 +4409,22 @@ export function plainText(body: string, status = 200): Response {
|
|
|
3754
4409
|
}
|
|
3755
4410
|
|
|
3756
4411
|
// src/renderScim.ts
|
|
3757
|
-
function ph2(layers, count, start = 1) {
|
|
3758
|
-
if (layers.database === "postgres") {
|
|
3759
|
-
return Array.from({ length: count }, (_, index) => `$${start + index}`).join(", ");
|
|
3760
|
-
}
|
|
3761
|
-
return Array.from({ length: count }, () => "?").join(", ");
|
|
3762
|
-
}
|
|
3763
|
-
function sqlFalse2(layers) {
|
|
3764
|
-
return layers.database === "postgres" ? "false" : "0";
|
|
3765
|
-
}
|
|
3766
4412
|
function renderScimModule(layers) {
|
|
3767
4413
|
if (!layers.extras.scim || !authNeedsUsers(layers.auth)) {
|
|
3768
4414
|
return null;
|
|
3769
4415
|
}
|
|
3770
|
-
const tenantOn = usesTenantTable(layers.tenancy);
|
|
3771
|
-
const insertCols = tenantOn ? "name, email, password, is_admin, tenant_id" : "name, email, password, is_admin";
|
|
3772
|
-
const insertPh = tenantOn ? ph2(layers, 5) : ph2(layers, 4);
|
|
3773
|
-
const insertTail = tenantOn ? `, ${sqlFalse2(layers)}, tenantId` : `, ${sqlFalse2(layers)}`;
|
|
3774
|
-
const emailPh = ph2(layers, 1);
|
|
3775
|
-
const idPh = ph2(layers, 1);
|
|
3776
|
-
const updatePh = `${ph2(layers, 1)}, ${ph2(layers, 1, 2)}, ${ph2(layers, 1, 3)}`;
|
|
3777
4416
|
return `import { randomBytes } from "node:crypto";
|
|
3778
4417
|
import type { AppModule } from "@getstrata/bootstrap/contracts";
|
|
3779
4418
|
import { routeParams } from "@getstrata/bootstrap/web/routing";
|
|
3780
4419
|
import { createScimAuthMiddleware } from "@getstrata/core/auth/scimAuthMiddleware";
|
|
3781
4420
|
import { hashPassword } from "@getstrata/core/auth/password";
|
|
4421
|
+
import { BadRequestError } from "@getstrata/core/errors/http";
|
|
4422
|
+
import { parsePositiveIntParam } from "@getstrata/core/http/validation";
|
|
3782
4423
|
import { withErrorHandling } from "@getstrata/core/http/response";
|
|
3783
4424
|
import { withMiddleware } from "@getstrata/core/http/routeMiddleware";
|
|
3784
4425
|
import { createScimThrottleMiddleware } from "@getstrata/core/http/scimThrottleMiddleware";
|
|
3785
4426
|
import { currentTenant } from "@getstrata/core/tenant/tenantContext";
|
|
3786
|
-
import {
|
|
4427
|
+
import { User } from "../../models/User.ts";
|
|
3787
4428
|
|
|
3788
4429
|
const USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User";
|
|
3789
4430
|
const LIST_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
|
|
@@ -3791,12 +4432,29 @@ const ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error";
|
|
|
3791
4432
|
const PATCH_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
|
|
3792
4433
|
const CONFIG_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig";
|
|
3793
4434
|
|
|
3794
|
-
type UserRow = { id: number; name: string; email: string };
|
|
3795
|
-
|
|
3796
4435
|
function scimEnabled(): boolean {
|
|
3797
4436
|
return (process.env.FEATURE_SCIM ?? "false") === "true";
|
|
3798
4437
|
}
|
|
3799
4438
|
|
|
4439
|
+
function tenantId(): number {
|
|
4440
|
+
const tenant = currentTenant();
|
|
4441
|
+
if (!tenant) {
|
|
4442
|
+
throw new Error("SCIM requires a tenant context.");
|
|
4443
|
+
}
|
|
4444
|
+
return tenant.id;
|
|
4445
|
+
}
|
|
4446
|
+
|
|
4447
|
+
function readScimUserId(request: Request): number | Response {
|
|
4448
|
+
try {
|
|
4449
|
+
return parsePositiveIntParam(routeParams(request).id ?? "", "id");
|
|
4450
|
+
} catch (error) {
|
|
4451
|
+
if (error instanceof BadRequestError) {
|
|
4452
|
+
return scimError("Invalid id.", 400);
|
|
4453
|
+
}
|
|
4454
|
+
throw error;
|
|
4455
|
+
}
|
|
4456
|
+
}
|
|
4457
|
+
|
|
3800
4458
|
function scimJson(body: unknown, status = 200): Response {
|
|
3801
4459
|
return Response.json(body, {
|
|
3802
4460
|
status,
|
|
@@ -3808,13 +4466,15 @@ function scimError(detail: string, status: number): Response {
|
|
|
3808
4466
|
return scimJson({ schemas: [ERROR_SCHEMA], detail, status: String(status) }, status);
|
|
3809
4467
|
}
|
|
3810
4468
|
|
|
3811
|
-
function toScimUser(
|
|
4469
|
+
function toScimUser(user: { get(key: "id" | "name" | "email"): unknown }) {
|
|
4470
|
+
const email = String(user.get("email") ?? "");
|
|
4471
|
+
const name = String(user.get("name") ?? "");
|
|
3812
4472
|
return {
|
|
3813
4473
|
schemas: [USER_SCHEMA],
|
|
3814
|
-
id: String(
|
|
3815
|
-
userName:
|
|
3816
|
-
name: { formatted:
|
|
3817
|
-
emails: [{ value:
|
|
4474
|
+
id: String(user.get("id")),
|
|
4475
|
+
userName: email,
|
|
4476
|
+
name: { formatted: name },
|
|
4477
|
+
emails: [{ value: email, primary: true }],
|
|
3818
4478
|
active: true,
|
|
3819
4479
|
meta: { resourceType: "User" },
|
|
3820
4480
|
};
|
|
@@ -3904,24 +4564,29 @@ const scimModule: AppModule = {
|
|
|
3904
4564
|
const url = new URL(request.url);
|
|
3905
4565
|
const filter = url.searchParams.get("filter") ?? "";
|
|
3906
4566
|
const match = /userName\\s+eq\\s+"([^"]+)"/i.exec(filter);
|
|
3907
|
-
|
|
4567
|
+
const startIndex = Math.max(1, Number.parseInt(url.searchParams.get("startIndex") ?? "1", 10) || 1);
|
|
4568
|
+
const count = Math.min(200, Math.max(1, Number.parseInt(url.searchParams.get("count") ?? "200", 10) || 200));
|
|
3908
4569
|
if (match?.[1]) {
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
);
|
|
3913
|
-
|
|
3914
|
-
|
|
4570
|
+
const email = match[1].trim().toLowerCase();
|
|
4571
|
+
const user = await User.where({ email, tenant_id: tenantId() }).first();
|
|
4572
|
+
const users = user ? [user] : [];
|
|
4573
|
+
const slice = users.slice(startIndex - 1, startIndex - 1 + count);
|
|
4574
|
+
return scimJson({
|
|
4575
|
+
schemas: [LIST_SCHEMA],
|
|
4576
|
+
totalResults: users.length,
|
|
4577
|
+
startIndex,
|
|
4578
|
+
itemsPerPage: slice.length,
|
|
4579
|
+
Resources: slice.map(toScimUser),
|
|
4580
|
+
});
|
|
3915
4581
|
}
|
|
3916
|
-
const
|
|
3917
|
-
const
|
|
3918
|
-
const slice = rows.slice(startIndex - 1, startIndex - 1 + count);
|
|
4582
|
+
const totalResults = await User.where({ tenant_id: tenantId() }).count();
|
|
4583
|
+
const page = await User.where({ tenant_id: tenantId() }).offset(startIndex - 1).limit(count).get();
|
|
3919
4584
|
return scimJson({
|
|
3920
4585
|
schemas: [LIST_SCHEMA],
|
|
3921
|
-
totalResults
|
|
4586
|
+
totalResults,
|
|
3922
4587
|
startIndex,
|
|
3923
|
-
itemsPerPage:
|
|
3924
|
-
Resources:
|
|
4588
|
+
itemsPerPage: page.length,
|
|
4589
|
+
Resources: page.map(toScimUser),
|
|
3925
4590
|
});
|
|
3926
4591
|
}),
|
|
3927
4592
|
),
|
|
@@ -3934,28 +4599,16 @@ const scimModule: AppModule = {
|
|
|
3934
4599
|
if (!email) {
|
|
3935
4600
|
return scimError("userName is required.", 400);
|
|
3936
4601
|
}
|
|
3937
|
-
const existing = await
|
|
3938
|
-
|
|
3939
|
-
[email],
|
|
3940
|
-
);
|
|
3941
|
-
if (existing[0]) {
|
|
4602
|
+
const existing = await User.where({ email, tenant_id: tenantId() }).first();
|
|
4603
|
+
if (existing) {
|
|
3942
4604
|
return scimError("User already exists.", 409);
|
|
3943
4605
|
}
|
|
3944
4606
|
const hashed = await hashPassword(randomBytes(18).toString("hex"));
|
|
3945
|
-
const
|
|
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) {
|
|
4607
|
+
const created = await User.create({ name, email, password: hashed, is_admin: false, tenant_id: tenantId() });
|
|
4608
|
+
if (!created) {
|
|
3956
4609
|
return scimError("Could not create user.", 500);
|
|
3957
4610
|
}
|
|
3958
|
-
return scimJson(toScimUser(
|
|
4611
|
+
return scimJson(toScimUser(created), 201);
|
|
3959
4612
|
}),
|
|
3960
4613
|
),
|
|
3961
4614
|
},
|
|
@@ -3963,61 +4616,55 @@ const scimModule: AppModule = {
|
|
|
3963
4616
|
GET: kernel.wrap(
|
|
3964
4617
|
"api",
|
|
3965
4618
|
wrapScim(async (request) => {
|
|
3966
|
-
const id =
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
);
|
|
3971
|
-
|
|
3972
|
-
if (!row) {
|
|
4619
|
+
const id = readScimUserId(request);
|
|
4620
|
+
if (id instanceof Response) {
|
|
4621
|
+
return id;
|
|
4622
|
+
}
|
|
4623
|
+
const user = await User.where({ id, tenant_id: tenantId() }).first();
|
|
4624
|
+
if (!user) {
|
|
3973
4625
|
return scimError("User not found.", 404);
|
|
3974
4626
|
}
|
|
3975
|
-
return scimJson(toScimUser(
|
|
4627
|
+
return scimJson(toScimUser(user));
|
|
3976
4628
|
}),
|
|
3977
4629
|
),
|
|
3978
4630
|
PUT: kernel.wrap(
|
|
3979
4631
|
"api",
|
|
3980
4632
|
wrapScim(async (request) => {
|
|
3981
|
-
const id =
|
|
4633
|
+
const id = readScimUserId(request);
|
|
4634
|
+
if (id instanceof Response) {
|
|
4635
|
+
return id;
|
|
4636
|
+
}
|
|
3982
4637
|
const body = (await request.json()) as Record<string, unknown>;
|
|
3983
4638
|
const email = readUserName(body);
|
|
3984
4639
|
const name = readName(body, email);
|
|
3985
4640
|
if (!email || !name) {
|
|
3986
4641
|
return scimError("userName and name are required.", 400);
|
|
3987
4642
|
}
|
|
3988
|
-
await
|
|
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) {
|
|
4643
|
+
const user = await User.where({ id, tenant_id: tenantId() }).first();
|
|
4644
|
+
if (!user) {
|
|
3998
4645
|
return scimError("User not found.", 404);
|
|
3999
4646
|
}
|
|
4000
|
-
|
|
4647
|
+
await user.update({ name, email });
|
|
4648
|
+
return scimJson(toScimUser(user));
|
|
4001
4649
|
}),
|
|
4002
4650
|
),
|
|
4003
4651
|
PATCH: kernel.wrap(
|
|
4004
4652
|
"api",
|
|
4005
4653
|
wrapScim(async (request) => {
|
|
4006
|
-
const id =
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
);
|
|
4011
|
-
|
|
4012
|
-
if (!row) {
|
|
4654
|
+
const id = readScimUserId(request);
|
|
4655
|
+
if (id instanceof Response) {
|
|
4656
|
+
return id;
|
|
4657
|
+
}
|
|
4658
|
+
const user = await User.where({ id, tenant_id: tenantId() }).first();
|
|
4659
|
+
if (!user) {
|
|
4013
4660
|
return scimError("User not found.", 404);
|
|
4014
4661
|
}
|
|
4015
4662
|
const body = (await request.json()) as { schemas?: string[]; Operations?: Array<{ op?: string; path?: string; value?: unknown }> };
|
|
4016
4663
|
if (body.schemas && !body.schemas.includes(PATCH_SCHEMA)) {
|
|
4017
4664
|
return scimError("Unsupported patch schema.", 400);
|
|
4018
4665
|
}
|
|
4019
|
-
let name =
|
|
4020
|
-
let email =
|
|
4666
|
+
let name = String(user.get("name") ?? "");
|
|
4667
|
+
let email = String(user.get("email") ?? "");
|
|
4021
4668
|
for (const operation of body.Operations ?? []) {
|
|
4022
4669
|
const op = (operation.op ?? "replace").toLowerCase();
|
|
4023
4670
|
if (op !== "replace" && op !== "add") {
|
|
@@ -4038,18 +4685,21 @@ const scimModule: AppModule = {
|
|
|
4038
4685
|
name = readName(value, name);
|
|
4039
4686
|
}
|
|
4040
4687
|
}
|
|
4041
|
-
await
|
|
4042
|
-
|
|
4043
|
-
[name, email, id],
|
|
4044
|
-
);
|
|
4045
|
-
return scimJson(toScimUser({ id: row.id, name, email }));
|
|
4688
|
+
await user.update({ name, email });
|
|
4689
|
+
return scimJson(toScimUser(user));
|
|
4046
4690
|
}),
|
|
4047
4691
|
),
|
|
4048
4692
|
DELETE: kernel.wrap(
|
|
4049
4693
|
"api",
|
|
4050
4694
|
wrapScim(async (request) => {
|
|
4051
|
-
const id =
|
|
4052
|
-
|
|
4695
|
+
const id = readScimUserId(request);
|
|
4696
|
+
if (id instanceof Response) {
|
|
4697
|
+
return id;
|
|
4698
|
+
}
|
|
4699
|
+
const user = await User.where({ id, tenant_id: tenantId() }).first();
|
|
4700
|
+
if (user) {
|
|
4701
|
+
await user.delete();
|
|
4702
|
+
}
|
|
4053
4703
|
return new Response(null, { status: 204 });
|
|
4054
4704
|
}),
|
|
4055
4705
|
),
|
|
@@ -4131,6 +4781,18 @@ function writeGeneratedFiles(options) {
|
|
|
4131
4781
|
} else {
|
|
4132
4782
|
removeIfExists(join2(targetDir, "docker-compose.yml"));
|
|
4133
4783
|
}
|
|
4784
|
+
const postgresInit = renderPostgresAppRoleInitSql(projectName, layers);
|
|
4785
|
+
if (postgresInit) {
|
|
4786
|
+
writeText(join2(targetDir, "db/ensure-postgres-app-role.sql"), postgresInit);
|
|
4787
|
+
if (usesComposePostgres(layers)) {
|
|
4788
|
+
writeText(join2(targetDir, "docker/postgres-init/01-strata-app-role.sql"), postgresInit);
|
|
4789
|
+
} else {
|
|
4790
|
+
removeIfExists(join2(targetDir, "docker/postgres-init"));
|
|
4791
|
+
}
|
|
4792
|
+
} else {
|
|
4793
|
+
removeIfExists(join2(targetDir, "db/ensure-postgres-app-role.sql"));
|
|
4794
|
+
removeIfExists(join2(targetDir, "docker/postgres-init"));
|
|
4795
|
+
}
|
|
4134
4796
|
writeText(join2(src, "routes.ts"), renderRoutesTs());
|
|
4135
4797
|
writeText(join2(src, "lib/view.ts"), renderViewTs(layers));
|
|
4136
4798
|
writeText(join2(src, "bootstrap/config.ts"), renderConfigTs());
|
|
@@ -4145,9 +4807,22 @@ function writeGeneratedFiles(options) {
|
|
|
4145
4807
|
writeText(join2(src, "bootstrap/createApp.ts"), renderCreateAppTs(layers));
|
|
4146
4808
|
writeText(join2(src, "bootstrap/providers/config.ts"), renderConfigProvider(layers));
|
|
4147
4809
|
writeText(join2(src, "bootstrap/providers/queue.ts"), renderQueueProvider());
|
|
4810
|
+
writeText(join2(src, "bootstrap/providers/policy.ts"), renderPolicyProvider());
|
|
4148
4811
|
writeText(join2(src, "bootstrap/providers/index.ts"), renderProvidersIndex());
|
|
4149
4812
|
writeText(join2(src, "bootstrap/providers/auth.ts"), renderAuthProvider(layers));
|
|
4150
|
-
writeText(join2(src, "models/Note.ts"), renderNoteModel());
|
|
4813
|
+
writeText(join2(src, "models/Note.ts"), renderNoteModel(layers));
|
|
4814
|
+
if (authNeedsUsers(layers.auth)) {
|
|
4815
|
+
writeText(join2(src, "models/User.ts"), renderUserModel(layers));
|
|
4816
|
+
writeText(join2(src, "models/AuthOneTimeToken.ts"), renderAuthOneTimeTokenModel());
|
|
4817
|
+
} else {
|
|
4818
|
+
removeIfExists(join2(src, "models/User.ts"));
|
|
4819
|
+
removeIfExists(join2(src, "models/AuthOneTimeToken.ts"));
|
|
4820
|
+
}
|
|
4821
|
+
if (authUsesToken(layers.auth)) {
|
|
4822
|
+
writeText(join2(src, "models/ApiToken.ts"), renderApiTokenModel());
|
|
4823
|
+
} else {
|
|
4824
|
+
removeIfExists(join2(src, "models/ApiToken.ts"));
|
|
4825
|
+
}
|
|
4151
4826
|
writeText(join2(src, "db/migrate.ts"), renderMigrateTs(layers));
|
|
4152
4827
|
writeText(join2(src, "db/fresh.ts"), renderFreshTs(layers));
|
|
4153
4828
|
writeText(join2(src, "db/seed.ts"), renderSeedTs());
|
|
@@ -4178,6 +4853,17 @@ function writeGeneratedFiles(options) {
|
|
|
4178
4853
|
removeIfExists(join2(src, "bootstrap/pendingMfa.ts"));
|
|
4179
4854
|
}
|
|
4180
4855
|
writeText(join2(targetDir, "public/assets/site.css"), renderSiteCss());
|
|
4856
|
+
const htmxSource = join2(options.templateRoot ?? starterPackageRoot(), "public/assets/htmx.min.js");
|
|
4857
|
+
const htmxFallback = join2(starterPackageRoot(), "templates/public/assets/htmx.min.js");
|
|
4858
|
+
const htmxPath = existsSync2(htmxSource) ? htmxSource : htmxFallback;
|
|
4859
|
+
if (existsSync2(htmxPath)) {
|
|
4860
|
+
mkdirSync2(join2(targetDir, "public/assets"), { recursive: true });
|
|
4861
|
+
copyFileSync(htmxPath, join2(targetDir, "public/assets/htmx.min.js"));
|
|
4862
|
+
const versionSidecar = `${htmxPath}.version`;
|
|
4863
|
+
if (existsSync2(versionSidecar)) {
|
|
4864
|
+
copyFileSync(versionSidecar, join2(targetDir, "public/assets/htmx.min.js.version"));
|
|
4865
|
+
}
|
|
4866
|
+
}
|
|
4181
4867
|
writeText(join2(targetDir, "views/home.eta"), renderHomeView(projectName, layers));
|
|
4182
4868
|
writeText(join2(targetDir, "views/layouts/app.eta"), renderLayout(layers, projectName));
|
|
4183
4869
|
if (htmlAuthKit(layers.auth)) {
|
|
@@ -4191,6 +4877,7 @@ function writeGeneratedFiles(options) {
|
|
|
4191
4877
|
if (layers.extras.mfa) {
|
|
4192
4878
|
writeText(join2(targetDir, "views/auth/mfa-challenge.eta"), renderMfaChallengeView());
|
|
4193
4879
|
writeText(join2(targetDir, "views/auth/mfa-setup.eta"), renderMfaSetupView());
|
|
4880
|
+
writeText(join2(targetDir, "views/auth/confirm-password.eta"), renderConfirmPasswordView());
|
|
4194
4881
|
}
|
|
4195
4882
|
}
|
|
4196
4883
|
mkdirSync2(join2(targetDir, "storage"), { recursive: true });
|