@getstrata/bootstrap 1.0.9 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/dist/bootstrap/discoverListeners.d.ts +2 -2
- package/dist/bootstrap/secretsGuard.d.ts +13 -1
- package/dist/bootstrap/web/session.d.ts +1 -0
- package/dist/entries/buildModuleRoutes.js +34 -38
- package/dist/entries/buildWebModuleRoutes.js +34 -38
- package/dist/entries/context.js +27 -11
- package/dist/entries/createSpaRoutes.js +10 -3
- package/dist/entries/createWebRoutes.js +43 -42
- package/dist/entries/dependencies.js +27 -11
- package/dist/entries/discoverListeners.js +54 -0
- package/dist/entries/health.js +3 -2
- package/dist/entries/httpKernel.js +28 -37
- package/dist/entries/metricsRoutes.js +2 -1
- package/dist/entries/providers.js +29 -13
- package/dist/entries/schedule.js +2 -2
- package/dist/entries/secretsGuard.js +100 -5
- package/dist/entries/web/routing.js +28 -37
- package/dist/entries/web/server.js +9 -3
- package/dist/entries/web/session.js +68 -18
- package/dist/index.js +225 -87
- package/package.json +8 -3
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// ../../src/bootstrap/discoverListeners.ts
|
|
3
|
+
import { existsSync, readdirSync } from "fs";
|
|
4
|
+
import { createRequire } from "module";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
var requireListener = createRequire(import.meta.url);
|
|
7
|
+
var DISCOVER_LISTENERS_STATE_KEY = Symbol.for("@getstrata/discoverListenersState");
|
|
8
|
+
function readDiscoverListenersState() {
|
|
9
|
+
const existing = globalThis[DISCOVER_LISTENERS_STATE_KEY];
|
|
10
|
+
if (existing) {
|
|
11
|
+
return existing;
|
|
12
|
+
}
|
|
13
|
+
const state = {};
|
|
14
|
+
globalThis[DISCOVER_LISTENERS_STATE_KEY] = state;
|
|
15
|
+
return state;
|
|
16
|
+
}
|
|
17
|
+
function resolveListenersDirectory() {
|
|
18
|
+
const fromCwd = join(process.cwd(), "src", "listeners");
|
|
19
|
+
if (existsSync(fromCwd)) {
|
|
20
|
+
return fromCwd;
|
|
21
|
+
}
|
|
22
|
+
return join(import.meta.dir, "../listeners");
|
|
23
|
+
}
|
|
24
|
+
function loadDiscoveredListeners() {
|
|
25
|
+
const listenersDirectory = resolveListenersDirectory();
|
|
26
|
+
let entries;
|
|
27
|
+
try {
|
|
28
|
+
entries = readdirSync(listenersDirectory, { withFileTypes: true }).filter((entry) => entry.isFile() && /\.(ts|js)$/.test(entry.name)).map((entry) => entry.name);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
const listeners = entries.map((fileName) => {
|
|
36
|
+
const filePath = join(listenersDirectory, fileName);
|
|
37
|
+
const loaded = requireListener(filePath);
|
|
38
|
+
return loaded.default;
|
|
39
|
+
});
|
|
40
|
+
return listeners.filter((listener) => typeof listener === "function");
|
|
41
|
+
}
|
|
42
|
+
function discoverListeners() {
|
|
43
|
+
const state = readDiscoverListenersState();
|
|
44
|
+
state.appListeners ??= loadDiscoveredListeners();
|
|
45
|
+
return state.appListeners;
|
|
46
|
+
}
|
|
47
|
+
function resetDiscoverListenersForTests() {
|
|
48
|
+
const state = readDiscoverListenersState();
|
|
49
|
+
state.appListeners = undefined;
|
|
50
|
+
}
|
|
51
|
+
export {
|
|
52
|
+
discoverListeners,
|
|
53
|
+
resetDiscoverListenersForTests
|
|
54
|
+
};
|
package/dist/entries/health.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { getBoundDatabaseConnection } from "@getstrata/core/database/boundConnection";
|
|
4
4
|
import { getDefaultDatabasePool } from "@getstrata/core/database/defaultConnection";
|
|
5
5
|
import { jsonResponse } from "@getstrata/core/http/response";
|
|
6
|
+
import { envFlagEnabled } from "@getstrata/core/runtime/appEnv";
|
|
6
7
|
var {RedisClient } = globalThis.Bun;
|
|
7
8
|
|
|
8
9
|
// ../../src/bootstrap/config.ts
|
|
@@ -114,10 +115,10 @@ function createHealthRoutes(dependencies, options = {}) {
|
|
|
114
115
|
"/ready": async () => {
|
|
115
116
|
const extra = await resolveExtraFields(options.extra);
|
|
116
117
|
const { checks, ready } = await collectDependencyChecks(dependencies);
|
|
118
|
+
const debug = envFlagEnabled(process.env.APP_DEBUG);
|
|
117
119
|
return jsonResponse({
|
|
118
120
|
status: ready ? "ready" : "not_ready",
|
|
119
|
-
checks,
|
|
120
|
-
...extra
|
|
121
|
+
...debug ? { checks, ...extra } : {}
|
|
121
122
|
}, { status: ready ? 200 : 503 });
|
|
122
123
|
}
|
|
123
124
|
};
|
|
@@ -134,8 +134,9 @@ class HttpKernel {
|
|
|
134
134
|
case "web":
|
|
135
135
|
return isViewsEnabled() ? [createFlashMiddleware(), createCsrfMiddleware()] : [];
|
|
136
136
|
case "api": {
|
|
137
|
+
const csrf = createCsrfMiddleware();
|
|
137
138
|
if (!this.dependencies.container.has(CORE_CONFIG_TOKEN)) {
|
|
138
|
-
return [];
|
|
139
|
+
return [csrf];
|
|
139
140
|
}
|
|
140
141
|
const config = this.dependencies.container.resolve(CORE_CONFIG_TOKEN);
|
|
141
142
|
const redisUrl = config.get(REDIS_URL_CONFIG_KEY)?.trim() ?? "";
|
|
@@ -145,7 +146,8 @@ class HttpKernel {
|
|
|
145
146
|
createMemoryThrottleMiddleware({
|
|
146
147
|
maxAttempts: Number.isFinite(maxAttempts) ? maxAttempts : 120,
|
|
147
148
|
decaySeconds: 60
|
|
148
|
-
})
|
|
149
|
+
}),
|
|
150
|
+
csrf
|
|
149
151
|
];
|
|
150
152
|
}
|
|
151
153
|
const maxAttempts = Number(process.env.RATE_LIMIT_PER_MINUTE ?? "120");
|
|
@@ -154,7 +156,8 @@ class HttpKernel {
|
|
|
154
156
|
redisUrl,
|
|
155
157
|
maxAttempts: Number.isFinite(maxAttempts) ? maxAttempts : 120,
|
|
156
158
|
decaySeconds: 60
|
|
157
|
-
})
|
|
159
|
+
}),
|
|
160
|
+
csrf
|
|
158
161
|
];
|
|
159
162
|
}
|
|
160
163
|
default:
|
|
@@ -164,13 +167,14 @@ class HttpKernel {
|
|
|
164
167
|
wrap(groups, handler) {
|
|
165
168
|
const names = Array.isArray(groups) ? groups : [groups];
|
|
166
169
|
const middleware = names.flatMap((name) => this.group(name));
|
|
167
|
-
|
|
168
|
-
|
|
170
|
+
const wrapped = middleware.length === 0 ? handler : withMiddleware(...middleware)(handler);
|
|
171
|
+
if (names.includes("api")) {
|
|
172
|
+
return withJsonErrorHandling(wrapped);
|
|
169
173
|
}
|
|
170
|
-
return
|
|
174
|
+
return wrapped;
|
|
171
175
|
}
|
|
172
176
|
wrapApi(handler) {
|
|
173
|
-
return
|
|
177
|
+
return this.wrap(["api", "authenticated"], handler);
|
|
174
178
|
}
|
|
175
179
|
wrapWeb(handler) {
|
|
176
180
|
return withErrorHandling(this.wrap("web", handler));
|
|
@@ -279,41 +283,28 @@ class HttpKernel {
|
|
|
279
283
|
return [createRequireVerifiedMiddleware(auth)];
|
|
280
284
|
}
|
|
281
285
|
wrapThrottle(scope, rateLimit, handler) {
|
|
282
|
-
const middleware = [];
|
|
283
286
|
const memoryKeyPrefix = scope === "login" ? "login-throttle:" : "register-throttle:";
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
decaySeconds: rateLimit.decaySeconds,
|
|
296
|
-
keyPrefix: memoryKeyPrefix
|
|
297
|
-
});
|
|
298
|
-
middleware.push(throttle);
|
|
299
|
-
} else {
|
|
300
|
-
middleware.push(createMemoryThrottleMiddleware({
|
|
301
|
-
maxAttempts: rateLimit.maxAttempts,
|
|
302
|
-
decaySeconds: rateLimit.decaySeconds,
|
|
303
|
-
keyPrefix: memoryKeyPrefix
|
|
304
|
-
}));
|
|
305
|
-
}
|
|
306
|
-
} else {
|
|
307
|
-
middleware.push(createMemoryThrottleMiddleware({
|
|
287
|
+
const redisUrl = this.dependencies.container.has(CORE_CONFIG_TOKEN) ? this.dependencies.container.resolve(CORE_CONFIG_TOKEN).get(REDIS_URL_CONFIG_KEY)?.trim() ?? "" : "";
|
|
288
|
+
if (scope === "login") {
|
|
289
|
+
return withMiddleware(createLoginThrottleMiddleware({
|
|
290
|
+
...redisUrl ? { redisUrl } : {},
|
|
291
|
+
maxAttempts: rateLimit.maxAttempts,
|
|
292
|
+
decaySeconds: rateLimit.decaySeconds
|
|
293
|
+
}))(handler);
|
|
294
|
+
}
|
|
295
|
+
if (redisUrl) {
|
|
296
|
+
return withMiddleware(createThrottleMiddleware({
|
|
297
|
+
redisUrl,
|
|
308
298
|
maxAttempts: rateLimit.maxAttempts,
|
|
309
299
|
decaySeconds: rateLimit.decaySeconds,
|
|
310
300
|
keyPrefix: memoryKeyPrefix
|
|
311
|
-
}));
|
|
312
|
-
}
|
|
313
|
-
if (middleware.length === 0) {
|
|
314
|
-
return handler;
|
|
301
|
+
}))(handler);
|
|
315
302
|
}
|
|
316
|
-
return withMiddleware(
|
|
303
|
+
return withMiddleware(createMemoryThrottleMiddleware({
|
|
304
|
+
maxAttempts: rateLimit.maxAttempts,
|
|
305
|
+
decaySeconds: rateLimit.decaySeconds,
|
|
306
|
+
keyPrefix: memoryKeyPrefix
|
|
307
|
+
}))(handler);
|
|
317
308
|
}
|
|
318
309
|
}
|
|
319
310
|
function createHttpKernel(dependencies) {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// ../../src/bootstrap/metricsRoutes.ts
|
|
3
3
|
import { timingSafeEqual } from "crypto";
|
|
4
4
|
import { prometheusRegistry } from "@getstrata/core/metrics/prometheus";
|
|
5
|
+
import { isProductionEnv } from "@getstrata/core/runtime/appEnv";
|
|
5
6
|
function tokensMatch(left, right) {
|
|
6
7
|
const leftBuffer = Buffer.from(left);
|
|
7
8
|
const rightBuffer = Buffer.from(right);
|
|
@@ -17,7 +18,7 @@ function authorizeMetrics(request) {
|
|
|
17
18
|
if (expected) {
|
|
18
19
|
return presented.length > 0 && tokensMatch(presented, expected);
|
|
19
20
|
}
|
|
20
|
-
if ((
|
|
21
|
+
if (isProductionEnv()) {
|
|
21
22
|
return false;
|
|
22
23
|
}
|
|
23
24
|
return true;
|
|
@@ -14,7 +14,7 @@ import { SessionGuard } from "@getstrata/core/auth/sessionGuard";
|
|
|
14
14
|
import { envFlagEnabled } from "@getstrata/core/runtime/appEnv";
|
|
15
15
|
var authConfig = {
|
|
16
16
|
allowDevHeaders: envFlagEnabled(process.env.AUTH_DEV_HEADERS),
|
|
17
|
-
tokenDefaultAbilities: [
|
|
17
|
+
tokenDefaultAbilities: []
|
|
18
18
|
};
|
|
19
19
|
|
|
20
20
|
// ../../src/bootstrap/config.ts
|
|
@@ -50,7 +50,7 @@ var authProvider = {
|
|
|
50
50
|
config.set("auth.allowDevHeaders", authConfig.allowDevHeaders);
|
|
51
51
|
const apiGuard = new DatabaseTokenGuard(container);
|
|
52
52
|
const sessionGuard = new SessionGuard(container);
|
|
53
|
-
const jwtGuard = new JwtGuard;
|
|
53
|
+
const jwtGuard = new JwtGuard(container);
|
|
54
54
|
const basicGuard = new BasicAuthGuard(container);
|
|
55
55
|
const guards = [apiGuard, jwtGuard, basicGuard, sessionGuard];
|
|
56
56
|
if (authConfig.allowDevHeaders) {
|
|
@@ -95,7 +95,7 @@ import { validateEnv } from "@getstrata/core/config/envSchema";
|
|
|
95
95
|
var appConfig = {
|
|
96
96
|
name: process.env.APP_NAME?.trim() || "Strata",
|
|
97
97
|
env: process.env.APP_ENV ?? "local",
|
|
98
|
-
debug: (process.env.APP_DEBUG ?? "
|
|
98
|
+
debug: (process.env.APP_DEBUG ?? "false") === "true",
|
|
99
99
|
url: process.env.APP_URL ?? "http://localhost:3000",
|
|
100
100
|
apiPrefix: process.env.API_PREFIX ?? "/api/v1"
|
|
101
101
|
};
|
|
@@ -276,8 +276,19 @@ var events_default = eventsProvider;
|
|
|
276
276
|
|
|
277
277
|
// ../../src/bootstrap/discoverListeners.ts
|
|
278
278
|
import { existsSync, readdirSync } from "fs";
|
|
279
|
+
import { createRequire } from "module";
|
|
279
280
|
import { join } from "path";
|
|
280
|
-
|
|
281
|
+
var requireListener = createRequire(import.meta.url);
|
|
282
|
+
var DISCOVER_LISTENERS_STATE_KEY = Symbol.for("@getstrata/discoverListenersState");
|
|
283
|
+
function readDiscoverListenersState() {
|
|
284
|
+
const existing = globalThis[DISCOVER_LISTENERS_STATE_KEY];
|
|
285
|
+
if (existing) {
|
|
286
|
+
return existing;
|
|
287
|
+
}
|
|
288
|
+
const state = {};
|
|
289
|
+
globalThis[DISCOVER_LISTENERS_STATE_KEY] = state;
|
|
290
|
+
return state;
|
|
291
|
+
}
|
|
281
292
|
function resolveListenersDirectory() {
|
|
282
293
|
const fromCwd = join(process.cwd(), "src", "listeners");
|
|
283
294
|
if (existsSync(fromCwd)) {
|
|
@@ -285,7 +296,7 @@ function resolveListenersDirectory() {
|
|
|
285
296
|
}
|
|
286
297
|
return join(import.meta.dir, "../listeners");
|
|
287
298
|
}
|
|
288
|
-
|
|
299
|
+
function loadDiscoveredListeners() {
|
|
289
300
|
const listenersDirectory = resolveListenersDirectory();
|
|
290
301
|
let entries;
|
|
291
302
|
try {
|
|
@@ -296,16 +307,21 @@ async function loadDiscoveredListeners() {
|
|
|
296
307
|
}
|
|
297
308
|
throw error;
|
|
298
309
|
}
|
|
299
|
-
const listeners =
|
|
300
|
-
const
|
|
301
|
-
const loaded =
|
|
310
|
+
const listeners = entries.map((fileName) => {
|
|
311
|
+
const filePath = join(listenersDirectory, fileName);
|
|
312
|
+
const loaded = requireListener(filePath);
|
|
302
313
|
return loaded.default;
|
|
303
|
-
})
|
|
314
|
+
});
|
|
304
315
|
return listeners.filter((listener) => typeof listener === "function");
|
|
305
316
|
}
|
|
306
|
-
var appListeners = await loadDiscoveredListeners();
|
|
307
317
|
function discoverListeners() {
|
|
308
|
-
|
|
318
|
+
const state = readDiscoverListenersState();
|
|
319
|
+
state.appListeners ??= loadDiscoveredListeners();
|
|
320
|
+
return state.appListeners;
|
|
321
|
+
}
|
|
322
|
+
function resetDiscoverListenersForTests() {
|
|
323
|
+
const state = readDiscoverListenersState();
|
|
324
|
+
state.appListeners = undefined;
|
|
309
325
|
}
|
|
310
326
|
|
|
311
327
|
// ../../src/bootstrap/listeners/invalidateCacheOnModelWrite.ts
|
|
@@ -329,7 +345,7 @@ import {
|
|
|
329
345
|
// ../../src/bootstrap/discoverModules.ts
|
|
330
346
|
import { readdirSync as readdirSync2 } from "fs";
|
|
331
347
|
import { join as join2 } from "path";
|
|
332
|
-
import { pathToFileURL
|
|
348
|
+
import { pathToFileURL } from "url";
|
|
333
349
|
var DISCOVER_MODULES_STATE_KEY = Symbol.for("@getstrata/discoverModulesState");
|
|
334
350
|
function readDiscoverModulesState() {
|
|
335
351
|
const existing = globalThis[DISCOVER_MODULES_STATE_KEY];
|
|
@@ -370,7 +386,7 @@ async function loadDiscoveredModules(options) {
|
|
|
370
386
|
throw error;
|
|
371
387
|
}
|
|
372
388
|
const modules = await Promise.all(moduleNames.map(async (moduleName) => {
|
|
373
|
-
const moduleUrl =
|
|
389
|
+
const moduleUrl = pathToFileURL(join2(modulesDirectory, moduleName, "index.ts")).href;
|
|
374
390
|
const loaded = await import(moduleUrl);
|
|
375
391
|
return loaded.default;
|
|
376
392
|
}));
|
package/dist/entries/schedule.js
CHANGED
|
@@ -14,8 +14,8 @@ function readFeatureFlags() {
|
|
|
14
14
|
samlLogin: (process.env.FEATURE_SAML ?? "false") === "true",
|
|
15
15
|
scim: (process.env.FEATURE_SCIM ?? "true") !== "false",
|
|
16
16
|
billing: (process.env.FEATURE_BILLING ?? "true") !== "false",
|
|
17
|
-
siemExport: (process.env.FEATURE_SIEM_EXPORT ?? "
|
|
18
|
-
publicReads: (process.env.FEATURE_PUBLIC_READS ?? "
|
|
17
|
+
siemExport: (process.env.FEATURE_SIEM_EXPORT ?? "false") === "true",
|
|
18
|
+
publicReads: (process.env.FEATURE_PUBLIC_READS ?? "false") === "true",
|
|
19
19
|
emailVerification: (process.env.FEATURE_EMAIL_VERIFICATION ?? "false") === "true",
|
|
20
20
|
mfa: (process.env.FEATURE_MFA ?? "false") === "true",
|
|
21
21
|
registration: (process.env.FEATURE_REGISTRATION ?? "true") !== "false"
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// ../../src/bootstrap/secretsGuard.ts
|
|
3
|
+
import { getDefaultDatabasePool } from "@getstrata/core/database/defaultConnection";
|
|
3
4
|
import { envFlagEnabled, isProductionEnv } from "@getstrata/core/runtime/appEnv";
|
|
4
5
|
import { isViewsMode, parseFrontendMode } from "@getstrata/core/runtime/frontendMode";
|
|
6
|
+
import {
|
|
7
|
+
assertPostgresRoleCannotBypassRls,
|
|
8
|
+
inspectCurrentPostgresRole,
|
|
9
|
+
isPostgresUrl,
|
|
10
|
+
postgresUrlUsername
|
|
11
|
+
} from "@getstrata/core/tenant/enableTenantRls";
|
|
12
|
+
import { isRlsTenancy } from "@getstrata/core/tenant/tenancyConfig";
|
|
5
13
|
var PUBLISHED_TEST_ADMIN_API_TOKEN = "strata-admin-test-token";
|
|
6
14
|
var PUBLISHED_TEST_MEMBER_API_TOKEN = "strata-member-test-token";
|
|
7
15
|
var PUBLISHED_TEST_SCIM_BEARER_TOKEN = "strata-scim-test-token";
|
|
@@ -22,8 +30,11 @@ var SECRETS_TO_ROTATE = [
|
|
|
22
30
|
"KMS_ENCRYPTION_KEY",
|
|
23
31
|
"STRIPE_WEBHOOK_SECRET",
|
|
24
32
|
"ADMIN_API_TOKEN",
|
|
25
|
-
"MEMBER_API_TOKEN"
|
|
33
|
+
"MEMBER_API_TOKEN",
|
|
34
|
+
"DATABASE_URL",
|
|
35
|
+
"APP_DATABASE_URL"
|
|
26
36
|
];
|
|
37
|
+
var RLS_BYPASS_DATABASE_USERS = new Set(["postgres", "root"]);
|
|
27
38
|
function assertNoPlaceholderSecrets(env) {
|
|
28
39
|
const unrotated = SECRETS_TO_ROTATE.filter((name) => PLACEHOLDER_SECRET_PATTERN.test(env[name]?.trim() ?? ""));
|
|
29
40
|
if (unrotated.length > 0) {
|
|
@@ -34,7 +45,11 @@ function isTokenAuthEnabled(env) {
|
|
|
34
45
|
return Boolean(env.ADMIN_API_TOKEN?.trim()) || Boolean(env.MEMBER_API_TOKEN?.trim()) || envFlagEnabled(env.FEATURE_API_TOKENS);
|
|
35
46
|
}
|
|
36
47
|
function isOAuthEnabled(env) {
|
|
37
|
-
return envFlagEnabled(env.FEATURE_OAUTH) || envFlagEnabled(env.FEATURE_SAML) || Boolean(env.GITHUB_CLIENT_ID?.trim()) || Boolean(env.OIDC_ISSUER?.trim()) || Boolean(env.SAML_LOGIN_URL?.trim());
|
|
48
|
+
return envFlagEnabled(env.FEATURE_OAUTH) || envFlagEnabled(env.FEATURE_SAML) || Boolean(env.GITHUB_CLIENT_ID?.trim()) || Boolean(env.OIDC_ISSUER?.trim()) || Boolean(env.SAML_IDP_SSO_URL?.trim()) || Boolean(env.SAML_LOGIN_URL?.trim());
|
|
49
|
+
}
|
|
50
|
+
function isHeaderOnlyAuth(env) {
|
|
51
|
+
const frontend = env.FRONTEND_MODE?.trim() ?? "";
|
|
52
|
+
return env.AUTH_MODE === "headers" || frontend === "api" && env.AUTH_DEV_HEADERS === "true";
|
|
38
53
|
}
|
|
39
54
|
function isCorsConfigured(env) {
|
|
40
55
|
return env.CORS_ALLOWED_ORIGINS !== undefined;
|
|
@@ -77,8 +92,27 @@ function assertFeatureProductionSecrets(env) {
|
|
|
77
92
|
throw new Error("Production startup blocked: rotate SCIM_BEARER_TOKEN away from published test defaults.");
|
|
78
93
|
}
|
|
79
94
|
}
|
|
80
|
-
if (envFlagEnabled(env.FEATURE_FIELD_ENCRYPTION) && !env.KMS_ENCRYPTION_KEY?.trim()) {
|
|
81
|
-
throw new Error("Production startup blocked: set KMS_ENCRYPTION_KEY when field encryption is enabled.");
|
|
95
|
+
if ((envFlagEnabled(env.FEATURE_FIELD_ENCRYPTION) || envFlagEnabled(env.FEATURE_MFA)) && !env.KMS_ENCRYPTION_KEY?.trim()) {
|
|
96
|
+
throw new Error("Production startup blocked: set KMS_ENCRYPTION_KEY when field encryption or MFA is enabled.");
|
|
97
|
+
}
|
|
98
|
+
if (envFlagEnabled(env.FEATURE_SAML)) {
|
|
99
|
+
const required = [
|
|
100
|
+
"SAML_IDP_CERT",
|
|
101
|
+
"SAML_IDP_SSO_URL",
|
|
102
|
+
"SAML_SP_ENTITY_ID",
|
|
103
|
+
"SAML_ACS_URL",
|
|
104
|
+
"SAML_IDP_ISSUER"
|
|
105
|
+
];
|
|
106
|
+
const missing = required.filter((name) => !env[name]?.trim());
|
|
107
|
+
if (missing.length > 0) {
|
|
108
|
+
throw new Error(`Production startup blocked: set ${missing.join(", ")} when FEATURE_SAML=true.`);
|
|
109
|
+
}
|
|
110
|
+
if (env.SAML_WANT_RESPONSE_SIGNED === "false") {
|
|
111
|
+
throw new Error("Production startup blocked: signed SAML responses are required (do not set SAML_WANT_RESPONSE_SIGNED=false).");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (envFlagEnabled(env.UPLOAD_ALLOW_UNKNOWN_MIME)) {
|
|
115
|
+
throw new Error("Production startup blocked: set UPLOAD_ALLOW_UNKNOWN_MIME=false (unknown MIME types are not allowed).");
|
|
82
116
|
}
|
|
83
117
|
if (envFlagEnabled(env.FEATURE_BILLING) && !env.STRIPE_WEBHOOK_SECRET?.trim()) {
|
|
84
118
|
throw new Error("Production startup blocked: set STRIPE_WEBHOOK_SECRET when billing webhooks are enabled.");
|
|
@@ -100,6 +134,61 @@ function assertFeatureProductionSecrets(env) {
|
|
|
100
134
|
}
|
|
101
135
|
}
|
|
102
136
|
var LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"]);
|
|
137
|
+
var livePostgresRoleInspector = null;
|
|
138
|
+
function setLivePostgresRoleInspectorForTests(inspect) {
|
|
139
|
+
livePostgresRoleInspector = inspect;
|
|
140
|
+
}
|
|
141
|
+
function rlsRuntimeDatabaseUrl(env) {
|
|
142
|
+
const app = env.APP_DATABASE_URL?.trim();
|
|
143
|
+
if (app) {
|
|
144
|
+
return { name: "APP_DATABASE_URL", raw: app };
|
|
145
|
+
}
|
|
146
|
+
const database = env.DATABASE_URL?.trim();
|
|
147
|
+
if (database) {
|
|
148
|
+
return { name: "DATABASE_URL", raw: database };
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
function assertRlsUsesAppDatabaseRole(env) {
|
|
153
|
+
if (!isRlsTenancy(env)) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const runtime = rlsRuntimeDatabaseUrl(env);
|
|
157
|
+
if (!runtime) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const user = postgresUrlUsername(runtime.raw);
|
|
161
|
+
if (user === null) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (user === "") {
|
|
165
|
+
throw new Error(`TENANCY_DRIVER=rls startup blocked: ${runtime.name} must include a NOBYPASSRLS role username.`);
|
|
166
|
+
}
|
|
167
|
+
if (RLS_BYPASS_DATABASE_USERS.has(user.toLowerCase())) {
|
|
168
|
+
throw new Error(`TENANCY_DRIVER=rls startup blocked: ${runtime.name} for TENANCY_DRIVER=rls must use a NOBYPASSRLS role, not ${user}. FORCE RLS does not apply to PostgreSQL superusers.`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async function defaultInspectLivePostgresRole() {
|
|
172
|
+
return await inspectCurrentPostgresRole(getDefaultDatabasePool());
|
|
173
|
+
}
|
|
174
|
+
async function assertRlsLiveDatabaseRole(env = process.env, inspect) {
|
|
175
|
+
if (!isRlsTenancy(env)) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const runtime = rlsRuntimeDatabaseUrl(env);
|
|
179
|
+
if (!runtime || !isPostgresUrl(runtime.raw)) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const source = runtime.name;
|
|
183
|
+
assertRlsUsesAppDatabaseRole(env);
|
|
184
|
+
let role;
|
|
185
|
+
try {
|
|
186
|
+
role = await (inspect ?? livePostgresRoleInspector ?? defaultInspectLivePostgresRole)();
|
|
187
|
+
} catch {
|
|
188
|
+
throw new Error(`TENANCY_DRIVER=rls startup blocked: could not inspect the live Postgres role for ${source}.`);
|
|
189
|
+
}
|
|
190
|
+
assertPostgresRoleCannotBypassRls(role, source);
|
|
191
|
+
}
|
|
103
192
|
function assertPublicAppUrl(env) {
|
|
104
193
|
const raw = env.APP_URL?.trim() ?? "";
|
|
105
194
|
let url = null;
|
|
@@ -122,6 +211,9 @@ function assertProductionSecrets(env = process.env) {
|
|
|
122
211
|
assertNoPlaceholderSecrets(env);
|
|
123
212
|
assertAuthDevHeadersDisabled(env);
|
|
124
213
|
assertPublicAppUrl(env);
|
|
214
|
+
if (isHeaderOnlyAuth(env)) {
|
|
215
|
+
throw new Error("Production startup blocked: header-only authentication is not allowed. Configure cookie, token, or JWT auth.");
|
|
216
|
+
}
|
|
125
217
|
if (isTokenAuthEnabled(env)) {
|
|
126
218
|
assertTokenAuthProductionSecrets(env);
|
|
127
219
|
}
|
|
@@ -129,7 +221,10 @@ function assertProductionSecrets(env = process.env) {
|
|
|
129
221
|
if (isViewsMode(parseFrontendMode(env.FRONTEND_MODE))) {
|
|
130
222
|
assertSessionSecret(env);
|
|
131
223
|
}
|
|
224
|
+
assertRlsUsesAppDatabaseRole(env);
|
|
132
225
|
}
|
|
133
226
|
export {
|
|
134
|
-
assertProductionSecrets
|
|
227
|
+
assertProductionSecrets,
|
|
228
|
+
assertRlsLiveDatabaseRole,
|
|
229
|
+
setLivePostgresRoleInspectorForTests
|
|
135
230
|
};
|
|
@@ -144,8 +144,9 @@ class HttpKernel {
|
|
|
144
144
|
case "web":
|
|
145
145
|
return isViewsEnabled() ? [createFlashMiddleware(), createCsrfMiddleware()] : [];
|
|
146
146
|
case "api": {
|
|
147
|
+
const csrf = createCsrfMiddleware();
|
|
147
148
|
if (!this.dependencies.container.has(CORE_CONFIG_TOKEN)) {
|
|
148
|
-
return [];
|
|
149
|
+
return [csrf];
|
|
149
150
|
}
|
|
150
151
|
const config = this.dependencies.container.resolve(CORE_CONFIG_TOKEN);
|
|
151
152
|
const redisUrl = config.get(REDIS_URL_CONFIG_KEY)?.trim() ?? "";
|
|
@@ -155,7 +156,8 @@ class HttpKernel {
|
|
|
155
156
|
createMemoryThrottleMiddleware({
|
|
156
157
|
maxAttempts: Number.isFinite(maxAttempts) ? maxAttempts : 120,
|
|
157
158
|
decaySeconds: 60
|
|
158
|
-
})
|
|
159
|
+
}),
|
|
160
|
+
csrf
|
|
159
161
|
];
|
|
160
162
|
}
|
|
161
163
|
const maxAttempts = Number(process.env.RATE_LIMIT_PER_MINUTE ?? "120");
|
|
@@ -164,7 +166,8 @@ class HttpKernel {
|
|
|
164
166
|
redisUrl,
|
|
165
167
|
maxAttempts: Number.isFinite(maxAttempts) ? maxAttempts : 120,
|
|
166
168
|
decaySeconds: 60
|
|
167
|
-
})
|
|
169
|
+
}),
|
|
170
|
+
csrf
|
|
168
171
|
];
|
|
169
172
|
}
|
|
170
173
|
default:
|
|
@@ -174,13 +177,14 @@ class HttpKernel {
|
|
|
174
177
|
wrap(groups, handler) {
|
|
175
178
|
const names = Array.isArray(groups) ? groups : [groups];
|
|
176
179
|
const middleware = names.flatMap((name) => this.group(name));
|
|
177
|
-
|
|
178
|
-
|
|
180
|
+
const wrapped = middleware.length === 0 ? handler : withMiddleware(...middleware)(handler);
|
|
181
|
+
if (names.includes("api")) {
|
|
182
|
+
return withJsonErrorHandling(wrapped);
|
|
179
183
|
}
|
|
180
|
-
return
|
|
184
|
+
return wrapped;
|
|
181
185
|
}
|
|
182
186
|
wrapApi(handler) {
|
|
183
|
-
return
|
|
187
|
+
return this.wrap(["api", "authenticated"], handler);
|
|
184
188
|
}
|
|
185
189
|
wrapWeb(handler) {
|
|
186
190
|
return withErrorHandling(this.wrap("web", handler));
|
|
@@ -289,41 +293,28 @@ class HttpKernel {
|
|
|
289
293
|
return [createRequireVerifiedMiddleware(auth)];
|
|
290
294
|
}
|
|
291
295
|
wrapThrottle(scope, rateLimit, handler) {
|
|
292
|
-
const middleware = [];
|
|
293
296
|
const memoryKeyPrefix = scope === "login" ? "login-throttle:" : "register-throttle:";
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
decaySeconds: rateLimit.decaySeconds,
|
|
306
|
-
keyPrefix: memoryKeyPrefix
|
|
307
|
-
});
|
|
308
|
-
middleware.push(throttle);
|
|
309
|
-
} else {
|
|
310
|
-
middleware.push(createMemoryThrottleMiddleware({
|
|
311
|
-
maxAttempts: rateLimit.maxAttempts,
|
|
312
|
-
decaySeconds: rateLimit.decaySeconds,
|
|
313
|
-
keyPrefix: memoryKeyPrefix
|
|
314
|
-
}));
|
|
315
|
-
}
|
|
316
|
-
} else {
|
|
317
|
-
middleware.push(createMemoryThrottleMiddleware({
|
|
297
|
+
const redisUrl = this.dependencies.container.has(CORE_CONFIG_TOKEN) ? this.dependencies.container.resolve(CORE_CONFIG_TOKEN).get(REDIS_URL_CONFIG_KEY)?.trim() ?? "" : "";
|
|
298
|
+
if (scope === "login") {
|
|
299
|
+
return withMiddleware(createLoginThrottleMiddleware({
|
|
300
|
+
...redisUrl ? { redisUrl } : {},
|
|
301
|
+
maxAttempts: rateLimit.maxAttempts,
|
|
302
|
+
decaySeconds: rateLimit.decaySeconds
|
|
303
|
+
}))(handler);
|
|
304
|
+
}
|
|
305
|
+
if (redisUrl) {
|
|
306
|
+
return withMiddleware(createThrottleMiddleware({
|
|
307
|
+
redisUrl,
|
|
318
308
|
maxAttempts: rateLimit.maxAttempts,
|
|
319
309
|
decaySeconds: rateLimit.decaySeconds,
|
|
320
310
|
keyPrefix: memoryKeyPrefix
|
|
321
|
-
}));
|
|
322
|
-
}
|
|
323
|
-
if (middleware.length === 0) {
|
|
324
|
-
return handler;
|
|
311
|
+
}))(handler);
|
|
325
312
|
}
|
|
326
|
-
return withMiddleware(
|
|
313
|
+
return withMiddleware(createMemoryThrottleMiddleware({
|
|
314
|
+
maxAttempts: rateLimit.maxAttempts,
|
|
315
|
+
decaySeconds: rateLimit.decaySeconds,
|
|
316
|
+
keyPrefix: memoryKeyPrefix
|
|
317
|
+
}))(handler);
|
|
327
318
|
}
|
|
328
319
|
}
|
|
329
320
|
function createHttpKernel(dependencies) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// ../../src/bootstrap/web/server.ts
|
|
3
3
|
import { currentRequestMeta, runWithRequestMeta } from "@getstrata/core/http/requestMetaContext";
|
|
4
|
+
import { assertUrlPathUnderRoot } from "@getstrata/core/security/safePath";
|
|
4
5
|
import { notFoundHtmlResponse } from "@getstrata/core/view";
|
|
5
6
|
function socketAddress(server, request) {
|
|
6
7
|
const address = server?.requestIP(request)?.address;
|
|
@@ -60,9 +61,14 @@ function createWebServer(options) {
|
|
|
60
61
|
await options.onRequest?.(request);
|
|
61
62
|
const url = new URL(request.url);
|
|
62
63
|
if (url.pathname.startsWith("/assets/")) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
try {
|
|
65
|
+
const filePath = assertUrlPathUnderRoot(publicDir, url.pathname);
|
|
66
|
+
const file = Bun.file(filePath);
|
|
67
|
+
if (await file.exists()) {
|
|
68
|
+
return new Response(file);
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
return await missingHtmlResponse();
|
|
66
72
|
}
|
|
67
73
|
}
|
|
68
74
|
if (options.handle) {
|