@getstrata/starter 1.0.8 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1238 -498
- 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;
|
|
1553
|
+
})),
|
|
1554
|
+
},` : "";
|
|
1555
|
+
const samlRoutes = cookie ? `
|
|
1556
|
+
"/auth/saml": {
|
|
1557
|
+
GET: kernel.wrap("api", withErrorHandling(async () => {
|
|
1558
|
+
if (process.env.FEATURE_SAML !== "true") {
|
|
1559
|
+
return new Response("Not found", { status: 404 });
|
|
1560
|
+
}
|
|
1561
|
+
const issued = createOAuthState();
|
|
1562
|
+
const url = await createSamlServiceProvider().authorizationUrl(issued.state);
|
|
1563
|
+
return new Response(null, { status: 302, headers: { location: url } });
|
|
1564
|
+
})),
|
|
1565
|
+
},
|
|
1566
|
+
"/auth/saml/acs": {
|
|
1567
|
+
POST: kernel.wrap("api", withErrorHandling(async (request) => {
|
|
1568
|
+
if (process.env.FEATURE_SAML !== "true") {
|
|
1569
|
+
return new Response("Not found", { status: 404 });
|
|
1570
|
+
}
|
|
1571
|
+
const form = await request.formData();
|
|
1572
|
+
const samlResponse = String(form.get("SAMLResponse") ?? "");
|
|
1573
|
+
const relayState = String(form.get("RelayState") ?? "");
|
|
1574
|
+
if (!verifyOAuthState(request, relayState)) {
|
|
1575
|
+
return jsonResponse({ error: "Invalid SAML state." }, { status: 403 });
|
|
1576
|
+
}
|
|
1577
|
+
const profile = await createSamlServiceProvider()
|
|
1578
|
+
.consumePost(samlResponse, relayState)
|
|
1579
|
+
.catch(() => null);
|
|
1580
|
+
if (!profile) {
|
|
1581
|
+
return jsonResponse({ error: "Invalid SAML response." }, { status: 400 });
|
|
1582
|
+
}
|
|
1583
|
+
let record = await starterAuthDirectory.findByEmail?.(profile.email);
|
|
1584
|
+
if (!record) {
|
|
1585
|
+
if ((process.env.FEATURE_REGISTRATION ?? "true") === "false") {
|
|
1586
|
+
return jsonResponse({ error: "SAML user is not provisioned." }, { status: 403 });
|
|
1587
|
+
}
|
|
1588
|
+
const hashed = await hashPassword(randomBytes(18).toString("hex"));
|
|
1589
|
+
try {
|
|
1590
|
+
await runAuthWrite(profile.email, async () => {
|
|
1591
|
+
await User.create({
|
|
1592
|
+
name: profile.name,
|
|
1593
|
+
email: profile.email,
|
|
1594
|
+
password: hashed,
|
|
1595
|
+
is_admin: false,${samlTenantCreateField}
|
|
1596
|
+
});
|
|
1597
|
+
});
|
|
1598
|
+
} catch {
|
|
1599
|
+
// Unique email: another request already provisioned this user.
|
|
1600
|
+
}
|
|
1601
|
+
record = await starterAuthDirectory.findByEmail?.(profile.email);
|
|
1602
|
+
}
|
|
1603
|
+
if (!record) {
|
|
1604
|
+
return jsonResponse({ error: "Could not complete SAML login." }, { status: 500 });
|
|
1605
|
+
}
|
|
1606
|
+
${mfa ? `if (record.mfa_enabled) {
|
|
1607
|
+
const pending = redirectTo("/login/mfa");
|
|
1608
|
+
pending.headers.append("set-cookie", pendingMfaSetCookie(record.id));
|
|
1609
|
+
return pending;
|
|
1610
|
+
}
|
|
1611
|
+
` : ""}const auth = dependencies.container.resolve<CookieSessionAuthManager>(CORE_AUTH_TOKEN);
|
|
1612
|
+
return auth.signInRedirect(sessionUser(record), "/");
|
|
1410
1613
|
})),
|
|
1411
1614
|
},` : "";
|
|
1412
|
-
const apiRoutes = jsonApi ? `
|
|
1615
|
+
const apiRoutes = jsonApi || cookie ? `
|
|
1413
1616
|
routes({ kernel, dependencies }) {
|
|
1414
|
-
return {${tokenLogin}${jsonRegister}${jwtLogin}${apiUser}${jsonPassword}${jsonVerify}
|
|
1617
|
+
return {${csrfRoute}${samlRoutes}${cookieJsonAuth}${tokenLogin}${jsonRegister}${jwtLogin}${apiUser}${jsonPassword}${jsonVerify}
|
|
1415
1618
|
};
|
|
1416
1619
|
},` : "";
|
|
1417
|
-
const mfaLoginBranch =
|
|
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.";
|
|
1689
|
+
if ((process.env.FEATURE_REGISTRATION ?? "true") === "false") {
|
|
1690
|
+
return new Response("Not found", { status: 404 });
|
|
1475
1691
|
}
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
if (email && (await starterAuthDirectory.findByEmail?.(email))) {
|
|
1483
|
-
errors.email = "Email is already registered.";
|
|
1484
|
-
}
|
|
1485
|
-
if (Object.keys(errors).length > 0) {
|
|
1486
|
-
return renderPage(
|
|
1487
|
-
"auth/register.eta",
|
|
1488
|
-
{ layout: { title: "Create account" }, errors, name, email, password: "" },
|
|
1489
|
-
request,
|
|
1490
|
-
);
|
|
1692
|
+
const { fields } = await parseFormBody(request);
|
|
1693
|
+
const { name, email, password } = parseRegisterCredentials(fields);
|
|
1694
|
+
const existing = await starterAuthDirectory.findByEmail?.(email);
|
|
1695
|
+
await hashPassword(password);
|
|
1696
|
+
if (existing) {
|
|
1697
|
+
${registerSuccessExisting}
|
|
1491
1698
|
}
|
|
1492
1699
|
const hashed = await hashPassword(password);
|
|
1493
|
-
await
|
|
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,13 +1943,13 @@ export default authModule;
|
|
|
1700
1943
|
function renderSiteModule(_layers) {
|
|
1701
1944
|
return `import type { AppModule } from "@getstrata/bootstrap/contracts";
|
|
1702
1945
|
import { withErrorHandling } from "@getstrata/core/http/response";
|
|
1703
|
-
import {
|
|
1946
|
+
import { pingDatabase } from "../../bootstrap/database.ts";
|
|
1947
|
+
import { Note } from "../../models/Note.ts";
|
|
1704
1948
|
import { plainText, renderPage } from "../../lib/view.ts";
|
|
1705
1949
|
|
|
1706
|
-
// Proves the database answers and the schema is migrated. Point it at a table your app owns.
|
|
1707
1950
|
async function schemaReady(): Promise<boolean> {
|
|
1708
1951
|
try {
|
|
1709
|
-
await
|
|
1952
|
+
await Note.query().limit(1).get();
|
|
1710
1953
|
return true;
|
|
1711
1954
|
} catch {
|
|
1712
1955
|
return false;
|
|
@@ -1911,6 +2154,7 @@ function renderLayout(layers, projectName) {
|
|
|
1911
2154
|
<meta name="description" content="<%= it.layout.description %>" />
|
|
1912
2155
|
<% } %>
|
|
1913
2156
|
<link rel="stylesheet" href="/assets/site.css" />
|
|
2157
|
+
<script src="/assets/htmx.min.js"></script>
|
|
1914
2158
|
</head>
|
|
1915
2159
|
<body>
|
|
1916
2160
|
<header class="site-header">
|
|
@@ -1939,7 +2183,7 @@ function renderHomeView(projectName, layers) {
|
|
|
1939
2183
|
<a class="button" href="/register">Create account</a>
|
|
1940
2184
|
<a class="button button-secondary" href="/login">Sign in</a>
|
|
1941
2185
|
</div>
|
|
1942
|
-
<p class="muted">Seeded demo: <code>demo@example.com</code> / <code>
|
|
2186
|
+
<p class="muted">Seeded demo: <code>demo@example.com</code> / <code>StrataDemo!ChangeMe</code>.</p>
|
|
1943
2187
|
<% } else { %>
|
|
1944
2188
|
<p class="lede">You are signed in as <strong><%= it.currentUser.email %></strong>.</p>
|
|
1945
2189
|
<p>Add routes in <code>src/modules</code>. This homepage is yours to restyle.</p>
|
|
@@ -2015,6 +2259,9 @@ function renderVerifyEmailView() {
|
|
|
2015
2259
|
function renderMfaChallengeView() {
|
|
2016
2260
|
return renderFormView("Two-factor code", `${textField("code", "Authenticator or recovery code", "text", 'required autocomplete="one-time-code"')}`, "Continue", `<a href="/login">Cancel</a>`).replace('action="<%= it.action || "" %>"', 'action="/login/mfa"');
|
|
2017
2261
|
}
|
|
2262
|
+
function renderConfirmPasswordView() {
|
|
2263
|
+
return renderFormView("Confirm your password", textField("password", "Current password", "password", 'required autocomplete="current-password"'), "Confirm", `<a href="/">Cancel</a>`).replace('action="<%= it.action || "" %>"', 'action=""');
|
|
2264
|
+
}
|
|
2018
2265
|
function renderMfaSetupView() {
|
|
2019
2266
|
return `<section class="auth-card">
|
|
2020
2267
|
<h1>Authenticator app</h1>
|
|
@@ -2050,111 +2297,105 @@ function renderAuthDirectory(layers) {
|
|
|
2050
2297
|
return null;
|
|
2051
2298
|
}
|
|
2052
2299
|
const hashed = hashApiToken(token);
|
|
2053
|
-
const
|
|
2054
|
-
{
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
abilities: string;
|
|
2058
|
-
expires_at: Date | string | null;
|
|
2059
|
-
role?: string;
|
|
2060
|
-
is_admin?: number | boolean;
|
|
2061
|
-
email_verified_at?: Date | string | null;
|
|
2062
|
-
}
|
|
2063
|
-
>(
|
|
2064
|
-
\`SELECT t.id, t.user_id, t.abilities, t.expires_at, u.is_admin, u.email_verified_at
|
|
2065
|
-
FROM api_tokens t INNER JOIN users u ON u.id = t.user_id
|
|
2066
|
-
WHERE t.token_hash = ?\`,
|
|
2067
|
-
[hashed],
|
|
2068
|
-
);
|
|
2069
|
-
const row = rows[0];
|
|
2070
|
-
if (!row) {
|
|
2300
|
+
const tokenRow = await runWithMigrationBypassForIdentifier(hashed, async () => {
|
|
2301
|
+
return await ApiToken.where({ token_hash: hashed }).first();
|
|
2302
|
+
});
|
|
2303
|
+
if (!tokenRow) {
|
|
2071
2304
|
return null;
|
|
2072
2305
|
}
|
|
2073
|
-
|
|
2306
|
+
const expiresAt = tokenRow.get("expires_at");
|
|
2307
|
+
if (expiresAt && new Date(String(expiresAt)).getTime() <= Date.now()) {
|
|
2308
|
+
return null;
|
|
2309
|
+
}
|
|
2310
|
+
const user_id = Number(tokenRow.get("user_id"));
|
|
2311
|
+
const user = await runWithMigrationBypassForIdentifier(user_id, async () => {
|
|
2312
|
+
return await User.find(user_id);
|
|
2313
|
+
});
|
|
2314
|
+
if (!user) {
|
|
2074
2315
|
return null;
|
|
2075
2316
|
}
|
|
2076
2317
|
let abilities: string[] = [];
|
|
2077
2318
|
try {
|
|
2078
|
-
abilities = JSON.parse(String(
|
|
2319
|
+
abilities = JSON.parse(String(tokenRow.get("abilities") ?? "[]")) as string[];
|
|
2079
2320
|
} catch {
|
|
2080
|
-
abilities = [
|
|
2321
|
+
abilities = [];
|
|
2081
2322
|
}
|
|
2082
2323
|
return {
|
|
2083
|
-
id:
|
|
2084
|
-
role:
|
|
2324
|
+
id: user_id,
|
|
2325
|
+
role: mapRole(user.get("is_admin")),
|
|
2085
2326
|
abilities,
|
|
2086
|
-
tokenId: Number(
|
|
2087
|
-
emailVerifiedAt:
|
|
2327
|
+
tokenId: Number(tokenRow.get("id")),
|
|
2328
|
+
emailVerifiedAt: asTimestamp(user.get("email_verified_at")),
|
|
2088
2329
|
};
|
|
2089
2330
|
},` : `
|
|
2090
2331
|
async resolveUserFromToken() {
|
|
2091
2332
|
return null;
|
|
2092
2333
|
},`;
|
|
2093
|
-
const placeholder = layers.database === "postgres" ? "$1" : "?";
|
|
2094
2334
|
const hashImport = authUsesToken(layers.auth) ? `import { hashApiToken } from "@getstrata/core/auth/tokenHash";
|
|
2095
2335
|
` : "";
|
|
2096
|
-
const
|
|
2097
|
-
|
|
2098
|
-
mfa_enabled?: number | boolean;
|
|
2099
|
-
mfa_secret?: string | null;
|
|
2100
|
-
mfa_recovery_codes?: string | null;` : "";
|
|
2336
|
+
const apiTokenImport = authUsesToken(layers.auth) ? `import { ApiToken } from "../models/ApiToken.ts";
|
|
2337
|
+
` : "";
|
|
2101
2338
|
const mfaReturn = layers.extras.mfa ? `
|
|
2102
|
-
mfa_enabled:
|
|
2103
|
-
mfa_secret:
|
|
2104
|
-
mfa_recovery_codes:
|
|
2105
|
-
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"` : "";
|
|
2106
2350
|
return `import type { AuthUser } from "@getstrata/core/auth/authContext";
|
|
2107
2351
|
import { verifyPassword } from "@getstrata/core/auth/password";
|
|
2108
|
-
${hashImport}import type { AuthUserDirectory } from "@getstrata/core/contracts/authUserDirectory";
|
|
2109
|
-
import {
|
|
2352
|
+
${hashImport}import type { AuthUserDirectory, AuthUserRecord } from "@getstrata/core/contracts/authUserDirectory";
|
|
2353
|
+
import { runWithMigrationBypassForIdentifier } from "@getstrata/core/tenant/databaseTenantContext";
|
|
2354
|
+
${apiTokenImport}import { User } from "../models/User.ts";
|
|
2110
2355
|
|
|
2111
|
-
type
|
|
2112
|
-
id:
|
|
2113
|
-
name: string;
|
|
2114
|
-
email: string;
|
|
2115
|
-
is_admin: number | boolean;
|
|
2116
|
-
email_verified_at: Date | string | null;
|
|
2117
|
-
password: string;${mfaColumns}
|
|
2356
|
+
type LoadedUser = {
|
|
2357
|
+
get(key: "id" | "name" | "email" | "is_admin" | "email_verified_at" | "password" | "session_valid_after"${mfaKeys}): unknown;
|
|
2118
2358
|
};
|
|
2119
2359
|
|
|
2120
2360
|
function mapRole(isAdmin: unknown): string {
|
|
2121
2361
|
return isAdmin === true || isAdmin === 1 || isAdmin === "1" ? "admin" : "member";
|
|
2122
2362
|
}
|
|
2123
2363
|
|
|
2124
|
-
function
|
|
2364
|
+
function asTimestamp(value: unknown): Date | string | null {
|
|
2365
|
+
return value instanceof Date || typeof value === "string" ? value : null;
|
|
2366
|
+
}
|
|
2367
|
+
${nullableStringHelper}
|
|
2368
|
+
function mapUserRow(user: LoadedUser): AuthUserRecord {
|
|
2125
2369
|
return {
|
|
2126
|
-
id: Number(
|
|
2127
|
-
name:
|
|
2128
|
-
email:
|
|
2129
|
-
role: mapRole(
|
|
2130
|
-
email_verified_at:
|
|
2131
|
-
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}
|
|
2132
2376
|
};
|
|
2133
2377
|
}
|
|
2134
2378
|
|
|
2135
2379
|
async function findUserById(id: number) {
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
}
|
|
2144
|
-
return mapUserRow(row);
|
|
2380
|
+
return await runWithMigrationBypassForIdentifier(id, async () => {
|
|
2381
|
+
const user = await User.find(id);
|
|
2382
|
+
if (!user) {
|
|
2383
|
+
throw new Error(\`User \${id} not found.\`);
|
|
2384
|
+
}
|
|
2385
|
+
return mapUserRow(user);
|
|
2386
|
+
});
|
|
2145
2387
|
}
|
|
2146
2388
|
|
|
2147
2389
|
async function findUserByEmail(email: string) {
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
return row ? mapUserRow(row) : null;
|
|
2390
|
+
email = email.trim().toLowerCase();
|
|
2391
|
+
return await runWithMigrationBypassForIdentifier(email, async () => {
|
|
2392
|
+
const user = await User.where({ email }).first();
|
|
2393
|
+
return user ? mapUserRow(user) : null;
|
|
2394
|
+
});
|
|
2154
2395
|
}
|
|
2155
2396
|
|
|
2156
2397
|
export const starterAuthDirectory: AuthUserDirectory = {
|
|
2157
|
-
${tokenLookup
|
|
2398
|
+
${tokenLookup}
|
|
2158
2399
|
|
|
2159
2400
|
findByIdOrThrow: findUserById,
|
|
2160
2401
|
|
|
@@ -2165,6 +2406,9 @@ ${tokenLookup.replace("WHERE t.token_hash = ?", `WHERE t.token_hash = ${placehol
|
|
|
2165
2406
|
if (!user?.password || !(await verifyPassword(password, user.password))) {
|
|
2166
2407
|
return null;
|
|
2167
2408
|
}
|
|
2409
|
+
if ("mfa_enabled" in user && user.mfa_enabled) {
|
|
2410
|
+
return null;
|
|
2411
|
+
}
|
|
2168
2412
|
return {
|
|
2169
2413
|
id: user.id,
|
|
2170
2414
|
role: user.role,
|
|
@@ -2183,6 +2427,11 @@ import type { ServiceProvider } from "@getstrata/core/contracts/di";
|
|
|
2183
2427
|
import { envFlagEnabled } from "@getstrata/core/runtime/appEnv";
|
|
2184
2428
|
|
|
2185
2429
|
class StarterAuthManager {
|
|
2430
|
+
async resolveWithSource(request?: Request) {
|
|
2431
|
+
const user = await this.resolve(request);
|
|
2432
|
+
return { user, credentialSource: user ? "guest" as const : null };
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2186
2435
|
async resolve(request?: Request): Promise<AuthUser | null> {
|
|
2187
2436
|
if (!envFlagEnabled(process.env.AUTH_DEV_HEADERS)) {
|
|
2188
2437
|
return request ? null : currentAuthUser();
|
|
@@ -2228,13 +2477,13 @@ export default authProvider;
|
|
|
2228
2477
|
role: user.is_admin ? "admin" : "member",
|
|
2229
2478
|
...(user.email_verified_at !== undefined ? { emailVerifiedAt: user.email_verified_at } : {}),
|
|
2230
2479
|
}),
|
|
2231
|
-
});` : ` const fallback = ${authUsesToken(layers.auth) ? "new DatabaseTokenGuard(container)" : "new JwtGuard()"};
|
|
2480
|
+
});` : ` const fallback = ${authUsesToken(layers.auth) ? "new DatabaseTokenGuard(container)" : "new JwtGuard(container)"};
|
|
2232
2481
|
const auth = new AuthManager(fallback);`;
|
|
2233
2482
|
const tokenRegs = authUsesToken(layers.auth) ? ` const apiGuard = new DatabaseTokenGuard(container);
|
|
2234
2483
|
auth.registerGuard("api", apiGuard);
|
|
2235
2484
|
auth.registerGuard("access_token", apiGuard);
|
|
2236
2485
|
auth.registerGuard("token", apiGuard);` : "";
|
|
2237
|
-
const jwtReg = authUsesJwt(layers.auth) ? ` auth.registerGuard("jwt", new JwtGuard());` : "";
|
|
2486
|
+
const jwtReg = authUsesJwt(layers.auth) ? ` auth.registerGuard("jwt", new JwtGuard(container));` : "";
|
|
2238
2487
|
const basicReg = authUsesToken(layers.auth) || authUsesJwt(layers.auth) ? ` auth.registerGuard("basic", new BasicAuthGuard(container));` : "";
|
|
2239
2488
|
const ability = authUsesToken(layers.auth) ? ` container.set(CORE_ABILITY_CHECKER_TOKEN, createTokenAbilityChecker());` : "";
|
|
2240
2489
|
const imports = [`import { CORE_AUTH_TOKEN } from "@getstrata/bootstrap/config";`];
|
|
@@ -2294,18 +2543,75 @@ export default authProvider;
|
|
|
2294
2543
|
function envFlag(value) {
|
|
2295
2544
|
return value ? "true" : "false";
|
|
2296
2545
|
}
|
|
2546
|
+
var GENERATED_POSTGRES_APP_ROLE = "strata_app";
|
|
2547
|
+
var GENERATED_POSTGRES_APP_PASSWORD = "dev-strata-app-change-me";
|
|
2548
|
+
var GENERATED_POSTGRES_SUPERUSER_PASSWORD = "dev-postgres-change-me";
|
|
2549
|
+
var GENERATED_MYSQL_ROOT_PASSWORD = "dev-mysql-change-me";
|
|
2297
2550
|
function appDatabaseName(projectName) {
|
|
2298
2551
|
return projectName.replace(/[^A-Za-z0-9_]/g, "_");
|
|
2299
2552
|
}
|
|
2553
|
+
function usesComposePostgres(layers) {
|
|
2554
|
+
return selectedDockerServices(layers).includes("postgres");
|
|
2555
|
+
}
|
|
2300
2556
|
function defaultDatabaseUrl(layers, projectName) {
|
|
2301
2557
|
if (layers.database === "sqlite") {
|
|
2302
2558
|
return "sqlite:./storage/app.sqlite";
|
|
2303
2559
|
}
|
|
2304
2560
|
const database = appDatabaseName(projectName);
|
|
2305
2561
|
if (layers.database === "mysql") {
|
|
2306
|
-
return `mysql://root
|
|
2562
|
+
return `mysql://root:${GENERATED_MYSQL_ROOT_PASSWORD}@localhost:3306/${database}`;
|
|
2307
2563
|
}
|
|
2308
|
-
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
|
+
`;
|
|
2309
2615
|
}
|
|
2310
2616
|
function renderEnvExample(projectName, layers) {
|
|
2311
2617
|
const lines = [
|
|
@@ -2315,8 +2621,10 @@ function renderEnvExample(projectName, layers) {
|
|
|
2315
2621
|
"PORT=3000",
|
|
2316
2622
|
"APP_URL=http://localhost:3000",
|
|
2317
2623
|
`DATABASE_URL=${defaultDatabaseUrl(layers, projectName)}`,
|
|
2624
|
+
...postgresAppRoleEnvNotes(layers),
|
|
2625
|
+
...layers.database === "postgres" ? [`MIGRATION_DATABASE_URL=${defaultMigrationDatabaseUrl(layers, projectName)}`] : [],
|
|
2318
2626
|
...layers.database === "sqlite" ? [] : [
|
|
2319
|
-
"# Optional.
|
|
2627
|
+
"# Optional. Point runtime at a different database than DATABASE_URL.",
|
|
2320
2628
|
"# APP_DATABASE_URL="
|
|
2321
2629
|
],
|
|
2322
2630
|
`DB_CONNECTION=${layers.database === "postgres" ? "pgsql" : layers.database}`,
|
|
@@ -2328,14 +2636,23 @@ function renderEnvExample(projectName, layers) {
|
|
|
2328
2636
|
`MAIL_DRIVER=${layers.mail}`,
|
|
2329
2637
|
`AUTH_DEV_HEADERS=${envFlag(layers.auth === "headers")}`
|
|
2330
2638
|
];
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2639
|
+
lines.push("APP_DEBUG=false");
|
|
2640
|
+
lines.push("FEATURE_PUBLIC_READS=false");
|
|
2641
|
+
lines.push("FEATURE_SIEM_EXPORT=false");
|
|
2642
|
+
lines.push("FEATURE_REGISTRATION=true");
|
|
2643
|
+
lines.push("FEATURE_SAML=false");
|
|
2644
|
+
lines.push("# SAML_IDP_SSO_URL=");
|
|
2645
|
+
lines.push("# SAML_IDP_CERT=");
|
|
2646
|
+
lines.push("# SAML_SP_ENTITY_ID=");
|
|
2647
|
+
lines.push("# SAML_ACS_URL=");
|
|
2648
|
+
lines.push("# SAML_IDP_ISSUER=");
|
|
2649
|
+
lines.push("# SAML_WANT_RESPONSE_SIGNED=");
|
|
2650
|
+
lines.push("# SAML_DISABLE_REQUESTED_AUTHN_CONTEXT=");
|
|
2336
2651
|
if (needsRedis(layers)) {
|
|
2337
|
-
lines.push("
|
|
2652
|
+
lines.push("REDIS_PASSWORD=dev-redis-change-me");
|
|
2653
|
+
lines.push("REDIS_URL=redis://:dev-redis-change-me@127.0.0.1:6379");
|
|
2338
2654
|
} else {
|
|
2655
|
+
lines.push("# REDIS_PASSWORD=");
|
|
2339
2656
|
lines.push("# REDIS_URL=redis://127.0.0.1:6379");
|
|
2340
2657
|
}
|
|
2341
2658
|
if (authUsesCookie(layers.auth) || layers.frontend === "server-htmx" || layers.frontend === "hybrid") {
|
|
@@ -2357,6 +2674,11 @@ function renderEnvExample(projectName, layers) {
|
|
|
2357
2674
|
lines.push("# FEATURE_API_TOKENS=false");
|
|
2358
2675
|
}
|
|
2359
2676
|
lines.push(`FEATURE_MFA=${envFlag(layers.extras.mfa)}`);
|
|
2677
|
+
if (layers.extras.mfa) {
|
|
2678
|
+
lines.push("KMS_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef");
|
|
2679
|
+
} else {
|
|
2680
|
+
lines.push("# KMS_ENCRYPTION_KEY=");
|
|
2681
|
+
}
|
|
2360
2682
|
lines.push(`FEATURE_EMAIL_VERIFICATION=${envFlag(layers.extras.emailVerification)}`);
|
|
2361
2683
|
if (layers.extras.metrics) {
|
|
2362
2684
|
lines.push("METRICS_TOKEN=dev-metrics-token-change-me");
|
|
@@ -2402,22 +2724,24 @@ function renderDockerCompose(projectName, layers) {
|
|
|
2402
2724
|
image: postgres:16-alpine
|
|
2403
2725
|
environment:
|
|
2404
2726
|
POSTGRES_USER: postgres
|
|
2405
|
-
POSTGRES_PASSWORD:
|
|
2727
|
+
POSTGRES_PASSWORD: ${GENERATED_POSTGRES_SUPERUSER_PASSWORD}
|
|
2406
2728
|
POSTGRES_DB: ${database}
|
|
2407
2729
|
ports:
|
|
2408
|
-
|
|
2730
|
+
# Localhost only. Do not publish this port on a production host.
|
|
2731
|
+
- "127.0.0.1:5432:5432"
|
|
2409
2732
|
volumes:
|
|
2410
|
-
- pgdata:/var/lib/postgresql/data
|
|
2733
|
+
- pgdata:/var/lib/postgresql/data
|
|
2734
|
+
- ./docker/postgres-init:/docker-entrypoint-initdb.d:ro`);
|
|
2411
2735
|
}
|
|
2412
2736
|
if (selectedSet.has("mysql")) {
|
|
2413
2737
|
const database = appDatabaseName(projectName);
|
|
2414
2738
|
services.push(` mysql:
|
|
2415
2739
|
image: mysql:8.4
|
|
2416
2740
|
environment:
|
|
2417
|
-
MYSQL_ROOT_PASSWORD:
|
|
2741
|
+
MYSQL_ROOT_PASSWORD: ${GENERATED_MYSQL_ROOT_PASSWORD}
|
|
2418
2742
|
MYSQL_DATABASE: ${database}
|
|
2419
2743
|
ports:
|
|
2420
|
-
- "3306:3306"
|
|
2744
|
+
- "127.0.0.1:3306:3306"
|
|
2421
2745
|
volumes:
|
|
2422
2746
|
- mysqldata:/var/lib/mysql`);
|
|
2423
2747
|
}
|
|
@@ -2425,25 +2749,28 @@ function renderDockerCompose(projectName, layers) {
|
|
|
2425
2749
|
const server = selectedSet.has("mysql") ? "mysql" : "postgres";
|
|
2426
2750
|
services.push(` adminer:
|
|
2427
2751
|
image: adminer:5.4.2
|
|
2752
|
+
profiles:
|
|
2753
|
+
- debug
|
|
2428
2754
|
environment:
|
|
2429
2755
|
ADMINER_DEFAULT_SERVER: ${server}
|
|
2430
2756
|
depends_on:
|
|
2431
2757
|
- ${server}
|
|
2432
2758
|
ports:
|
|
2433
|
-
- "8080:8080"`);
|
|
2759
|
+
- "127.0.0.1:8080:8080"`);
|
|
2434
2760
|
}
|
|
2435
2761
|
if (selectedSet.has("redis")) {
|
|
2436
2762
|
services.push(` redis:
|
|
2437
2763
|
image: redis:7-alpine
|
|
2764
|
+
command: ["redis-server", "--requirepass", "\${REDIS_PASSWORD:-dev-redis-change-me}"]
|
|
2438
2765
|
ports:
|
|
2439
|
-
- "6379:6379"`);
|
|
2766
|
+
- "127.0.0.1:6379:6379"`);
|
|
2440
2767
|
}
|
|
2441
2768
|
if (selectedSet.has("mailpit")) {
|
|
2442
2769
|
services.push(` mailpit:
|
|
2443
2770
|
image: axllent/mailpit:latest
|
|
2444
2771
|
ports:
|
|
2445
|
-
- "1025:1025"
|
|
2446
|
-
- "8025:8025"`);
|
|
2772
|
+
- "127.0.0.1:1025:1025"
|
|
2773
|
+
- "127.0.0.1:8025:8025"`);
|
|
2447
2774
|
}
|
|
2448
2775
|
const volumes = [];
|
|
2449
2776
|
if (selectedSet.has("postgres")) {
|
|
@@ -2529,9 +2856,9 @@ function renderPackageJson(projectName, options = {}) {
|
|
|
2529
2856
|
"@getstrata/cli": "workspace:*",
|
|
2530
2857
|
"@getstrata/core": "workspace:*"
|
|
2531
2858
|
} : {
|
|
2532
|
-
"@getstrata/bootstrap": "^1.0
|
|
2533
|
-
"@getstrata/cli": "^1.0
|
|
2534
|
-
"@getstrata/core": "^1.0
|
|
2859
|
+
"@getstrata/bootstrap": "^1.1.0",
|
|
2860
|
+
"@getstrata/cli": "^1.1.0",
|
|
2861
|
+
"@getstrata/core": "^1.1.0"
|
|
2535
2862
|
};
|
|
2536
2863
|
coreDeps.eta = "^4.6.0";
|
|
2537
2864
|
if (options.layers?.database === "mysql") {
|
|
@@ -2613,8 +2940,11 @@ function renderSupportingToolsReadme(layers) {
|
|
|
2613
2940
|
const system = mysql ? "MySQL" : "PostgreSQL";
|
|
2614
2941
|
const server = mysql ? "mysql" : "postgres";
|
|
2615
2942
|
const username = mysql ? "root" : "postgres";
|
|
2616
|
-
const password = mysql ?
|
|
2943
|
+
const password = mysql ? GENERATED_MYSQL_ROOT_PASSWORD : GENERATED_POSTGRES_SUPERUSER_PASSWORD;
|
|
2617
2944
|
lines.push(`Adminer: http://localhost:8080 (${system}, server \`${server}\`, username \`${username}\`, password \`${password}\`).`, "");
|
|
2945
|
+
if (!mysql) {
|
|
2946
|
+
lines.push("Adminer uses the Compose `postgres` superuser, which skips FORCE RLS. Application traffic should use `strata_app`.", "");
|
|
2947
|
+
}
|
|
2618
2948
|
}
|
|
2619
2949
|
}
|
|
2620
2950
|
if (localOn.length > 0) {
|
|
@@ -2628,13 +2958,13 @@ function renderSupportingToolsReadme(layers) {
|
|
|
2628
2958
|
}
|
|
2629
2959
|
function renderApiDocs(projectName, layers) {
|
|
2630
2960
|
const rows = ["| Method | Path | Notes |", "| --- | --- | --- |"];
|
|
2631
|
-
rows.push("| `GET` | `/health` | Plain text `ok` (200)
|
|
2961
|
+
rows.push("| `GET` | `/health` | Plain text `ok` (200) when the database ping succeeds and `notes` is readable under the request tenant (zero rows is still 200). `degraded` (503) when the ping or that read fails. Docker HEALTHCHECK uses this path. |", '| `GET` | `/ready` | JSON from the framework: `{"status":"ready","checks":{"database":"ok","redis":"skipped"}}` (200) or `not_ready` (503). `redis` is `ok` or `error` when `REDIS_URL` is set and `skipped` otherwise. It does not check the schema, so use `/health` as the deploy gate. |');
|
|
2632
2962
|
rows.push("| `GET` | `/` | Welcome page. Restyle or replace it. |");
|
|
2633
2963
|
if (authUsesToken(layers.auth)) {
|
|
2634
|
-
rows.push("| `POST` | `/api/v1/auth/login` | `{ email, password }` returns `{ token }`. |", "| `POST` | `/api/v1/auth/register` | Creates a user and returns a token. |", "| `GET` | `/api/v1/auth/me` | Requires `Authorization: Bearer <token>`. |");
|
|
2964
|
+
rows.push("| `POST` | `/api/v1/auth/login` | `{ email, password }` returns `{ token }`. Requires `GET /api/v1/auth/csrf` then `X-CSRF-Token`. |", "| `POST` | `/api/v1/auth/register` | Creates a user and returns a token. |", "| `GET` | `/api/v1/auth/me` | Requires `Authorization: Bearer <token>`. |");
|
|
2635
2965
|
}
|
|
2636
2966
|
if (authUsesJwt(layers.auth)) {
|
|
2637
|
-
rows.push("| `POST` | `/api/auth/token` | Mints a short-lived JWT. Not an HTML session. |");
|
|
2967
|
+
rows.push("| `POST` | `/api/auth/token` | Mints a short-lived JWT. Not an HTML session. Requires CSRF like JSON login. |");
|
|
2638
2968
|
}
|
|
2639
2969
|
if (authNeedsUsers(layers.auth)) {
|
|
2640
2970
|
rows.push("| `POST` | `/api/v1/auth/forgot-password` | Sends a signed reset link through the mail driver. |", "| `POST` | `/api/v1/auth/reset-password` | Consumes the signed link. |", "| `GET` | `/api/user` | Current user for the active guard. |");
|
|
@@ -2642,7 +2972,7 @@ function renderApiDocs(projectName, layers) {
|
|
|
2642
2972
|
if (layers.extras.metrics) {
|
|
2643
2973
|
rows.push("| `GET` | `/metrics` | Prometheus text. Production requires `Authorization: Bearer <METRICS_TOKEN>`. |");
|
|
2644
2974
|
}
|
|
2645
|
-
const authNote = layers.auth === "headers" ? `Auth is \`headers\`. Send \`x-authenticated-user-id\` (and optional \`x-authenticated-user-role\`) for local work and tests. There are no login endpoints and no \`users\` table. Production must set \`AUTH_DEV_HEADERS=false\`, which turns those headers off and leaves you without a guard, so pick another auth layer before you ship.` : authUsesToken(layers.auth) ? `Sign in with \`POST /api/v1/auth/login
|
|
2975
|
+
const authNote = layers.auth === "headers" ? `Auth is \`headers\`. Send \`x-authenticated-user-id\` (and optional \`x-authenticated-user-role\`) for local work and tests. There are no login endpoints and no \`users\` table. Production must set \`AUTH_DEV_HEADERS=false\`, which turns those headers off and leaves you without a guard, so pick another auth layer before you ship.` : authUsesToken(layers.auth) ? `Sign in with \`GET /api/v1/auth/csrf\` then \`POST /api/v1/auth/login\` (send \`X-CSRF-Token\`), then send \`Authorization: Bearer <token>\` on every request. Tokens are stored hashed in \`api_tokens\` and expire after \`API_TOKEN_DEFAULT_EXPIRY_DAYS\` (30 in \`.env.example\`). The response includes \`expires_at\`.` : `Mint a JWT with \`POST /api/auth/token\`, then send \`Authorization: Bearer <jwt>\`. JWTs expire; re-mint rather than refreshing in place.`;
|
|
2646
2976
|
return `# ${projectName} API
|
|
2647
2977
|
|
|
2648
2978
|
\`FRONTEND_MODE=${layers.frontend}\`. ${layers.frontend === "api" ? "No server-rendered views beyond the welcome page and no SPA assets." : `HTML is served alongside this API. The SPA is mounted at \`${layers.spaPrefix}\`.`}
|
|
@@ -2666,7 +2996,7 @@ Create a module under \`src/modules/\` and return a route map. Modules are disco
|
|
|
2666
2996
|
// src/modules/notes/index.ts
|
|
2667
2997
|
import type { AppModule } from "@getstrata/bootstrap/contracts";
|
|
2668
2998
|
import { jsonResponse } from "@getstrata/core/http/response";
|
|
2669
|
-
import {
|
|
2999
|
+
import { Note } from "../../models/Note.ts";
|
|
2670
3000
|
|
|
2671
3001
|
const notesModule: AppModule = {
|
|
2672
3002
|
name: "notes",
|
|
@@ -2674,10 +3004,8 @@ const notesModule: AppModule = {
|
|
|
2674
3004
|
routes({ kernel }) {
|
|
2675
3005
|
return {
|
|
2676
3006
|
"/api/v1/notes": kernel.wrap("api", async () => {
|
|
2677
|
-
const
|
|
2678
|
-
|
|
2679
|
-
);
|
|
2680
|
-
return jsonResponse({ data: rows });
|
|
3007
|
+
const bodiesById = await Note.query().orderBy({ id: "desc" }).pluck("body", "id");
|
|
3008
|
+
return jsonResponse({ data: Object.fromEntries(bodiesById) });
|
|
2681
3009
|
}),
|
|
2682
3010
|
};
|
|
2683
3011
|
},
|
|
@@ -2695,7 +3023,25 @@ Import from \`@getstrata/core/...\` subpaths rather than the package root, so si
|
|
|
2695
3023
|
- [Databases](https://github.com/EyK-26/strata/blob/main/docs/DATABASE.md)
|
|
2696
3024
|
`;
|
|
2697
3025
|
}
|
|
2698
|
-
function
|
|
3026
|
+
function renderReadmeIntro(projectName, options) {
|
|
3027
|
+
if (options.dogfood) {
|
|
3028
|
+
return `# ${projectName}
|
|
3029
|
+
|
|
3030
|
+
This in-repo app is Strata dogfood for internal end-to-end testing (migrate, seed, boot, OpenAPI, smoke). It is not a product. Start a product app with \`bunx create-strata\`.
|
|
3031
|
+
`;
|
|
3032
|
+
}
|
|
3033
|
+
if (options.inRepoExample) {
|
|
3034
|
+
return `# ${projectName}
|
|
3035
|
+
|
|
3036
|
+
This in-repo app is a generated sibling layer map. It is not a product and it is not CI dogfood. CI dogfood is \`apps/hiroapp\`. Start a product app with \`bunx create-strata\`.
|
|
3037
|
+
`;
|
|
3038
|
+
}
|
|
3039
|
+
return `# ${projectName}
|
|
3040
|
+
|
|
3041
|
+
Strata app generated by \`create-strata\`.
|
|
3042
|
+
`;
|
|
3043
|
+
}
|
|
3044
|
+
function renderReadme(projectName, layers, options = {}) {
|
|
2699
3045
|
const docker = renderDockerCompose(projectName, layers);
|
|
2700
3046
|
const next = [`cd ${projectName}`, "cp .env.example .env"];
|
|
2701
3047
|
if (docker) {
|
|
@@ -2710,10 +3056,7 @@ function renderReadme(projectName, layers) {
|
|
|
2710
3056
|
const dockerServices = selectedDockerServices(layers);
|
|
2711
3057
|
const neededTools = neededDockerServices(layers);
|
|
2712
3058
|
const dockerLabel = neededTools.length === 0 ? "not needed" : dockerServices.length > 0 ? dockerServices.join(", ") : "off (local installs)";
|
|
2713
|
-
return
|
|
2714
|
-
|
|
2715
|
-
Strata app generated by \`create-strata\`.
|
|
2716
|
-
|
|
3059
|
+
return `${renderReadmeIntro(projectName, options)}
|
|
2717
3060
|
## Layers
|
|
2718
3061
|
|
|
2719
3062
|
| Layer | Choice |
|
|
@@ -2741,7 +3084,7 @@ ${next.join(`
|
|
|
2741
3084
|
Open http://localhost:3000. Health check: \`GET /health\`.
|
|
2742
3085
|
|
|
2743
3086
|
${renderSupportingToolsReadme(layers)}${layers.auth !== "headers" ? `
|
|
2744
|
-
Seeded login (password \`
|
|
3087
|
+
Seeded login (password \`StrataDemo!ChangeMe\`):
|
|
2745
3088
|
|
|
2746
3089
|
- \`demo@example.com\` (member)
|
|
2747
3090
|
- \`admin@example.test\` (admin)
|
|
@@ -2777,7 +3120,7 @@ Until \`frontend/dist\` exists, \`${layers.spaPrefix}\` answers 503. Use \`bun r
|
|
|
2777
3120
|
` : ""}${layers.database === "sqlite" ? "" : `
|
|
2778
3121
|
## Database
|
|
2779
3122
|
|
|
2780
|
-
The app uses the database named in \`DATABASE_URL\` and creates it on first migrate when the connection user may. Set \`APP_DATABASE_URL\` only when migrations and the app should target a different database than \`DATABASE_URL\`.
|
|
3123
|
+
The app uses the database named in \`DATABASE_URL\` and creates it on first migrate when the connection user may. Set \`APP_DATABASE_URL\` only when migrations and the app should target a different database than \`DATABASE_URL\`.${usesComposePostgres(layers) ? ` \`.env.example\` points \`DATABASE_URL\` at \`${GENERATED_POSTGRES_APP_ROLE}\` (\`NOSUPERUSER\` \`NOBYPASSRLS\`), including \`--no-docker\`. \`db/ensure-postgres-app-role.sql\` is repeatable on an existing volume; Compose init still runs on first empty volume. The \`postgres\` superuser is for CREATE ROLE / GRANT / migrate and Adminer.` : ""}
|
|
2781
3124
|
`}
|
|
2782
3125
|
## Deploy
|
|
2783
3126
|
|
|
@@ -2801,11 +3144,112 @@ The image sets \`APP_ENV=production\` and \`AUTH_DEV_HEADERS=false\`; everything
|
|
|
2801
3144
|
- Set \`FEATURE_PUBLIC_READS=false\`. ${layers.frontend === "api" ? "This app already ships `false`." : "This app ships `true` so the local welcome page reads without a login. Production requires `false`."}
|
|
2802
3145
|
- Cross-origin browser calls are off in production until you set \`CORS_ALLOWED_ORIGINS\` to explicit origins. A \`*\` entry is rejected. Non-browser clients are unaffected.
|
|
2803
3146
|
- Behind a reverse proxy or load balancer, set \`TRUST_FORWARDED_FOR=true\` so throttles and session records see the client address instead of the proxy. Only the rightmost public hop of \`X-Forwarded-For\` is trusted.
|
|
2804
|
-
${authUsesCookie(layers.auth) ? "- Set `SESSION_SECRET` to 32+ characters.\n" : ""}${authUsesToken(layers.auth) ? "- Set `TOKEN_HASH_PEPPER`.\n" : ""}${layers.extras.scim ? "- Set `SCIM_BEARER_TOKEN`.\n" : ""}${layers.extras.metrics ? "- Set `METRICS_TOKEN`.\n" : ""}
|
|
2805
|
-
|
|
3147
|
+
${authUsesCookie(layers.auth) ? "- Set `SESSION_SECRET` to 32+ characters.\n" : ""}${authUsesToken(layers.auth) ? "- Set `TOKEN_HASH_PEPPER`.\n" : ""}${layers.extras.scim ? "- Set `SCIM_BEARER_TOKEN`.\n" : ""}${layers.extras.metrics ? "- Set `METRICS_TOKEN`.\n" : ""}${layers.tenancy === "rls" ? `- \`DATABASE_URL\` must be a \`NOBYPASSRLS\` role, not the \`postgres\` superuser. Generated apps create \`${GENERATED_POSTGRES_APP_ROLE}\` via \`db/ensure-postgres-app-role.sql\` (and Compose init on first empty volume). \`MIGRATION_DATABASE_URL\` may stay the superuser for CREATE ROLE / GRANT / migrate. Production boot rejects username \`postgres\` or \`root\`, then inspects \`pg_roles\` for \`rolsuper\` / \`rolbypassrls\`.
|
|
3148
|
+
` : ""}
|
|
3149
|
+
\`strata start\` does not migrate when \`APP_ENV=production\`. Run \`bun run db:migrate\` as a deploy step. \`GET /health\` is 200 when \`notes\` is readable (including zero rows) and 503 when that read fails.
|
|
2806
3150
|
`;
|
|
2807
3151
|
}
|
|
2808
3152
|
|
|
3153
|
+
// ../../src/core/runtime/appEnv.ts
|
|
3154
|
+
var NON_PRODUCTION_APP_ENVS = new Set(["local", "development", "dev", "test", "testing", "ci"]);
|
|
3155
|
+
|
|
3156
|
+
// ../../src/core/tenant/enableTenantRls.ts
|
|
3157
|
+
function assertSafeIdentifier(identifier) {
|
|
3158
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
|
|
3159
|
+
throw new Error(`Invalid SQL identifier: ${identifier}`);
|
|
3160
|
+
}
|
|
3161
|
+
return identifier;
|
|
3162
|
+
}
|
|
3163
|
+
var RLS_HELPER_SQL = `
|
|
3164
|
+
CREATE OR REPLACE FUNCTION app_bypass_rls()
|
|
3165
|
+
RETURNS BOOLEAN AS $$
|
|
3166
|
+
BEGIN
|
|
3167
|
+
RETURN COALESCE(current_setting('app.bypass_rls', true), 'false') = 'true';
|
|
3168
|
+
EXCEPTION
|
|
3169
|
+
WHEN others THEN
|
|
3170
|
+
RETURN FALSE;
|
|
3171
|
+
END;
|
|
3172
|
+
$$ LANGUAGE plpgsql STABLE;
|
|
3173
|
+
|
|
3174
|
+
CREATE OR REPLACE FUNCTION app_bypass_identifier()
|
|
3175
|
+
RETURNS TEXT AS $$
|
|
3176
|
+
BEGIN
|
|
3177
|
+
RETURN NULLIF(current_setting('app.bypass_identifier', true), '');
|
|
3178
|
+
EXCEPTION
|
|
3179
|
+
WHEN others THEN
|
|
3180
|
+
RETURN NULL;
|
|
3181
|
+
END;
|
|
3182
|
+
$$ LANGUAGE plpgsql STABLE;
|
|
3183
|
+
|
|
3184
|
+
CREATE OR REPLACE FUNCTION app_current_tenant_id()
|
|
3185
|
+
RETURNS INTEGER AS $$
|
|
3186
|
+
BEGIN
|
|
3187
|
+
RETURN NULLIF(current_setting('app.tenant_id', true), '')::INTEGER;
|
|
3188
|
+
EXCEPTION
|
|
3189
|
+
WHEN others THEN
|
|
3190
|
+
RETURN NULL;
|
|
3191
|
+
END;
|
|
3192
|
+
$$ LANGUAGE plpgsql STABLE;
|
|
3193
|
+
`;
|
|
3194
|
+
function tenantIsolationPolicy(table, predicate) {
|
|
3195
|
+
return `
|
|
3196
|
+
ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY;
|
|
3197
|
+
ALTER TABLE ${table} FORCE ROW LEVEL SECURITY;
|
|
3198
|
+
DROP POLICY IF EXISTS tenant_isolation ON ${table};
|
|
3199
|
+
CREATE POLICY tenant_isolation ON ${table}
|
|
3200
|
+
USING (
|
|
3201
|
+
app_bypass_rls()
|
|
3202
|
+
OR ${predicate}
|
|
3203
|
+
)
|
|
3204
|
+
WITH CHECK (
|
|
3205
|
+
app_bypass_rls()
|
|
3206
|
+
OR ${predicate}
|
|
3207
|
+
);
|
|
3208
|
+
`;
|
|
3209
|
+
}
|
|
3210
|
+
function enableTenantRlsSql(tableName) {
|
|
3211
|
+
const table = assertSafeIdentifier(tableName);
|
|
3212
|
+
return tenantIsolationPolicy(table, `tenant_id = app_current_tenant_id()`);
|
|
3213
|
+
}
|
|
3214
|
+
function enableUsersRlsSql() {
|
|
3215
|
+
return tenantIsolationPolicy("users", `tenant_id = app_current_tenant_id()
|
|
3216
|
+
OR (
|
|
3217
|
+
app_bypass_identifier() IS NOT NULL
|
|
3218
|
+
AND (
|
|
3219
|
+
id::text = app_bypass_identifier()
|
|
3220
|
+
OR email = app_bypass_identifier()
|
|
3221
|
+
)
|
|
3222
|
+
)`);
|
|
3223
|
+
}
|
|
3224
|
+
function userOwnedIdentifierPredicate(table, userIdColumn) {
|
|
3225
|
+
const userIdMatch = `${table}.${userIdColumn}::text = app_bypass_identifier()`;
|
|
3226
|
+
if (table === "sessions") {
|
|
3227
|
+
return `(app_bypass_identifier() IS NOT NULL AND (${table}.id::text = app_bypass_identifier() OR ${userIdMatch}))`;
|
|
3228
|
+
}
|
|
3229
|
+
if (table === "api_tokens" || table === "auth_one_time_tokens") {
|
|
3230
|
+
return `(app_bypass_identifier() IS NOT NULL AND (${table}.token_hash = app_bypass_identifier() OR ${userIdMatch}))`;
|
|
3231
|
+
}
|
|
3232
|
+
return `(app_bypass_identifier() IS NOT NULL AND ${userIdMatch})`;
|
|
3233
|
+
}
|
|
3234
|
+
function enableUserOwnedRlsSql(tableName, userIdColumn = "user_id") {
|
|
3235
|
+
const table = assertSafeIdentifier(tableName);
|
|
3236
|
+
const column = assertSafeIdentifier(userIdColumn);
|
|
3237
|
+
return tenantIsolationPolicy(table, `EXISTS (
|
|
3238
|
+
SELECT 1 FROM users u
|
|
3239
|
+
WHERE u.id = ${table}.${column}
|
|
3240
|
+
AND u.tenant_id = app_current_tenant_id()
|
|
3241
|
+
)
|
|
3242
|
+
OR ${userOwnedIdentifierPredicate(table, column)}`);
|
|
3243
|
+
}
|
|
3244
|
+
function generatedRlsBootstrapSql(tables, userOwnedTables = []) {
|
|
3245
|
+
return [
|
|
3246
|
+
RLS_HELPER_SQL,
|
|
3247
|
+
...tables.map((table) => table === "users" ? enableUsersRlsSql() : enableTenantRlsSql(table)),
|
|
3248
|
+
...userOwnedTables.map((table) => enableUserOwnedRlsSql(table))
|
|
3249
|
+
].join(`
|
|
3250
|
+
`);
|
|
3251
|
+
}
|
|
3252
|
+
|
|
2809
3253
|
// src/renderRuntime.ts
|
|
2810
3254
|
function needsEnsure(layers) {
|
|
2811
3255
|
return layers.database !== "sqlite";
|
|
@@ -3038,17 +3482,19 @@ function renderMigrateTs(layers) {
|
|
|
3038
3482
|
statements.push(`CREATE TABLE IF NOT EXISTS tenant (
|
|
3039
3483
|
id ${d.id},
|
|
3040
3484
|
slug ${d.keyText} NOT NULL UNIQUE,
|
|
3041
|
-
plan ${d.defaultText} NOT NULL DEFAULT '
|
|
3485
|
+
plan ${d.defaultText} NOT NULL DEFAULT 'free',
|
|
3042
3486
|
region ${d.defaultText} NOT NULL DEFAULT 'eu'
|
|
3043
3487
|
)`);
|
|
3044
3488
|
}
|
|
3489
|
+
const notesTenantColumn = tenancyOn ? `
|
|
3490
|
+
tenant_id INTEGER NOT NULL DEFAULT 1,` : "";
|
|
3045
3491
|
statements.push(`CREATE TABLE IF NOT EXISTS notes (
|
|
3046
3492
|
id ${d.id},
|
|
3047
|
-
body ${d.text} NOT NULL
|
|
3493
|
+
body ${d.text} NOT NULL,${notesTenantColumn}
|
|
3048
3494
|
created_at ${d.timestamp}
|
|
3049
3495
|
)`);
|
|
3050
3496
|
if (authNeedsUsers(layers.auth)) {
|
|
3051
|
-
const tenantColumn = tenancyOn ? `
|
|
3497
|
+
const tenantColumn = tenancyOn || layers.extras.scim ? `
|
|
3052
3498
|
tenant_id INTEGER NOT NULL DEFAULT 1,` : "";
|
|
3053
3499
|
const mfaColumns = mfaOn ? `
|
|
3054
3500
|
mfa_secret ${d.text},
|
|
@@ -3060,8 +3506,17 @@ function renderMigrateTs(layers) {
|
|
|
3060
3506
|
email ${d.keyText} NOT NULL UNIQUE,
|
|
3061
3507
|
password ${d.text} NOT NULL,
|
|
3062
3508
|
is_admin ${d.bool},${tenantColumn}${mfaColumns}
|
|
3509
|
+
session_valid_after ${d.timestampNull},
|
|
3063
3510
|
email_verified_at ${d.timestampNull},
|
|
3064
3511
|
created_at ${d.timestamp}
|
|
3512
|
+
)`);
|
|
3513
|
+
statements.push(`CREATE TABLE IF NOT EXISTS auth_one_time_tokens (
|
|
3514
|
+
id ${d.id},
|
|
3515
|
+
purpose ${d.keyText} NOT NULL,
|
|
3516
|
+
user_id INTEGER NOT NULL,
|
|
3517
|
+
token_hash ${d.keyText} NOT NULL UNIQUE,
|
|
3518
|
+
expires_at ${d.timestamp},
|
|
3519
|
+
consumed_at ${d.timestampNull}
|
|
3065
3520
|
)`);
|
|
3066
3521
|
}
|
|
3067
3522
|
if (authUsesCookie(layers.auth)) {
|
|
@@ -3071,7 +3526,15 @@ function renderMigrateTs(layers) {
|
|
|
3071
3526
|
expires_at ${d.timestamp},
|
|
3072
3527
|
user_agent ${d.text},
|
|
3073
3528
|
ip_address ${d.text},
|
|
3074
|
-
last_active_at ${d.timestamp}
|
|
3529
|
+
last_active_at ${d.timestamp},
|
|
3530
|
+
created_at ${d.timestamp}
|
|
3531
|
+
)`);
|
|
3532
|
+
if (layers.database === "postgres") {
|
|
3533
|
+
statements.push("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()");
|
|
3534
|
+
}
|
|
3535
|
+
statements.push(`CREATE TABLE IF NOT EXISTS auth_saml_assertions (
|
|
3536
|
+
assertion_id ${d.keyText} PRIMARY KEY,
|
|
3537
|
+
consumed_at ${d.timestamp}
|
|
3075
3538
|
)`);
|
|
3076
3539
|
}
|
|
3077
3540
|
if (authUsesToken(layers.auth)) {
|
|
@@ -3086,17 +3549,26 @@ function renderMigrateTs(layers) {
|
|
|
3086
3549
|
created_at ${d.timestamp}
|
|
3087
3550
|
)`);
|
|
3088
3551
|
}
|
|
3089
|
-
const
|
|
3552
|
+
const rlsOn = layers.tenancy === "rls" && layers.database === "postgres";
|
|
3553
|
+
if (rlsOn) {
|
|
3554
|
+
const rlsTables = authNeedsUsers(layers.auth) ? ["notes", "users"] : ["notes"];
|
|
3555
|
+
const userOwnedTables = [];
|
|
3556
|
+
if (authNeedsUsers(layers.auth)) {
|
|
3557
|
+
userOwnedTables.push("auth_one_time_tokens");
|
|
3558
|
+
}
|
|
3559
|
+
if (authUsesCookie(layers.auth)) {
|
|
3560
|
+
userOwnedTables.push("sessions");
|
|
3561
|
+
}
|
|
3562
|
+
if (authUsesToken(layers.auth)) {
|
|
3563
|
+
userOwnedTables.push("api_tokens");
|
|
3564
|
+
}
|
|
3565
|
+
statements.push(generatedRlsBootstrapSql(rlsTables, userOwnedTables).trim());
|
|
3566
|
+
}
|
|
3567
|
+
const list = statements.map((sql) => ` \`${sql.replace(/`/g, "\\`")}\`,`).join(`
|
|
3090
3568
|
`);
|
|
3091
3569
|
const ph = layers.database === "postgres";
|
|
3092
|
-
const notePlaceholder = ph ? "$1" : "?";
|
|
3093
3570
|
const verifyOn = layers.extras.emailVerification && authNeedsUsers(layers.auth);
|
|
3094
|
-
const userColumns = verifyOn ? "name, email, password, is_admin, email_verified_at" : "name, email, password, is_admin";
|
|
3095
|
-
const userPlaceholders = verifyOn ? ph ? "$1, $2, $3, $4, $5), ($6, $7, $8, $9, $10" : "?, ?, ?, ?, ?), (?, ?, ?, ?, ?" : ph ? "$1, $2, $3, $4), ($5, $6, $7, $8" : "?, ?, ?, ?), (?, ?, ?, ?";
|
|
3096
|
-
const adminFlag = ph ? "false" : "0";
|
|
3097
|
-
const adminTrue = ph ? "true" : "1";
|
|
3098
3571
|
const verifiedNow = nowTimestampLiteral(layers.database);
|
|
3099
|
-
const userValues = verifyOn ? `["Demo User", "demo@example.com", password, ${adminFlag}, ${verifiedNow}, "Admin User", "admin@example.test", password, ${adminTrue}, ${verifiedNow}]` : `["Demo User", "demo@example.com", password, ${adminFlag}, "Admin User", "admin@example.test", password, ${adminTrue}]`;
|
|
3100
3572
|
const seedTenant = tenancyOn ? `
|
|
3101
3573
|
const [{ count: tenantCount }] = await sql.unsafe<{ count: string | number }>(
|
|
3102
3574
|
"SELECT COUNT(*) AS count FROM tenant",
|
|
@@ -3104,47 +3576,80 @@ function renderMigrateTs(layers) {
|
|
|
3104
3576
|
if (Number(tenantCount) === 0) {
|
|
3105
3577
|
await sql.unsafe(
|
|
3106
3578
|
"INSERT INTO tenant (slug, plan, region) VALUES (${ph ? "$1, $2, $3" : "?, ?, ?"})",
|
|
3107
|
-
["default", "
|
|
3579
|
+
["default", "free", "eu"],
|
|
3108
3580
|
);
|
|
3109
3581
|
}` : "";
|
|
3110
3582
|
const seedUsers = authNeedsUsers(layers.auth) ? `
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
${
|
|
3119
|
-
);
|
|
3583
|
+
if ((await User.query().value("id")) === null) {
|
|
3584
|
+
const password = await hashPassword("StrataDemo!ChangeMe");
|
|
3585
|
+
await User.create({
|
|
3586
|
+
name: "Demo User",
|
|
3587
|
+
email: "demo@example.com",
|
|
3588
|
+
password,
|
|
3589
|
+
is_admin: false,${verifyOn ? `
|
|
3590
|
+
email_verified_at: ${verifiedNow},` : ""}
|
|
3591
|
+
});
|
|
3592
|
+
await User.create({
|
|
3593
|
+
name: "Admin User",
|
|
3594
|
+
email: "admin@example.test",
|
|
3595
|
+
password,
|
|
3596
|
+
is_admin: true,${verifyOn ? `
|
|
3597
|
+
email_verified_at: ${verifiedNow},` : ""}
|
|
3598
|
+
});
|
|
3120
3599
|
}` : "";
|
|
3121
3600
|
const hashImport = authNeedsUsers(layers.auth) ? `import { hashPassword } from "@getstrata/core/auth/password";
|
|
3122
3601
|
` : "";
|
|
3123
|
-
const
|
|
3124
|
-
|
|
3125
|
-
|
|
3602
|
+
const rlsBypassImport = layers.tenancy === "rls" && layers.database === "postgres" ? `import { runWithMigrationBypass } from "@getstrata/core/tenant/databaseTenantContext";
|
|
3603
|
+
` : "";
|
|
3604
|
+
const postgresRoleImport = layers.database === "postgres" ? `import { grantPostgresAppRolePrivileges, openPostgresAdminConnection, postgresDatabaseNameFromUrl } from "@getstrata/core/tenant/enableTenantRls";
|
|
3605
|
+
` : "";
|
|
3606
|
+
const bindSql = seedTenant || layers.tenancy === "rls" && layers.database === "postgres" ? ` const sql = getSql();
|
|
3607
|
+
` : "";
|
|
3608
|
+
const seedOpen = layers.tenancy === "rls" && layers.database === "postgres" ? ` await runWithMigrationBypass(async () => {
|
|
3609
|
+
` : "";
|
|
3610
|
+
const seedClose = layers.tenancy === "rls" && layers.database === "postgres" ? `
|
|
3611
|
+
});` : "";
|
|
3612
|
+
const noteCreate = tenancyOn ? `await Note.create({ body: "Welcome to Strata!", tenant_id: 1 });` : `await Note.create({ body: "Welcome to Strata!" });`;
|
|
3613
|
+
const userImport = authNeedsUsers(layers.auth) ? `import { User } from "../models/User.ts";
|
|
3614
|
+
` : "";
|
|
3615
|
+
const needsSqlClient = layers.database !== "postgres" || Boolean(seedTenant) || layers.tenancy === "rls" && layers.database === "postgres";
|
|
3616
|
+
const databaseImport = needsSqlClient ? `import { closeDatabase, getSql } from "../bootstrap/database.ts";
|
|
3617
|
+
` : `import { closeDatabase } from "../bootstrap/database.ts";
|
|
3618
|
+
`;
|
|
3619
|
+
return `${hashImport}${rlsBypassImport}${postgresRoleImport}${ensureImport(layers)}${databaseImport}import { Note } from "../models/Note.ts";
|
|
3620
|
+
${userImport}
|
|
3126
3621
|
const migrations = [
|
|
3127
3622
|
${list}
|
|
3128
3623
|
];
|
|
3129
3624
|
|
|
3130
3625
|
export async function seed() {
|
|
3131
|
-
${ensureCall(layers)}
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
if (Number(count) === 0) {
|
|
3136
|
-
await sql.unsafe("INSERT INTO notes (body) VALUES (${notePlaceholder})", [
|
|
3137
|
-
"Welcome to Strata!",
|
|
3138
|
-
]);
|
|
3139
|
-
}${seedBlock}
|
|
3626
|
+
${ensureCall(layers)}${bindSql}${seedOpen}${seedTenant}
|
|
3627
|
+
if ((await Note.query().value("id")) === null) {
|
|
3628
|
+
${noteCreate}
|
|
3629
|
+
}${seedUsers}${seedClose}
|
|
3140
3630
|
}
|
|
3141
3631
|
|
|
3142
3632
|
export async function migrate() {
|
|
3143
|
-
${ensureCall(layers)} const
|
|
3633
|
+
${ensureCall(layers)}${layers.database === "postgres" ? ` const runtimeUrl = process.env.DATABASE_URL ?? "";
|
|
3634
|
+
const admin = await openPostgresAdminConnection({
|
|
3635
|
+
runtimeUrl,
|
|
3636
|
+
migrationUrl: process.env.MIGRATION_DATABASE_URL,
|
|
3637
|
+
});
|
|
3638
|
+
try {
|
|
3639
|
+
for (const statement of migrations) {
|
|
3640
|
+
await admin.unsafe(statement);
|
|
3641
|
+
}
|
|
3642
|
+
await grantPostgresAppRolePrivileges(admin, {
|
|
3643
|
+
database: postgresDatabaseNameFromUrl(runtimeUrl),
|
|
3644
|
+
});
|
|
3645
|
+
} finally {
|
|
3646
|
+
await admin.close?.();
|
|
3647
|
+
}
|
|
3648
|
+
` : ` const sql = getSql();
|
|
3144
3649
|
for (const statement of migrations) {
|
|
3145
3650
|
await sql.unsafe(statement);
|
|
3146
3651
|
}
|
|
3147
|
-
await seed();
|
|
3652
|
+
`} await seed();
|
|
3148
3653
|
}
|
|
3149
3654
|
|
|
3150
3655
|
/** The CLI calls this after migrate() so pooled drivers do not hold the process open. */
|
|
@@ -3160,6 +3665,202 @@ if (import.meta.main) {
|
|
|
3160
3665
|
}
|
|
3161
3666
|
`;
|
|
3162
3667
|
}
|
|
3668
|
+
function renderNoteModel(layers) {
|
|
3669
|
+
const tenancyOn = usesTenantTable(layers.tenancy);
|
|
3670
|
+
const tenantField = tenancyOn ? `
|
|
3671
|
+
tenant_id: number;` : "";
|
|
3672
|
+
const tenantColumn = tenancyOn ? ', "tenant_id"' : "";
|
|
3673
|
+
const fillable = tenancyOn ? '["body", "tenant_id"]' : '["body"]';
|
|
3674
|
+
return `import { BaseRepository } from "@getstrata/core/database/baseRepository";
|
|
3675
|
+
import { Model, registerModelRepository } from "@getstrata/core/database/model";
|
|
3676
|
+
import { defineTable } from "@getstrata/core/database/table";
|
|
3677
|
+
|
|
3678
|
+
interface NoteRecord {
|
|
3679
|
+
id: number;
|
|
3680
|
+
body: string;${tenantField}
|
|
3681
|
+
created_at: Date | string;
|
|
3682
|
+
}
|
|
3683
|
+
|
|
3684
|
+
const notesTable = defineTable<NoteRecord, "id">({
|
|
3685
|
+
name: "notes",
|
|
3686
|
+
primaryKey: "id",
|
|
3687
|
+
columns: ["id", "body"${tenantColumn}, "created_at"],
|
|
3688
|
+
defaultOrderBy: { column: "id", direction: "ASC" },
|
|
3689
|
+
});
|
|
3690
|
+
|
|
3691
|
+
class NoteRepository extends BaseRepository<NoteRecord, "id"> {
|
|
3692
|
+
constructor() {
|
|
3693
|
+
super(notesTable);
|
|
3694
|
+
}
|
|
3695
|
+
}
|
|
3696
|
+
|
|
3697
|
+
class Note extends Model<NoteRecord, "id"> {
|
|
3698
|
+
static $fillable = ${fillable} as const;
|
|
3699
|
+
// created_at uses the table default. Sending a JS Date from $timestamps
|
|
3700
|
+
// is rejected by SQLite bindings.
|
|
3701
|
+
static $timestamps = false;
|
|
3702
|
+
}
|
|
3703
|
+
|
|
3704
|
+
registerModelRepository(Note, new NoteRepository());
|
|
3705
|
+
|
|
3706
|
+
export type { NoteRecord };
|
|
3707
|
+
export { Note };
|
|
3708
|
+
`;
|
|
3709
|
+
}
|
|
3710
|
+
function renderUserModel(layers) {
|
|
3711
|
+
const tenancyOn = usesTenantTable(layers.tenancy) || layers.extras.scim;
|
|
3712
|
+
const mfaOn = Boolean(layers.extras.mfa);
|
|
3713
|
+
const tenantField = tenancyOn ? `
|
|
3714
|
+
tenant_id: number;` : "";
|
|
3715
|
+
const mfaFields = mfaOn ? `
|
|
3716
|
+
mfa_secret: string | null;
|
|
3717
|
+
mfa_enabled: number | boolean;
|
|
3718
|
+
mfa_recovery_codes: string | null;` : "";
|
|
3719
|
+
const columns = ["id", "name", "email", "password", "is_admin"];
|
|
3720
|
+
if (tenancyOn) {
|
|
3721
|
+
columns.push("tenant_id");
|
|
3722
|
+
}
|
|
3723
|
+
if (mfaOn) {
|
|
3724
|
+
columns.push("mfa_secret", "mfa_enabled", "mfa_recovery_codes");
|
|
3725
|
+
}
|
|
3726
|
+
columns.push("session_valid_after", "email_verified_at", "created_at");
|
|
3727
|
+
const fillable = ["name", "email", "password", "is_admin"];
|
|
3728
|
+
if (tenancyOn) {
|
|
3729
|
+
fillable.push("tenant_id");
|
|
3730
|
+
}
|
|
3731
|
+
if (mfaOn) {
|
|
3732
|
+
fillable.push("mfa_secret", "mfa_enabled", "mfa_recovery_codes");
|
|
3733
|
+
}
|
|
3734
|
+
fillable.push("session_valid_after", "email_verified_at");
|
|
3735
|
+
const hidden = ["password"];
|
|
3736
|
+
if (mfaOn) {
|
|
3737
|
+
hidden.push("mfa_secret", "mfa_recovery_codes");
|
|
3738
|
+
}
|
|
3739
|
+
const fillableLiteral = `[${fillable.map((column) => `"${column}"`).join(", ")}]`;
|
|
3740
|
+
const hiddenLiteral = `[${hidden.map((column) => `"${column}"`).join(", ")}]`;
|
|
3741
|
+
const columnsLiteral = columns.map((column) => `"${column}"`).join(", ");
|
|
3742
|
+
return `import { BaseRepository } from "@getstrata/core/database/baseRepository";
|
|
3743
|
+
import { Model, registerModelRepository } from "@getstrata/core/database/model";
|
|
3744
|
+
import { defineTable } from "@getstrata/core/database/table";
|
|
3745
|
+
|
|
3746
|
+
interface UserRecord {
|
|
3747
|
+
id: number;
|
|
3748
|
+
name: string;
|
|
3749
|
+
email: string;
|
|
3750
|
+
password: string;
|
|
3751
|
+
is_admin: number | boolean;${tenantField}${mfaFields}
|
|
3752
|
+
session_valid_after: Date | string | null;
|
|
3753
|
+
email_verified_at: Date | string | null;
|
|
3754
|
+
created_at: Date | string;
|
|
3755
|
+
}
|
|
3756
|
+
|
|
3757
|
+
const usersTable = defineTable<UserRecord, "id">({
|
|
3758
|
+
name: "users",
|
|
3759
|
+
primaryKey: "id",
|
|
3760
|
+
columns: [${columnsLiteral}],
|
|
3761
|
+
defaultOrderBy: { column: "id", direction: "ASC" },
|
|
3762
|
+
});
|
|
3763
|
+
|
|
3764
|
+
class UserRepository extends BaseRepository<UserRecord, "id"> {
|
|
3765
|
+
constructor() {
|
|
3766
|
+
super(usersTable);
|
|
3767
|
+
}
|
|
3768
|
+
}
|
|
3769
|
+
|
|
3770
|
+
class User extends Model<UserRecord, "id"> {
|
|
3771
|
+
static $fillable = ${fillableLiteral} as const;
|
|
3772
|
+
static $hidden = ${hiddenLiteral} as const;
|
|
3773
|
+
// created_at uses the table default. Sending a JS Date from $timestamps
|
|
3774
|
+
// is rejected by SQLite bindings.
|
|
3775
|
+
static $timestamps = false;
|
|
3776
|
+
}
|
|
3777
|
+
|
|
3778
|
+
registerModelRepository(User, new UserRepository());
|
|
3779
|
+
|
|
3780
|
+
export type { UserRecord };
|
|
3781
|
+
export { User };
|
|
3782
|
+
`;
|
|
3783
|
+
}
|
|
3784
|
+
function renderApiTokenModel() {
|
|
3785
|
+
return `import { BaseRepository } from "@getstrata/core/database/baseRepository";
|
|
3786
|
+
import { Model, registerModelRepository } from "@getstrata/core/database/model";
|
|
3787
|
+
import { defineTable } from "@getstrata/core/database/table";
|
|
3788
|
+
|
|
3789
|
+
interface ApiTokenRecord {
|
|
3790
|
+
id: number;
|
|
3791
|
+
user_id: number;
|
|
3792
|
+
name: string;
|
|
3793
|
+
token_hash: string;
|
|
3794
|
+
abilities: string;
|
|
3795
|
+
expires_at: Date | string | null;
|
|
3796
|
+
last_used_at: Date | string | null;
|
|
3797
|
+
created_at: Date | string;
|
|
3798
|
+
}
|
|
3799
|
+
|
|
3800
|
+
const apiTokensTable = defineTable<ApiTokenRecord, "id">({
|
|
3801
|
+
name: "api_tokens",
|
|
3802
|
+
primaryKey: "id",
|
|
3803
|
+
columns: ["id", "user_id", "name", "token_hash", "abilities", "expires_at", "last_used_at", "created_at"],
|
|
3804
|
+
defaultOrderBy: { column: "id", direction: "ASC" },
|
|
3805
|
+
});
|
|
3806
|
+
|
|
3807
|
+
class ApiTokenRepository extends BaseRepository<ApiTokenRecord, "id"> {
|
|
3808
|
+
constructor() {
|
|
3809
|
+
super(apiTokensTable);
|
|
3810
|
+
}
|
|
3811
|
+
}
|
|
3812
|
+
|
|
3813
|
+
class ApiToken extends Model<ApiTokenRecord, "id"> {
|
|
3814
|
+
static $fillable = ["user_id", "name", "token_hash", "abilities", "expires_at", "last_used_at"] as const;
|
|
3815
|
+
// created_at uses the table default. Sending a JS Date from $timestamps
|
|
3816
|
+
// is rejected by SQLite bindings.
|
|
3817
|
+
static $timestamps = false;
|
|
3818
|
+
}
|
|
3819
|
+
|
|
3820
|
+
registerModelRepository(ApiToken, new ApiTokenRepository());
|
|
3821
|
+
|
|
3822
|
+
export type { ApiTokenRecord };
|
|
3823
|
+
export { ApiToken };
|
|
3824
|
+
`;
|
|
3825
|
+
}
|
|
3826
|
+
function renderAuthOneTimeTokenModel() {
|
|
3827
|
+
return `import { BaseRepository } from "@getstrata/core/database/baseRepository";
|
|
3828
|
+
import { Model, registerModelRepository } from "@getstrata/core/database/model";
|
|
3829
|
+
import { defineTable } from "@getstrata/core/database/table";
|
|
3830
|
+
|
|
3831
|
+
interface AuthOneTimeTokenRecord {
|
|
3832
|
+
id: number;
|
|
3833
|
+
purpose: string;
|
|
3834
|
+
user_id: number;
|
|
3835
|
+
token_hash: string;
|
|
3836
|
+
expires_at: Date | string;
|
|
3837
|
+
consumed_at: Date | string | null;
|
|
3838
|
+
}
|
|
3839
|
+
|
|
3840
|
+
const authOneTimeTokensTable = defineTable<AuthOneTimeTokenRecord, "id">({
|
|
3841
|
+
name: "auth_one_time_tokens",
|
|
3842
|
+
primaryKey: "id",
|
|
3843
|
+
columns: ["id", "purpose", "user_id", "token_hash", "expires_at", "consumed_at"],
|
|
3844
|
+
defaultOrderBy: { column: "id", direction: "ASC" },
|
|
3845
|
+
});
|
|
3846
|
+
|
|
3847
|
+
class AuthOneTimeTokenRepository extends BaseRepository<AuthOneTimeTokenRecord, "id"> {
|
|
3848
|
+
constructor() {
|
|
3849
|
+
super(authOneTimeTokensTable);
|
|
3850
|
+
}
|
|
3851
|
+
}
|
|
3852
|
+
|
|
3853
|
+
class AuthOneTimeToken extends Model<AuthOneTimeTokenRecord, "id"> {
|
|
3854
|
+
static $fillable = ["purpose", "user_id", "token_hash", "expires_at", "consumed_at"] as const;
|
|
3855
|
+
static $timestamps = false;
|
|
3856
|
+
}
|
|
3857
|
+
|
|
3858
|
+
registerModelRepository(AuthOneTimeToken, new AuthOneTimeTokenRepository());
|
|
3859
|
+
|
|
3860
|
+
export type { AuthOneTimeTokenRecord };
|
|
3861
|
+
export { AuthOneTimeToken };
|
|
3862
|
+
`;
|
|
3863
|
+
}
|
|
3163
3864
|
function dropTables(layers) {
|
|
3164
3865
|
const ordered = [];
|
|
3165
3866
|
if (authUsesToken(layers.auth)) {
|
|
@@ -3167,8 +3868,10 @@ function dropTables(layers) {
|
|
|
3167
3868
|
}
|
|
3168
3869
|
if (authUsesCookie(layers.auth)) {
|
|
3169
3870
|
ordered.push("sessions");
|
|
3871
|
+
ordered.push("auth_saml_assertions");
|
|
3170
3872
|
}
|
|
3171
3873
|
if (authNeedsUsers(layers.auth)) {
|
|
3874
|
+
ordered.push("auth_one_time_tokens");
|
|
3172
3875
|
ordered.push("users");
|
|
3173
3876
|
}
|
|
3174
3877
|
ordered.push("notes");
|
|
@@ -3177,20 +3880,31 @@ function dropTables(layers) {
|
|
|
3177
3880
|
}
|
|
3178
3881
|
return ordered;
|
|
3179
3882
|
}
|
|
3883
|
+
function renderPostgresAdminDropCall() {
|
|
3884
|
+
return ` await dropPostgresTablesAsAdmin(tables, {
|
|
3885
|
+
runtimeUrl: process.env.DATABASE_URL ?? "",
|
|
3886
|
+
migrationUrl: process.env.MIGRATION_DATABASE_URL,
|
|
3887
|
+
});
|
|
3888
|
+
`;
|
|
3889
|
+
}
|
|
3180
3890
|
function renderFreshTs(layers) {
|
|
3181
3891
|
const tables = dropTables(layers);
|
|
3182
3892
|
const cascade = layers.database === "sqlite" ? "" : " CASCADE";
|
|
3183
|
-
|
|
3184
|
-
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}
|
|
3185
3903
|
|
|
3186
3904
|
const tables = ${JSON.stringify(tables)};
|
|
3187
3905
|
|
|
3188
3906
|
export async function fresh() {
|
|
3189
|
-
${ensureCall(layers)}
|
|
3190
|
-
for (const table of tables) {
|
|
3191
|
-
await sql.unsafe(\`DROP TABLE IF EXISTS \${table}${cascade}\`);
|
|
3192
|
-
}
|
|
3193
|
-
await migrate();
|
|
3907
|
+
${ensureCall(layers)}${dropBody}
|
|
3194
3908
|
}
|
|
3195
3909
|
|
|
3196
3910
|
/** The CLI calls this after fresh() so pooled drivers do not hold the process open. */
|
|
@@ -3248,16 +3962,21 @@ if (import.meta.main) {
|
|
|
3248
3962
|
function renderRollbackTs(layers) {
|
|
3249
3963
|
const tables = dropTables(layers);
|
|
3250
3964
|
const cascade = layers.database === "sqlite" ? "" : " CASCADE";
|
|
3251
|
-
|
|
3965
|
+
const imports = layers.database === "postgres" ? `import { dropPostgresTablesAsAdmin } from "@getstrata/core/tenant/enableTenantRls";
|
|
3966
|
+
${ensureImport(layers)}` : `${ensureImport(layers)}import { getSql } from "../bootstrap/database.ts";`;
|
|
3967
|
+
const dropBody = layers.database === "postgres" ? `${renderPostgresAdminDropCall()} for (const table of tables) {
|
|
3968
|
+
console.log(\`dropped \${table}\`);
|
|
3969
|
+
}` : ` const sql = getSql();
|
|
3970
|
+
for (const table of tables) {
|
|
3971
|
+
await sql.unsafe(\`DROP TABLE IF EXISTS \${table}${cascade}\`);
|
|
3972
|
+
console.log(\`dropped \${table}\`);
|
|
3973
|
+
}`;
|
|
3974
|
+
return `${imports}
|
|
3252
3975
|
|
|
3253
3976
|
const tables = ${JSON.stringify(tables)};
|
|
3254
3977
|
|
|
3255
3978
|
export async function rollback() {
|
|
3256
|
-
${ensureCall(layers)}
|
|
3257
|
-
for (const table of tables) {
|
|
3258
|
-
await sql.unsafe(\`DROP TABLE IF EXISTS \${table}${cascade}\`);
|
|
3259
|
-
console.log(\`dropped \${table}\`);
|
|
3260
|
-
}
|
|
3979
|
+
${ensureCall(layers)}${dropBody}
|
|
3261
3980
|
}
|
|
3262
3981
|
|
|
3263
3982
|
if (import.meta.main) {
|
|
@@ -3268,13 +3987,15 @@ if (import.meta.main) {
|
|
|
3268
3987
|
`;
|
|
3269
3988
|
}
|
|
3270
3989
|
function renderPreloadTs(layers, projectName) {
|
|
3271
|
-
const
|
|
3272
|
-
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
|
+
` : "";
|
|
3273
3994
|
return `import { join } from "node:path";
|
|
3274
3995
|
import { configureModulesDirectory } from "@getstrata/bootstrap/discoverModules";
|
|
3275
3996
|
|
|
3276
3997
|
process.env.DATABASE_URL ??= ${JSON.stringify(fallback)};
|
|
3277
|
-
process.env.FRONTEND_MODE ??= ${JSON.stringify(layers.frontend)};
|
|
3998
|
+
${migrationLine}process.env.FRONTEND_MODE ??= ${JSON.stringify(layers.frontend)};
|
|
3278
3999
|
process.env.SPA_PREFIX ??= ${JSON.stringify(layers.spaPrefix)};
|
|
3279
4000
|
process.env.CACHE_DRIVER ??= ${JSON.stringify(layers.cache)};
|
|
3280
4001
|
process.env.QUEUE_DRIVER ??= ${JSON.stringify(layers.queue)};
|
|
@@ -3371,11 +4092,10 @@ function renderEnsureDatabaseTs(layers, projectName) {
|
|
|
3371
4092
|
if (layers.database === "sqlite") {
|
|
3372
4093
|
return null;
|
|
3373
4094
|
}
|
|
3374
|
-
const
|
|
3375
|
-
const fallback = layers.database === "mysql" ? `mysql://root:root@localhost:3306/${database}` : `postgresql://postgres:postgres@localhost:5432/${database}`;
|
|
4095
|
+
const fallback = defaultDatabaseUrl(layers, projectName);
|
|
3376
4096
|
const resolveUrl = `/**
|
|
3377
|
-
*
|
|
3378
|
-
*
|
|
4097
|
+
* Runtime URL. Superuser CREATE ROLE / CREATE DATABASE / GRANT / migrate uses
|
|
4098
|
+
* MIGRATION_DATABASE_URL when set.
|
|
3379
4099
|
*/
|
|
3380
4100
|
function resolveAppDatabaseUrl(): string {
|
|
3381
4101
|
const explicit = process.env.APP_DATABASE_URL?.trim();
|
|
@@ -3384,8 +4104,8 @@ function resolveAppDatabaseUrl(): string {
|
|
|
3384
4104
|
}
|
|
3385
4105
|
return process.env.DATABASE_URL?.trim() || DEFAULT_DATABASE_URL;
|
|
3386
4106
|
}
|
|
3387
|
-
|
|
3388
|
-
|
|
4107
|
+
`;
|
|
4108
|
+
const safeName = `/** Reject anything we would have to quote before interpolating into DDL. */
|
|
3389
4109
|
function safeDatabaseName(url: string): string {
|
|
3390
4110
|
let name = "";
|
|
3391
4111
|
try {
|
|
@@ -3407,7 +4127,7 @@ function safeDatabaseName(url: string): string {
|
|
|
3407
4127
|
|
|
3408
4128
|
const DEFAULT_DATABASE_URL = ${JSON.stringify(fallback)};
|
|
3409
4129
|
|
|
3410
|
-
${resolveUrl}
|
|
4130
|
+
${resolveUrl}${safeName}
|
|
3411
4131
|
export async function ensureAppDatabase(): Promise<string> {
|
|
3412
4132
|
const url = resolveAppDatabaseUrl();
|
|
3413
4133
|
const name = safeDatabaseName(url);
|
|
@@ -3426,58 +4146,21 @@ export async function ensureAppDatabase(): Promise<string> {
|
|
|
3426
4146
|
}
|
|
3427
4147
|
`;
|
|
3428
4148
|
}
|
|
3429
|
-
return `
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
const names = ["postgres", "template1"];
|
|
3434
|
-
try {
|
|
3435
|
-
const current = decodeURIComponent(new URL(url).pathname.replace(/^\\//, ""));
|
|
3436
|
-
if (current && !names.includes(current)) {
|
|
3437
|
-
names.push(current);
|
|
3438
|
-
}
|
|
3439
|
-
} catch {
|
|
3440
|
-
}
|
|
3441
|
-
return names.map((name) => {
|
|
3442
|
-
const admin = new URL(url);
|
|
3443
|
-
admin.pathname = \`/\${name}\`;
|
|
3444
|
-
return admin.toString();
|
|
3445
|
-
});
|
|
3446
|
-
}
|
|
4149
|
+
return `import {
|
|
4150
|
+
ensurePostgresDatabaseAndAppRole,
|
|
4151
|
+
POSTGRES_APP_ROLE_PASSWORD,
|
|
4152
|
+
} from "@getstrata/core/tenant/enableTenantRls";
|
|
3447
4153
|
|
|
3448
|
-
|
|
3449
|
-
let lastError: unknown;
|
|
3450
|
-
for (const candidate of adminCandidateUrls(url)) {
|
|
3451
|
-
const adminSql = new Bun.SQL(candidate);
|
|
3452
|
-
try {
|
|
3453
|
-
await adminSql\`SELECT 1\`;
|
|
3454
|
-
return adminSql;
|
|
3455
|
-
} catch (error) {
|
|
3456
|
-
lastError = error;
|
|
3457
|
-
await adminSql.close().catch(() => undefined);
|
|
3458
|
-
}
|
|
3459
|
-
}
|
|
3460
|
-
throw lastError instanceof Error
|
|
3461
|
-
? lastError
|
|
3462
|
-
: new Error("Could not open an admin connection to create the app database.");
|
|
3463
|
-
}
|
|
4154
|
+
const DEFAULT_DATABASE_URL = ${JSON.stringify(fallback)};
|
|
3464
4155
|
|
|
4156
|
+
${resolveUrl}
|
|
3465
4157
|
export async function ensureAppDatabase(): Promise<string> {
|
|
3466
4158
|
const url = resolveAppDatabaseUrl();
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
SELECT 1 AS ok FROM pg_database WHERE datname = \${name}
|
|
3473
|
-
\`;
|
|
3474
|
-
if (rows.length === 0) {
|
|
3475
|
-
await adminSql.unsafe(\`CREATE DATABASE \${name}\`);
|
|
3476
|
-
}
|
|
3477
|
-
} finally {
|
|
3478
|
-
await adminSql.close();
|
|
3479
|
-
}
|
|
3480
|
-
|
|
4159
|
+
await ensurePostgresDatabaseAndAppRole({
|
|
4160
|
+
runtimeUrl: url,
|
|
4161
|
+
migrationUrl: process.env.MIGRATION_DATABASE_URL,
|
|
4162
|
+
password: process.env.STRATA_APP_PASSWORD ?? POSTGRES_APP_ROLE_PASSWORD,
|
|
4163
|
+
});
|
|
3481
4164
|
process.env.DATABASE_URL = url;
|
|
3482
4165
|
return url;
|
|
3483
4166
|
}
|
|
@@ -3486,11 +4169,27 @@ export async function ensureAppDatabase(): Promise<string> {
|
|
|
3486
4169
|
function renderSidecarsTs(_layers) {
|
|
3487
4170
|
return null;
|
|
3488
4171
|
}
|
|
4172
|
+
function renderPolicyProvider() {
|
|
4173
|
+
return `import { CORE_POLICY_GATE_TOKEN } from "@getstrata/bootstrap/config";
|
|
4174
|
+
import { PolicyGate } from "@getstrata/core/auth/policy";
|
|
4175
|
+
import type { ServiceProvider } from "@getstrata/core/contracts/di";
|
|
4176
|
+
|
|
4177
|
+
const policyProvider: ServiceProvider = {
|
|
4178
|
+
name: "core.policy",
|
|
4179
|
+
register({ container }) {
|
|
4180
|
+
container.set(CORE_POLICY_GATE_TOKEN, new PolicyGate());
|
|
4181
|
+
},
|
|
4182
|
+
};
|
|
4183
|
+
|
|
4184
|
+
export default policyProvider;
|
|
4185
|
+
`;
|
|
4186
|
+
}
|
|
3489
4187
|
function renderProvidersIndex() {
|
|
3490
4188
|
return `import type { ServiceProvider } from "@getstrata/core/contracts/di";
|
|
3491
4189
|
import authProvider from "./auth.ts";
|
|
3492
4190
|
import cacheProvider from "./cache.ts";
|
|
3493
4191
|
import configProvider from "./config.ts";
|
|
4192
|
+
import policyProvider from "./policy.ts";
|
|
3494
4193
|
import queueProvider from "./queue.ts";
|
|
3495
4194
|
import storageProvider from "./storage.ts";
|
|
3496
4195
|
|
|
@@ -3500,6 +4199,7 @@ const starterProviders: ServiceProvider[] = [
|
|
|
3500
4199
|
storageProvider,
|
|
3501
4200
|
queueProvider,
|
|
3502
4201
|
authProvider,
|
|
4202
|
+
policyProvider,
|
|
3503
4203
|
];
|
|
3504
4204
|
|
|
3505
4205
|
export { starterProviders };
|
|
@@ -3531,10 +4231,11 @@ import {
|
|
|
3531
4231
|
ensureModulesLoaded,
|
|
3532
4232
|
} from "@getstrata/bootstrap/discoverModules";
|
|
3533
4233
|
import { createHealthRoutes } from "@getstrata/bootstrap/health";
|
|
3534
|
-
${metricsImport}import { assertProductionSecrets } from "@getstrata/bootstrap/secretsGuard";
|
|
4234
|
+
${metricsImport}import { assertProductionSecrets, assertRlsLiveDatabaseRole } from "@getstrata/bootstrap/secretsGuard";
|
|
3535
4235
|
import { createWebServer } from "@getstrata/bootstrap/web/server";
|
|
3536
4236
|
import { setActiveApplicationContext } from "@getstrata/core/runtime/applicationRegistry";
|
|
3537
4237
|
import { isProductionEnv } from "@getstrata/core/runtime/appEnv";
|
|
4238
|
+
import { isRlsTenancy } from "@getstrata/core/tenant/tenancyConfig";
|
|
3538
4239
|
import { migrate } from "../db/migrate.ts";
|
|
3539
4240
|
import { buildRoutes } from "../routes.ts";
|
|
3540
4241
|
import { loadConfig } from "./config.ts";
|
|
@@ -3605,6 +4306,9 @@ export async function bootstrapApp(options: BootstrapOptions = {}): Promise<Boot
|
|
|
3605
4306
|
${needsEnsure(layers) ? ` await ensureAppDatabase();
|
|
3606
4307
|
` : ""} const appConfig = loadConfig();
|
|
3607
4308
|
getSql();
|
|
4309
|
+
if (isRlsTenancy()) {
|
|
4310
|
+
await assertRlsLiveDatabaseRole();
|
|
4311
|
+
}
|
|
3608
4312
|
configureModulesDirectory(join(import.meta.dir, "../modules"));
|
|
3609
4313
|
await ensureModulesLoaded();
|
|
3610
4314
|
const context = createAppContext();
|
|
@@ -3705,36 +4409,22 @@ export function plainText(body: string, status = 200): Response {
|
|
|
3705
4409
|
}
|
|
3706
4410
|
|
|
3707
4411
|
// src/renderScim.ts
|
|
3708
|
-
function ph2(layers, count, start = 1) {
|
|
3709
|
-
if (layers.database === "postgres") {
|
|
3710
|
-
return Array.from({ length: count }, (_, index) => `$${start + index}`).join(", ");
|
|
3711
|
-
}
|
|
3712
|
-
return Array.from({ length: count }, () => "?").join(", ");
|
|
3713
|
-
}
|
|
3714
|
-
function sqlFalse2(layers) {
|
|
3715
|
-
return layers.database === "postgres" ? "false" : "0";
|
|
3716
|
-
}
|
|
3717
4412
|
function renderScimModule(layers) {
|
|
3718
4413
|
if (!layers.extras.scim || !authNeedsUsers(layers.auth)) {
|
|
3719
4414
|
return null;
|
|
3720
4415
|
}
|
|
3721
|
-
const tenantOn = usesTenantTable(layers.tenancy);
|
|
3722
|
-
const insertCols = tenantOn ? "name, email, password, is_admin, tenant_id" : "name, email, password, is_admin";
|
|
3723
|
-
const insertPh = tenantOn ? ph2(layers, 5) : ph2(layers, 4);
|
|
3724
|
-
const insertTail = tenantOn ? `, ${sqlFalse2(layers)}, tenantId` : `, ${sqlFalse2(layers)}`;
|
|
3725
|
-
const emailPh = ph2(layers, 1);
|
|
3726
|
-
const idPh = ph2(layers, 1);
|
|
3727
|
-
const updatePh = `${ph2(layers, 1)}, ${ph2(layers, 1, 2)}, ${ph2(layers, 1, 3)}`;
|
|
3728
4416
|
return `import { randomBytes } from "node:crypto";
|
|
3729
4417
|
import type { AppModule } from "@getstrata/bootstrap/contracts";
|
|
3730
4418
|
import { routeParams } from "@getstrata/bootstrap/web/routing";
|
|
3731
4419
|
import { createScimAuthMiddleware } from "@getstrata/core/auth/scimAuthMiddleware";
|
|
3732
4420
|
import { hashPassword } from "@getstrata/core/auth/password";
|
|
4421
|
+
import { BadRequestError } from "@getstrata/core/errors/http";
|
|
4422
|
+
import { parsePositiveIntParam } from "@getstrata/core/http/validation";
|
|
3733
4423
|
import { withErrorHandling } from "@getstrata/core/http/response";
|
|
3734
4424
|
import { withMiddleware } from "@getstrata/core/http/routeMiddleware";
|
|
3735
4425
|
import { createScimThrottleMiddleware } from "@getstrata/core/http/scimThrottleMiddleware";
|
|
3736
4426
|
import { currentTenant } from "@getstrata/core/tenant/tenantContext";
|
|
3737
|
-
import {
|
|
4427
|
+
import { User } from "../../models/User.ts";
|
|
3738
4428
|
|
|
3739
4429
|
const USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User";
|
|
3740
4430
|
const LIST_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
|
|
@@ -3742,12 +4432,29 @@ const ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error";
|
|
|
3742
4432
|
const PATCH_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
|
|
3743
4433
|
const CONFIG_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig";
|
|
3744
4434
|
|
|
3745
|
-
type UserRow = { id: number; name: string; email: string };
|
|
3746
|
-
|
|
3747
4435
|
function scimEnabled(): boolean {
|
|
3748
4436
|
return (process.env.FEATURE_SCIM ?? "false") === "true";
|
|
3749
4437
|
}
|
|
3750
4438
|
|
|
4439
|
+
function tenantId(): number {
|
|
4440
|
+
const tenant = currentTenant();
|
|
4441
|
+
if (!tenant) {
|
|
4442
|
+
throw new Error("SCIM requires a tenant context.");
|
|
4443
|
+
}
|
|
4444
|
+
return tenant.id;
|
|
4445
|
+
}
|
|
4446
|
+
|
|
4447
|
+
function readScimUserId(request: Request): number | Response {
|
|
4448
|
+
try {
|
|
4449
|
+
return parsePositiveIntParam(routeParams(request).id ?? "", "id");
|
|
4450
|
+
} catch (error) {
|
|
4451
|
+
if (error instanceof BadRequestError) {
|
|
4452
|
+
return scimError("Invalid id.", 400);
|
|
4453
|
+
}
|
|
4454
|
+
throw error;
|
|
4455
|
+
}
|
|
4456
|
+
}
|
|
4457
|
+
|
|
3751
4458
|
function scimJson(body: unknown, status = 200): Response {
|
|
3752
4459
|
return Response.json(body, {
|
|
3753
4460
|
status,
|
|
@@ -3759,13 +4466,15 @@ function scimError(detail: string, status: number): Response {
|
|
|
3759
4466
|
return scimJson({ schemas: [ERROR_SCHEMA], detail, status: String(status) }, status);
|
|
3760
4467
|
}
|
|
3761
4468
|
|
|
3762
|
-
function toScimUser(
|
|
4469
|
+
function toScimUser(user: { get(key: "id" | "name" | "email"): unknown }) {
|
|
4470
|
+
const email = String(user.get("email") ?? "");
|
|
4471
|
+
const name = String(user.get("name") ?? "");
|
|
3763
4472
|
return {
|
|
3764
4473
|
schemas: [USER_SCHEMA],
|
|
3765
|
-
id: String(
|
|
3766
|
-
userName:
|
|
3767
|
-
name: { formatted:
|
|
3768
|
-
emails: [{ value:
|
|
4474
|
+
id: String(user.get("id")),
|
|
4475
|
+
userName: email,
|
|
4476
|
+
name: { formatted: name },
|
|
4477
|
+
emails: [{ value: email, primary: true }],
|
|
3769
4478
|
active: true,
|
|
3770
4479
|
meta: { resourceType: "User" },
|
|
3771
4480
|
};
|
|
@@ -3855,24 +4564,29 @@ const scimModule: AppModule = {
|
|
|
3855
4564
|
const url = new URL(request.url);
|
|
3856
4565
|
const filter = url.searchParams.get("filter") ?? "";
|
|
3857
4566
|
const match = /userName\\s+eq\\s+"([^"]+)"/i.exec(filter);
|
|
3858
|
-
|
|
4567
|
+
const startIndex = Math.max(1, Number.parseInt(url.searchParams.get("startIndex") ?? "1", 10) || 1);
|
|
4568
|
+
const count = Math.min(200, Math.max(1, Number.parseInt(url.searchParams.get("count") ?? "200", 10) || 200));
|
|
3859
4569
|
if (match?.[1]) {
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
);
|
|
3864
|
-
|
|
3865
|
-
|
|
4570
|
+
const email = match[1].trim().toLowerCase();
|
|
4571
|
+
const user = await User.where({ email, tenant_id: tenantId() }).first();
|
|
4572
|
+
const users = user ? [user] : [];
|
|
4573
|
+
const slice = users.slice(startIndex - 1, startIndex - 1 + count);
|
|
4574
|
+
return scimJson({
|
|
4575
|
+
schemas: [LIST_SCHEMA],
|
|
4576
|
+
totalResults: users.length,
|
|
4577
|
+
startIndex,
|
|
4578
|
+
itemsPerPage: slice.length,
|
|
4579
|
+
Resources: slice.map(toScimUser),
|
|
4580
|
+
});
|
|
3866
4581
|
}
|
|
3867
|
-
const
|
|
3868
|
-
const
|
|
3869
|
-
const slice = rows.slice(startIndex - 1, startIndex - 1 + count);
|
|
4582
|
+
const totalResults = await User.where({ tenant_id: tenantId() }).count();
|
|
4583
|
+
const page = await User.where({ tenant_id: tenantId() }).offset(startIndex - 1).limit(count).get();
|
|
3870
4584
|
return scimJson({
|
|
3871
4585
|
schemas: [LIST_SCHEMA],
|
|
3872
|
-
totalResults
|
|
4586
|
+
totalResults,
|
|
3873
4587
|
startIndex,
|
|
3874
|
-
itemsPerPage:
|
|
3875
|
-
Resources:
|
|
4588
|
+
itemsPerPage: page.length,
|
|
4589
|
+
Resources: page.map(toScimUser),
|
|
3876
4590
|
});
|
|
3877
4591
|
}),
|
|
3878
4592
|
),
|
|
@@ -3885,28 +4599,16 @@ const scimModule: AppModule = {
|
|
|
3885
4599
|
if (!email) {
|
|
3886
4600
|
return scimError("userName is required.", 400);
|
|
3887
4601
|
}
|
|
3888
|
-
const existing = await
|
|
3889
|
-
|
|
3890
|
-
[email],
|
|
3891
|
-
);
|
|
3892
|
-
if (existing[0]) {
|
|
4602
|
+
const existing = await User.where({ email, tenant_id: tenantId() }).first();
|
|
4603
|
+
if (existing) {
|
|
3893
4604
|
return scimError("User already exists.", 409);
|
|
3894
4605
|
}
|
|
3895
4606
|
const hashed = await hashPassword(randomBytes(18).toString("hex"));
|
|
3896
|
-
const
|
|
3897
|
-
|
|
3898
|
-
"INSERT INTO users (${insertCols}) VALUES (${insertPh})",
|
|
3899
|
-
[name, email, hashed${insertTail}],
|
|
3900
|
-
);
|
|
3901
|
-
const created = await getSql().unsafe<UserRow>(
|
|
3902
|
-
"SELECT id, name, email FROM users WHERE email = ${emailPh}",
|
|
3903
|
-
[email],
|
|
3904
|
-
);
|
|
3905
|
-
const row = created[0];
|
|
3906
|
-
if (!row) {
|
|
4607
|
+
const created = await User.create({ name, email, password: hashed, is_admin: false, tenant_id: tenantId() });
|
|
4608
|
+
if (!created) {
|
|
3907
4609
|
return scimError("Could not create user.", 500);
|
|
3908
4610
|
}
|
|
3909
|
-
return scimJson(toScimUser(
|
|
4611
|
+
return scimJson(toScimUser(created), 201);
|
|
3910
4612
|
}),
|
|
3911
4613
|
),
|
|
3912
4614
|
},
|
|
@@ -3914,61 +4616,55 @@ const scimModule: AppModule = {
|
|
|
3914
4616
|
GET: kernel.wrap(
|
|
3915
4617
|
"api",
|
|
3916
4618
|
wrapScim(async (request) => {
|
|
3917
|
-
const id =
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
);
|
|
3922
|
-
|
|
3923
|
-
if (!row) {
|
|
4619
|
+
const id = readScimUserId(request);
|
|
4620
|
+
if (id instanceof Response) {
|
|
4621
|
+
return id;
|
|
4622
|
+
}
|
|
4623
|
+
const user = await User.where({ id, tenant_id: tenantId() }).first();
|
|
4624
|
+
if (!user) {
|
|
3924
4625
|
return scimError("User not found.", 404);
|
|
3925
4626
|
}
|
|
3926
|
-
return scimJson(toScimUser(
|
|
4627
|
+
return scimJson(toScimUser(user));
|
|
3927
4628
|
}),
|
|
3928
4629
|
),
|
|
3929
4630
|
PUT: kernel.wrap(
|
|
3930
4631
|
"api",
|
|
3931
4632
|
wrapScim(async (request) => {
|
|
3932
|
-
const id =
|
|
4633
|
+
const id = readScimUserId(request);
|
|
4634
|
+
if (id instanceof Response) {
|
|
4635
|
+
return id;
|
|
4636
|
+
}
|
|
3933
4637
|
const body = (await request.json()) as Record<string, unknown>;
|
|
3934
4638
|
const email = readUserName(body);
|
|
3935
4639
|
const name = readName(body, email);
|
|
3936
4640
|
if (!email || !name) {
|
|
3937
4641
|
return scimError("userName and name are required.", 400);
|
|
3938
4642
|
}
|
|
3939
|
-
await
|
|
3940
|
-
|
|
3941
|
-
[name, email, id],
|
|
3942
|
-
);
|
|
3943
|
-
const rows = await getSql().unsafe<UserRow>(
|
|
3944
|
-
"SELECT id, name, email FROM users WHERE id = ${idPh}",
|
|
3945
|
-
[id],
|
|
3946
|
-
);
|
|
3947
|
-
const row = rows[0];
|
|
3948
|
-
if (!row) {
|
|
4643
|
+
const user = await User.where({ id, tenant_id: tenantId() }).first();
|
|
4644
|
+
if (!user) {
|
|
3949
4645
|
return scimError("User not found.", 404);
|
|
3950
4646
|
}
|
|
3951
|
-
|
|
4647
|
+
await user.update({ name, email });
|
|
4648
|
+
return scimJson(toScimUser(user));
|
|
3952
4649
|
}),
|
|
3953
4650
|
),
|
|
3954
4651
|
PATCH: kernel.wrap(
|
|
3955
4652
|
"api",
|
|
3956
4653
|
wrapScim(async (request) => {
|
|
3957
|
-
const id =
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
);
|
|
3962
|
-
|
|
3963
|
-
if (!row) {
|
|
4654
|
+
const id = readScimUserId(request);
|
|
4655
|
+
if (id instanceof Response) {
|
|
4656
|
+
return id;
|
|
4657
|
+
}
|
|
4658
|
+
const user = await User.where({ id, tenant_id: tenantId() }).first();
|
|
4659
|
+
if (!user) {
|
|
3964
4660
|
return scimError("User not found.", 404);
|
|
3965
4661
|
}
|
|
3966
4662
|
const body = (await request.json()) as { schemas?: string[]; Operations?: Array<{ op?: string; path?: string; value?: unknown }> };
|
|
3967
4663
|
if (body.schemas && !body.schemas.includes(PATCH_SCHEMA)) {
|
|
3968
4664
|
return scimError("Unsupported patch schema.", 400);
|
|
3969
4665
|
}
|
|
3970
|
-
let name =
|
|
3971
|
-
let email =
|
|
4666
|
+
let name = String(user.get("name") ?? "");
|
|
4667
|
+
let email = String(user.get("email") ?? "");
|
|
3972
4668
|
for (const operation of body.Operations ?? []) {
|
|
3973
4669
|
const op = (operation.op ?? "replace").toLowerCase();
|
|
3974
4670
|
if (op !== "replace" && op !== "add") {
|
|
@@ -3989,18 +4685,21 @@ const scimModule: AppModule = {
|
|
|
3989
4685
|
name = readName(value, name);
|
|
3990
4686
|
}
|
|
3991
4687
|
}
|
|
3992
|
-
await
|
|
3993
|
-
|
|
3994
|
-
[name, email, id],
|
|
3995
|
-
);
|
|
3996
|
-
return scimJson(toScimUser({ id: row.id, name, email }));
|
|
4688
|
+
await user.update({ name, email });
|
|
4689
|
+
return scimJson(toScimUser(user));
|
|
3997
4690
|
}),
|
|
3998
4691
|
),
|
|
3999
4692
|
DELETE: kernel.wrap(
|
|
4000
4693
|
"api",
|
|
4001
4694
|
wrapScim(async (request) => {
|
|
4002
|
-
const id =
|
|
4003
|
-
|
|
4695
|
+
const id = readScimUserId(request);
|
|
4696
|
+
if (id instanceof Response) {
|
|
4697
|
+
return id;
|
|
4698
|
+
}
|
|
4699
|
+
const user = await User.where({ id, tenant_id: tenantId() }).first();
|
|
4700
|
+
if (user) {
|
|
4701
|
+
await user.delete();
|
|
4702
|
+
}
|
|
4004
4703
|
return new Response(null, { status: 204 });
|
|
4005
4704
|
}),
|
|
4006
4705
|
),
|
|
@@ -4064,7 +4763,10 @@ function writeGeneratedFiles(options) {
|
|
|
4064
4763
|
writeText(join2(targetDir, ".env.example"), renderEnvExample(projectName, layers));
|
|
4065
4764
|
writeText(join2(targetDir, ".gitignore"), renderGitignore());
|
|
4066
4765
|
writeText(join2(targetDir, "package.json"), renderPackageJson(projectName, { ...options, layers }));
|
|
4067
|
-
writeText(join2(targetDir, "README.md"), renderReadme(projectName, layers
|
|
4766
|
+
writeText(join2(targetDir, "README.md"), renderReadme(projectName, layers, {
|
|
4767
|
+
inRepoExample: options.inRepoExample,
|
|
4768
|
+
dogfood: options.dogfood
|
|
4769
|
+
}));
|
|
4068
4770
|
if (layers.frontend === "api") {
|
|
4069
4771
|
writeText(join2(targetDir, "docs/API.md"), renderApiDocs(projectName, layers));
|
|
4070
4772
|
} else {
|
|
@@ -4079,6 +4781,18 @@ function writeGeneratedFiles(options) {
|
|
|
4079
4781
|
} else {
|
|
4080
4782
|
removeIfExists(join2(targetDir, "docker-compose.yml"));
|
|
4081
4783
|
}
|
|
4784
|
+
const postgresInit = renderPostgresAppRoleInitSql(projectName, layers);
|
|
4785
|
+
if (postgresInit) {
|
|
4786
|
+
writeText(join2(targetDir, "db/ensure-postgres-app-role.sql"), postgresInit);
|
|
4787
|
+
if (usesComposePostgres(layers)) {
|
|
4788
|
+
writeText(join2(targetDir, "docker/postgres-init/01-strata-app-role.sql"), postgresInit);
|
|
4789
|
+
} else {
|
|
4790
|
+
removeIfExists(join2(targetDir, "docker/postgres-init"));
|
|
4791
|
+
}
|
|
4792
|
+
} else {
|
|
4793
|
+
removeIfExists(join2(targetDir, "db/ensure-postgres-app-role.sql"));
|
|
4794
|
+
removeIfExists(join2(targetDir, "docker/postgres-init"));
|
|
4795
|
+
}
|
|
4082
4796
|
writeText(join2(src, "routes.ts"), renderRoutesTs());
|
|
4083
4797
|
writeText(join2(src, "lib/view.ts"), renderViewTs(layers));
|
|
4084
4798
|
writeText(join2(src, "bootstrap/config.ts"), renderConfigTs());
|
|
@@ -4093,8 +4807,22 @@ function writeGeneratedFiles(options) {
|
|
|
4093
4807
|
writeText(join2(src, "bootstrap/createApp.ts"), renderCreateAppTs(layers));
|
|
4094
4808
|
writeText(join2(src, "bootstrap/providers/config.ts"), renderConfigProvider(layers));
|
|
4095
4809
|
writeText(join2(src, "bootstrap/providers/queue.ts"), renderQueueProvider());
|
|
4810
|
+
writeText(join2(src, "bootstrap/providers/policy.ts"), renderPolicyProvider());
|
|
4096
4811
|
writeText(join2(src, "bootstrap/providers/index.ts"), renderProvidersIndex());
|
|
4097
4812
|
writeText(join2(src, "bootstrap/providers/auth.ts"), renderAuthProvider(layers));
|
|
4813
|
+
writeText(join2(src, "models/Note.ts"), renderNoteModel(layers));
|
|
4814
|
+
if (authNeedsUsers(layers.auth)) {
|
|
4815
|
+
writeText(join2(src, "models/User.ts"), renderUserModel(layers));
|
|
4816
|
+
writeText(join2(src, "models/AuthOneTimeToken.ts"), renderAuthOneTimeTokenModel());
|
|
4817
|
+
} else {
|
|
4818
|
+
removeIfExists(join2(src, "models/User.ts"));
|
|
4819
|
+
removeIfExists(join2(src, "models/AuthOneTimeToken.ts"));
|
|
4820
|
+
}
|
|
4821
|
+
if (authUsesToken(layers.auth)) {
|
|
4822
|
+
writeText(join2(src, "models/ApiToken.ts"), renderApiTokenModel());
|
|
4823
|
+
} else {
|
|
4824
|
+
removeIfExists(join2(src, "models/ApiToken.ts"));
|
|
4825
|
+
}
|
|
4098
4826
|
writeText(join2(src, "db/migrate.ts"), renderMigrateTs(layers));
|
|
4099
4827
|
writeText(join2(src, "db/fresh.ts"), renderFreshTs(layers));
|
|
4100
4828
|
writeText(join2(src, "db/seed.ts"), renderSeedTs());
|
|
@@ -4125,6 +4853,17 @@ function writeGeneratedFiles(options) {
|
|
|
4125
4853
|
removeIfExists(join2(src, "bootstrap/pendingMfa.ts"));
|
|
4126
4854
|
}
|
|
4127
4855
|
writeText(join2(targetDir, "public/assets/site.css"), renderSiteCss());
|
|
4856
|
+
const htmxSource = join2(options.templateRoot ?? starterPackageRoot(), "public/assets/htmx.min.js");
|
|
4857
|
+
const htmxFallback = join2(starterPackageRoot(), "templates/public/assets/htmx.min.js");
|
|
4858
|
+
const htmxPath = existsSync2(htmxSource) ? htmxSource : htmxFallback;
|
|
4859
|
+
if (existsSync2(htmxPath)) {
|
|
4860
|
+
mkdirSync2(join2(targetDir, "public/assets"), { recursive: true });
|
|
4861
|
+
copyFileSync(htmxPath, join2(targetDir, "public/assets/htmx.min.js"));
|
|
4862
|
+
const versionSidecar = `${htmxPath}.version`;
|
|
4863
|
+
if (existsSync2(versionSidecar)) {
|
|
4864
|
+
copyFileSync(versionSidecar, join2(targetDir, "public/assets/htmx.min.js.version"));
|
|
4865
|
+
}
|
|
4866
|
+
}
|
|
4128
4867
|
writeText(join2(targetDir, "views/home.eta"), renderHomeView(projectName, layers));
|
|
4129
4868
|
writeText(join2(targetDir, "views/layouts/app.eta"), renderLayout(layers, projectName));
|
|
4130
4869
|
if (htmlAuthKit(layers.auth)) {
|
|
@@ -4138,6 +4877,7 @@ function writeGeneratedFiles(options) {
|
|
|
4138
4877
|
if (layers.extras.mfa) {
|
|
4139
4878
|
writeText(join2(targetDir, "views/auth/mfa-challenge.eta"), renderMfaChallengeView());
|
|
4140
4879
|
writeText(join2(targetDir, "views/auth/mfa-setup.eta"), renderMfaSetupView());
|
|
4880
|
+
writeText(join2(targetDir, "views/auth/confirm-password.eta"), renderConfirmPasswordView());
|
|
4141
4881
|
}
|
|
4142
4882
|
}
|
|
4143
4883
|
mkdirSync2(join2(targetDir, "storage"), { recursive: true });
|