@getstrata/bootstrap 0.2.48 → 0.2.50
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/README.md +5 -1
- package/dist/bootstrap/config.d.ts +1 -1
- package/dist/bootstrap/health.d.ts +8 -2
- package/dist/bootstrap/public-api.d.ts +4 -2
- package/dist/bootstrap/web/index.d.ts +1 -1
- package/dist/bootstrap/web/routing.d.ts +2 -2
- package/dist/bootstrap/web/session.d.ts +25 -3
- package/dist/entries/buildModuleRoutes.js +24 -8
- package/dist/entries/buildWebModuleRoutes.js +24 -8
- package/dist/entries/config.js +4 -0
- package/dist/entries/context.js +2 -63
- package/dist/entries/createRoutes.js +218 -169
- package/dist/entries/createSpaRoutes.js +1 -1
- package/dist/entries/createWebRoutes.js +24 -8
- package/dist/entries/dependencies.js +2 -63
- package/dist/entries/health.js +69 -152
- package/dist/entries/httpKernel.js +24 -8
- package/dist/entries/providers.js +2 -0
- package/dist/entries/secretsGuard.js +64 -10
- package/dist/entries/web/routing.js +28 -12
- package/dist/entries/web/session.js +59 -7
- package/dist/index.js +300 -81
- package/package.json +5 -4
|
@@ -3,15 +3,40 @@ var __jsonParse = (a) => JSON.parse(a);
|
|
|
3
3
|
|
|
4
4
|
// ../../src/bootstrap/web/session.ts
|
|
5
5
|
import { createHash, randomBytes } from "crypto";
|
|
6
|
+
import { AuthManager } from "@getstrata/core/auth/guard";
|
|
7
|
+
import { getBoundDatabaseConnection } from "@getstrata/core/database/boundConnection";
|
|
8
|
+
import { getDefaultDatabasePool } from "@getstrata/core/database/defaultConnection";
|
|
6
9
|
import { readRequestCookie } from "@getstrata/core/http/cookies";
|
|
10
|
+
function isSqlClient(value) {
|
|
11
|
+
return typeof value.unsafe === "function";
|
|
12
|
+
}
|
|
13
|
+
function resolveSql(source) {
|
|
14
|
+
if (isSqlClient(source)) {
|
|
15
|
+
return source;
|
|
16
|
+
}
|
|
17
|
+
return source();
|
|
18
|
+
}
|
|
19
|
+
function defaultSessionSql() {
|
|
20
|
+
const bound = getBoundDatabaseConnection();
|
|
21
|
+
if (bound) {
|
|
22
|
+
return bound;
|
|
23
|
+
}
|
|
24
|
+
return getDefaultDatabasePool();
|
|
25
|
+
}
|
|
26
|
+
function defaultMapSessionUser(user) {
|
|
27
|
+
return {
|
|
28
|
+
id: user.id,
|
|
29
|
+
role: user.is_admin ? "admin" : "member"
|
|
30
|
+
};
|
|
31
|
+
}
|
|
7
32
|
|
|
8
33
|
class CookieSessionStore {
|
|
9
|
-
|
|
34
|
+
sqlSource;
|
|
10
35
|
secret;
|
|
11
36
|
cookieName;
|
|
12
37
|
maxAgeSeconds;
|
|
13
|
-
constructor(
|
|
14
|
-
this.
|
|
38
|
+
constructor(sqlSource, secret, cookieName = "strata_session", maxAgeSeconds = 60 * 60 * 24 * 14) {
|
|
39
|
+
this.sqlSource = sqlSource;
|
|
15
40
|
this.secret = secret;
|
|
16
41
|
this.cookieName = cookieName;
|
|
17
42
|
this.maxAgeSeconds = maxAgeSeconds;
|
|
@@ -29,10 +54,13 @@ class CookieSessionStore {
|
|
|
29
54
|
}
|
|
30
55
|
return header.includes("Secure") ? header : `${header}; Secure`;
|
|
31
56
|
}
|
|
57
|
+
sql() {
|
|
58
|
+
return resolveSql(this.sqlSource);
|
|
59
|
+
}
|
|
32
60
|
async create(user) {
|
|
33
61
|
const id = randomBytes(32).toString("hex");
|
|
34
62
|
const expires = new Date(Date.now() + this.maxAgeSeconds * 1000);
|
|
35
|
-
await this.sql.unsafe(`INSERT INTO sessions (id, user_id, expires_at) VALUES ($1, $2, $3)`, [
|
|
63
|
+
await this.sql().unsafe(`INSERT INTO sessions (id, user_id, expires_at) VALUES ($1, $2, $3)`, [
|
|
36
64
|
id,
|
|
37
65
|
user.id,
|
|
38
66
|
expires
|
|
@@ -40,7 +68,7 @@ class CookieSessionStore {
|
|
|
40
68
|
return id;
|
|
41
69
|
}
|
|
42
70
|
async destroy(sessionId) {
|
|
43
|
-
await this.sql.unsafe(`DELETE FROM sessions WHERE id = $1`, [sessionId]);
|
|
71
|
+
await this.sql().unsafe(`DELETE FROM sessions WHERE id = $1`, [sessionId]);
|
|
44
72
|
}
|
|
45
73
|
async read(request) {
|
|
46
74
|
const cookie = readRequestCookie(request, this.cookieName);
|
|
@@ -51,7 +79,7 @@ class CookieSessionStore {
|
|
|
51
79
|
if (!sessionId || !signature || signature !== this.sign(sessionId)) {
|
|
52
80
|
return null;
|
|
53
81
|
}
|
|
54
|
-
const rows = await this.sql.unsafe(`SELECT s.id, s.user_id, s.expires_at, u.name, u.email, u.learn_subscriber,
|
|
82
|
+
const rows = await this.sql().unsafe(`SELECT s.id, s.user_id, s.expires_at, u.name, u.email, u.learn_subscriber,
|
|
55
83
|
COALESCE(u.is_admin, false) AS is_admin
|
|
56
84
|
FROM sessions s
|
|
57
85
|
INNER JOIN users u ON u.id = s.user_id
|
|
@@ -71,6 +99,30 @@ class CookieSessionStore {
|
|
|
71
99
|
return createHash("sha256").update(`${value}.${this.secret}`).digest("hex").slice(0, 32);
|
|
72
100
|
}
|
|
73
101
|
}
|
|
102
|
+
|
|
103
|
+
class CookieSessionGuard {
|
|
104
|
+
store;
|
|
105
|
+
mapUser;
|
|
106
|
+
constructor(store, mapUser = defaultMapSessionUser) {
|
|
107
|
+
this.store = store;
|
|
108
|
+
this.mapUser = mapUser;
|
|
109
|
+
}
|
|
110
|
+
async resolve(request) {
|
|
111
|
+
const user = await this.store.read(request);
|
|
112
|
+
if (!user) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return this.mapUser(user);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function createCookieSessionAuthManager(options = {}) {
|
|
119
|
+
const store = options.store ?? new CookieSessionStore(options.sql ?? defaultSessionSql, options.secret ?? process.env.SESSION_SECRET?.trim() ?? "", options.cookieName, options.maxAgeSeconds);
|
|
120
|
+
return new AuthManager(new CookieSessionGuard(store, options.mapUser ?? defaultMapSessionUser));
|
|
121
|
+
}
|
|
74
122
|
export {
|
|
75
|
-
|
|
123
|
+
CookieSessionGuard,
|
|
124
|
+
CookieSessionStore,
|
|
125
|
+
createCookieSessionAuthManager,
|
|
126
|
+
defaultMapSessionUser,
|
|
127
|
+
defaultSessionSql
|
|
76
128
|
};
|
package/dist/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import { applyMiddlewareToRoutes } from "@getstrata/core/http/middleware";
|
|
|
20
20
|
|
|
21
21
|
// ../../src/bootstrap/httpKernel.ts
|
|
22
22
|
import { createMembershipMiddleware } from "@getstrata/core/auth/membershipMiddleware";
|
|
23
|
+
import { resolveAbilityChecker } from "@getstrata/core/contracts/serviceTokens";
|
|
23
24
|
import { createAuthMiddleware } from "@getstrata/core/http/authMiddleware";
|
|
24
25
|
import { createAuthorizeMiddleware } from "@getstrata/core/http/authorizeMiddleware";
|
|
25
26
|
import { createBodySizeLimitMiddleware } from "@getstrata/core/http/bodySizeLimitMiddleware";
|
|
@@ -34,6 +35,7 @@ import { createRequireAbilityMiddleware } from "@getstrata/core/http/requireAbil
|
|
|
34
35
|
import { createRequireAuthMiddleware } from "@getstrata/core/http/requireAuthMiddleware";
|
|
35
36
|
import { createRequireGlobalAdminMiddleware } from "@getstrata/core/http/requireGlobalAdminMiddleware";
|
|
36
37
|
import { createRequireWebAuthMiddleware } from "@getstrata/core/http/requireWebAuthMiddleware";
|
|
38
|
+
import { withErrorHandling } from "@getstrata/core/http/response";
|
|
37
39
|
import { withMiddleware } from "@getstrata/core/http/routeMiddleware";
|
|
38
40
|
import { createSecurityHeadersMiddleware } from "@getstrata/core/http/securityHeadersMiddleware";
|
|
39
41
|
import { createThrottleMiddleware } from "@getstrata/core/http/throttleMiddleware";
|
|
@@ -76,24 +78,38 @@ function parsePositiveInt(value, fallback) {
|
|
|
76
78
|
}
|
|
77
79
|
return Math.trunc(parsed);
|
|
78
80
|
}
|
|
81
|
+
function parseWindowSeconds(secondsValue, msValue, fallback) {
|
|
82
|
+
if (secondsValue !== undefined && secondsValue.trim() !== "") {
|
|
83
|
+
return parsePositiveInt(secondsValue, fallback);
|
|
84
|
+
}
|
|
85
|
+
if (msValue !== undefined && msValue.trim() !== "") {
|
|
86
|
+
const parsedMs = Number(msValue);
|
|
87
|
+
if (Number.isFinite(parsedMs) && parsedMs > 0) {
|
|
88
|
+
return Math.max(1, Math.trunc(parsedMs / 1000));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return fallback;
|
|
92
|
+
}
|
|
79
93
|
function resolveLoginRateLimit() {
|
|
80
94
|
const defaults = isLocalAppEnv() ? LOCAL_LOGIN_RATE_LIMIT : PRODUCTION_LOGIN_RATE_LIMIT;
|
|
81
95
|
return {
|
|
82
96
|
maxAttempts: parsePositiveInt(process.env.LOGIN_RATE_LIMIT_PER_WINDOW, defaults.maxAttempts),
|
|
83
|
-
decaySeconds:
|
|
97
|
+
decaySeconds: parseWindowSeconds(process.env.LOGIN_RATE_LIMIT_WINDOW_SECONDS, process.env.LOGIN_RATE_LIMIT_WINDOW_MS, defaults.decaySeconds)
|
|
84
98
|
};
|
|
85
99
|
}
|
|
86
100
|
function resolveRegisterRateLimit() {
|
|
87
101
|
const defaults = isLocalAppEnv() ? { maxAttempts: 100, decaySeconds: 60 } : { maxAttempts: 10, decaySeconds: 60 };
|
|
88
102
|
return {
|
|
89
103
|
maxAttempts: parsePositiveInt(process.env.REGISTER_RATE_LIMIT_PER_WINDOW, defaults.maxAttempts),
|
|
90
|
-
decaySeconds:
|
|
104
|
+
decaySeconds: parseWindowSeconds(process.env.REGISTER_RATE_LIMIT_WINDOW_SECONDS, process.env.REGISTER_RATE_LIMIT_WINDOW_MS, defaults.decaySeconds)
|
|
91
105
|
};
|
|
92
106
|
}
|
|
93
107
|
|
|
94
108
|
// ../../src/bootstrap/config.ts
|
|
95
109
|
import {
|
|
110
|
+
CORE_ABILITY_CHECKER_TOKEN,
|
|
96
111
|
CORE_AUTH_TOKEN,
|
|
112
|
+
CORE_AUTH_USER_DIRECTORY_TOKEN,
|
|
97
113
|
CORE_CACHE_TOKEN,
|
|
98
114
|
CORE_CONFIG_TOKEN,
|
|
99
115
|
CORE_EVENT_BUS_TOKEN,
|
|
@@ -182,7 +198,7 @@ class HttpKernel {
|
|
|
182
198
|
return this.wrap(["api", "authenticated"], handler);
|
|
183
199
|
}
|
|
184
200
|
wrapWeb(handler) {
|
|
185
|
-
return this.wrap("web", handler);
|
|
201
|
+
return withErrorHandling(this.wrap("web", handler));
|
|
186
202
|
}
|
|
187
203
|
wrapWebPublicRead(handler) {
|
|
188
204
|
if (isPublicReadsEnabled()) {
|
|
@@ -192,19 +208,19 @@ class HttpKernel {
|
|
|
192
208
|
}
|
|
193
209
|
wrapWebAuthenticated(handler) {
|
|
194
210
|
const auth = this.dependencies.container.resolve(CORE_AUTH_TOKEN);
|
|
195
|
-
return this.
|
|
211
|
+
return this.wrapWeb(withMiddleware(createRequireWebAuthMiddleware(auth))(handler));
|
|
196
212
|
}
|
|
197
213
|
wrapWebAbility(ability, handler) {
|
|
198
214
|
const auth = this.dependencies.container.resolve(CORE_AUTH_TOKEN);
|
|
199
|
-
const abilityChecker = this.dependencies.container
|
|
215
|
+
const abilityChecker = resolveAbilityChecker(this.dependencies.container);
|
|
200
216
|
const requireAbility = createRequireAbilityMiddleware(abilityChecker);
|
|
201
217
|
const middleware = [createRequireWebAuthMiddleware(auth), requireAbility(ability)];
|
|
202
|
-
return this.
|
|
218
|
+
return this.wrapWeb(withMiddleware(...middleware)(handler));
|
|
203
219
|
}
|
|
204
220
|
wrapWebGlobalAdmin(handler) {
|
|
205
221
|
const auth = this.dependencies.container.resolve(CORE_AUTH_TOKEN);
|
|
206
222
|
const middleware = [createRequireWebAuthMiddleware(auth), createRequireGlobalAdminMiddleware()];
|
|
207
|
-
return this.
|
|
223
|
+
return this.wrapWeb(withMiddleware(...middleware)(handler));
|
|
208
224
|
}
|
|
209
225
|
wrapAuthenticated(handler) {
|
|
210
226
|
return this.wrap("authenticated", handler);
|
|
@@ -220,7 +236,7 @@ class HttpKernel {
|
|
|
220
236
|
return withMiddleware(...middleware)(handler);
|
|
221
237
|
}
|
|
222
238
|
wrapAbility(ability, handler) {
|
|
223
|
-
const abilityChecker = this.dependencies.container
|
|
239
|
+
const abilityChecker = resolveAbilityChecker(this.dependencies.container);
|
|
224
240
|
const requireAbility = createRequireAbilityMiddleware(abilityChecker);
|
|
225
241
|
const middleware = [...this.group("authenticated"), requireAbility(ability)];
|
|
226
242
|
return withMiddleware(...middleware)(handler);
|
|
@@ -818,68 +834,6 @@ var coreProviders = [
|
|
|
818
834
|
viewProvider
|
|
819
835
|
];
|
|
820
836
|
|
|
821
|
-
// ../../src/bootstrap/secretsGuard.ts
|
|
822
|
-
var DEFAULT_ADMIN_API_TOKEN = "workhub-admin-test-token";
|
|
823
|
-
var DEFAULT_MEMBER_API_TOKEN = "workhub-member-test-token";
|
|
824
|
-
var DEFAULT_SCIM_BEARER_TOKEN = "workhub-scim-test-token";
|
|
825
|
-
var DEFAULT_TOKENS = new Set([DEFAULT_ADMIN_API_TOKEN, DEFAULT_MEMBER_API_TOKEN]);
|
|
826
|
-
var DEFAULT_SCIM_TOKENS = new Set([DEFAULT_SCIM_BEARER_TOKEN]);
|
|
827
|
-
function isEnabled(value, defaultEnabled) {
|
|
828
|
-
if (value === undefined) {
|
|
829
|
-
return defaultEnabled;
|
|
830
|
-
}
|
|
831
|
-
return defaultEnabled ? value !== "false" : value === "true";
|
|
832
|
-
}
|
|
833
|
-
function assertProductionSecrets(env = process.env) {
|
|
834
|
-
const appEnv = env.APP_ENV ?? "local";
|
|
835
|
-
if (appEnv !== "production") {
|
|
836
|
-
return;
|
|
837
|
-
}
|
|
838
|
-
const adminToken = env.ADMIN_API_TOKEN ?? DEFAULT_ADMIN_API_TOKEN;
|
|
839
|
-
const memberToken = env.MEMBER_API_TOKEN ?? DEFAULT_MEMBER_API_TOKEN;
|
|
840
|
-
const scimToken = env.SCIM_BEARER_TOKEN ?? DEFAULT_SCIM_BEARER_TOKEN;
|
|
841
|
-
const encryptionEnabled = isEnabled(env.FEATURE_FIELD_ENCRYPTION, true);
|
|
842
|
-
const devHeadersEnabled = isEnabled(env.AUTH_DEV_HEADERS, true);
|
|
843
|
-
if (devHeadersEnabled) {
|
|
844
|
-
throw new Error("Production startup blocked: set AUTH_DEV_HEADERS=false to disable development auth headers.");
|
|
845
|
-
}
|
|
846
|
-
if (DEFAULT_TOKENS.has(adminToken) || DEFAULT_TOKENS.has(memberToken)) {
|
|
847
|
-
throw new Error("Production startup blocked: rotate ADMIN_API_TOKEN and MEMBER_API_TOKEN away from default WorkHub test values.");
|
|
848
|
-
}
|
|
849
|
-
if (DEFAULT_SCIM_TOKENS.has(scimToken)) {
|
|
850
|
-
throw new Error("Production startup blocked: rotate SCIM_BEARER_TOKEN away from default WorkHub test values.");
|
|
851
|
-
}
|
|
852
|
-
if (encryptionEnabled && !env.KMS_ENCRYPTION_KEY?.trim()) {
|
|
853
|
-
throw new Error("Production startup blocked: set KMS_ENCRYPTION_KEY when field encryption is enabled.");
|
|
854
|
-
}
|
|
855
|
-
if (!env.SIEM_EXPORT_URL?.trim() && isEnabled(env.FEATURE_SIEM_EXPORT, true)) {
|
|
856
|
-
console.warn("[secrets] SIEM_EXPORT_URL is not configured; audit logs remain database-only.");
|
|
857
|
-
}
|
|
858
|
-
if (isEnabled(env.FEATURE_BILLING, true) && !env.STRIPE_WEBHOOK_SECRET?.trim()) {
|
|
859
|
-
throw new Error("Production startup blocked: set STRIPE_WEBHOOK_SECRET when billing webhooks are enabled.");
|
|
860
|
-
}
|
|
861
|
-
const corsOrigins = (env.CORS_ALLOWED_ORIGINS ?? "*").split(",").map((origin) => origin.trim());
|
|
862
|
-
if (corsOrigins.includes("*")) {
|
|
863
|
-
throw new Error("Production startup blocked: set explicit CORS_ALLOWED_ORIGINS instead of wildcard.");
|
|
864
|
-
}
|
|
865
|
-
if (isEnabled(env.FEATURE_PUBLIC_READS, true)) {
|
|
866
|
-
throw new Error("Production startup blocked: set FEATURE_PUBLIC_READS=false for authenticated-only reads.");
|
|
867
|
-
}
|
|
868
|
-
if (!env.OAUTH_STATE_SECRET?.trim()) {
|
|
869
|
-
throw new Error("Production startup blocked: set OAUTH_STATE_SECRET for OAuth CSRF protection.");
|
|
870
|
-
}
|
|
871
|
-
if (!env.TOKEN_HASH_PEPPER?.trim()) {
|
|
872
|
-
throw new Error("Production startup blocked: set TOKEN_HASH_PEPPER for API token hashing.");
|
|
873
|
-
}
|
|
874
|
-
if (!env.API_TOKEN_DEFAULT_EXPIRY_DAYS?.trim()) {
|
|
875
|
-
throw new Error("Production startup blocked: set API_TOKEN_DEFAULT_EXPIRY_DAYS to enforce token rotation.");
|
|
876
|
-
}
|
|
877
|
-
const frontendMode = (env.FRONTEND_MODE ?? "api").trim();
|
|
878
|
-
if (frontendMode === "server-htmx" && !env.SESSION_SECRET?.trim()) {
|
|
879
|
-
throw new Error("Production startup blocked: set SESSION_SECRET when FRONTEND_MODE=server-htmx.");
|
|
880
|
-
}
|
|
881
|
-
}
|
|
882
|
-
|
|
883
837
|
// ../../src/bootstrap/context.ts
|
|
884
838
|
function collectProviders(modules = discoverModules()) {
|
|
885
839
|
return [...coreProviders, ...modules.flatMap((module) => module.providers ?? [])];
|
|
@@ -890,7 +844,6 @@ function runProviderPhase(providers, phase, context) {
|
|
|
890
844
|
}
|
|
891
845
|
}
|
|
892
846
|
function createAppContext() {
|
|
893
|
-
assertProductionSecrets();
|
|
894
847
|
const container = new ServiceContainer;
|
|
895
848
|
const config = new ConfigStore;
|
|
896
849
|
const dependencies = {
|
|
@@ -953,6 +906,101 @@ function mergeWebRoutes(dependencies, routes) {
|
|
|
953
906
|
function createAppDependencies() {
|
|
954
907
|
return createAppContext().dependencies;
|
|
955
908
|
}
|
|
909
|
+
// ../../src/bootstrap/health.ts
|
|
910
|
+
import { getBoundDatabaseConnection } from "@getstrata/core/database/boundConnection";
|
|
911
|
+
import { getDefaultDatabasePool } from "@getstrata/core/database/defaultConnection";
|
|
912
|
+
import { jsonResponse } from "@getstrata/core/http/response";
|
|
913
|
+
var {RedisClient } = globalThis.Bun;
|
|
914
|
+
function resolveRedisUrl(dependencies) {
|
|
915
|
+
if (!dependencies.container.has(CORE_CONFIG_TOKEN)) {
|
|
916
|
+
return process.env.REDIS_URL?.trim() || undefined;
|
|
917
|
+
}
|
|
918
|
+
const config = dependencies.container.resolve(CORE_CONFIG_TOKEN);
|
|
919
|
+
const redisUrl = config.get(REDIS_URL_CONFIG_KEY)?.trim();
|
|
920
|
+
return redisUrl || undefined;
|
|
921
|
+
}
|
|
922
|
+
function resolveHealthDatabase() {
|
|
923
|
+
const bound = getBoundDatabaseConnection();
|
|
924
|
+
if (bound) {
|
|
925
|
+
return bound;
|
|
926
|
+
}
|
|
927
|
+
try {
|
|
928
|
+
return getDefaultDatabasePool();
|
|
929
|
+
} catch {
|
|
930
|
+
return null;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
async function pingDatabaseClient(connection) {
|
|
934
|
+
try {
|
|
935
|
+
await connection.unsafe("SELECT 1");
|
|
936
|
+
return true;
|
|
937
|
+
} catch {
|
|
938
|
+
return false;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
async function checkDatabase() {
|
|
942
|
+
const connection = resolveHealthDatabase();
|
|
943
|
+
if (!connection) {
|
|
944
|
+
return false;
|
|
945
|
+
}
|
|
946
|
+
return await pingDatabaseClient(connection);
|
|
947
|
+
}
|
|
948
|
+
async function pingDatabase() {
|
|
949
|
+
return await checkDatabase();
|
|
950
|
+
}
|
|
951
|
+
async function checkRedis(redisUrl) {
|
|
952
|
+
try {
|
|
953
|
+
const client = new RedisClient(redisUrl);
|
|
954
|
+
const response = await client.ping();
|
|
955
|
+
return response === "PONG";
|
|
956
|
+
} catch {
|
|
957
|
+
return false;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
async function resolveExtraFields(extra) {
|
|
961
|
+
if (!extra) {
|
|
962
|
+
return {};
|
|
963
|
+
}
|
|
964
|
+
return typeof extra === "function" ? await extra() : extra;
|
|
965
|
+
}
|
|
966
|
+
async function collectDependencyChecks(dependencies) {
|
|
967
|
+
const checks = {
|
|
968
|
+
database: await checkDatabase() ? "ok" : "error"
|
|
969
|
+
};
|
|
970
|
+
const redisUrl = resolveRedisUrl(dependencies);
|
|
971
|
+
if (redisUrl) {
|
|
972
|
+
checks.redis = await checkRedis(redisUrl) ? "ok" : "error";
|
|
973
|
+
} else {
|
|
974
|
+
checks.redis = "skipped";
|
|
975
|
+
}
|
|
976
|
+
const ready = checks.database === "ok" && (checks.redis === "ok" || checks.redis === "skipped");
|
|
977
|
+
return { checks, ready };
|
|
978
|
+
}
|
|
979
|
+
function createHealthRoutes(dependencies, options = {}) {
|
|
980
|
+
return {
|
|
981
|
+
"/health": async () => {
|
|
982
|
+
const extra = await resolveExtraFields(options.extra);
|
|
983
|
+
if (!options.pingOnHealth) {
|
|
984
|
+
return jsonResponse({ status: "ok", ...extra });
|
|
985
|
+
}
|
|
986
|
+
const { checks, ready } = await collectDependencyChecks(dependencies);
|
|
987
|
+
return jsonResponse({
|
|
988
|
+
status: ready ? "ok" : "error",
|
|
989
|
+
checks,
|
|
990
|
+
...extra
|
|
991
|
+
}, { status: ready ? 200 : 503 });
|
|
992
|
+
},
|
|
993
|
+
"/ready": async () => {
|
|
994
|
+
const extra = await resolveExtraFields(options.extra);
|
|
995
|
+
const { checks, ready } = await collectDependencyChecks(dependencies);
|
|
996
|
+
return jsonResponse({
|
|
997
|
+
status: ready ? "ready" : "not_ready",
|
|
998
|
+
checks,
|
|
999
|
+
...extra
|
|
1000
|
+
}, { status: ready ? 200 : 503 });
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
956
1004
|
// ../../src/bootstrap/http/securedRouteModelBinding.ts
|
|
957
1005
|
import {
|
|
958
1006
|
securedBindRouteModel,
|
|
@@ -960,6 +1008,121 @@ import {
|
|
|
960
1008
|
} from "@getstrata/core/http/securedRouteModelBinding";
|
|
961
1009
|
// ../../src/bootstrap/membershipService.ts
|
|
962
1010
|
import { resolveMembershipService } from "@getstrata/core/auth/membershipService";
|
|
1011
|
+
// ../../src/bootstrap/secretsGuard.ts
|
|
1012
|
+
var DEFAULT_ADMIN_API_TOKEN = "workhub-admin-test-token";
|
|
1013
|
+
var DEFAULT_MEMBER_API_TOKEN = "workhub-member-test-token";
|
|
1014
|
+
var DEFAULT_SCIM_BEARER_TOKEN = "workhub-scim-test-token";
|
|
1015
|
+
var MIN_SESSION_SECRET_LENGTH = 32;
|
|
1016
|
+
var DEFAULT_TOKENS = new Set([DEFAULT_ADMIN_API_TOKEN, DEFAULT_MEMBER_API_TOKEN]);
|
|
1017
|
+
var DEFAULT_SCIM_TOKENS = new Set([DEFAULT_SCIM_BEARER_TOKEN]);
|
|
1018
|
+
function isEnabled(value, defaultEnabled) {
|
|
1019
|
+
if (value === undefined) {
|
|
1020
|
+
return defaultEnabled;
|
|
1021
|
+
}
|
|
1022
|
+
return defaultEnabled ? value !== "false" : value === "true";
|
|
1023
|
+
}
|
|
1024
|
+
function isTokenAuthEnabled(env) {
|
|
1025
|
+
return Boolean(env.ADMIN_API_TOKEN?.trim()) || Boolean(env.MEMBER_API_TOKEN?.trim()) || env.FEATURE_API_TOKENS === "true";
|
|
1026
|
+
}
|
|
1027
|
+
function isOAuthEnabled(env) {
|
|
1028
|
+
return isEnabled(env.FEATURE_OAUTH, false) || isEnabled(env.FEATURE_SAML, false) || Boolean(env.GITHUB_CLIENT_ID?.trim()) || Boolean(env.OIDC_ISSUER?.trim()) || Boolean(env.SAML_LOGIN_URL?.trim());
|
|
1029
|
+
}
|
|
1030
|
+
function isCorsConfigured(env) {
|
|
1031
|
+
return env.CORS_ALLOWED_ORIGINS !== undefined;
|
|
1032
|
+
}
|
|
1033
|
+
function assertAuthDevHeadersDisabled(env) {
|
|
1034
|
+
if (isEnabled(env.AUTH_DEV_HEADERS, true)) {
|
|
1035
|
+
throw new Error("Production startup blocked: set AUTH_DEV_HEADERS=false to disable development auth headers.");
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
function assertSessionSecret(env) {
|
|
1039
|
+
const secret = env.SESSION_SECRET?.trim() ?? "";
|
|
1040
|
+
if (secret.length < MIN_SESSION_SECRET_LENGTH) {
|
|
1041
|
+
throw new Error("Production startup blocked: set SESSION_SECRET when FRONTEND_MODE=server-htmx (32+ characters).");
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
function assertWorkHubProductionSecrets(env) {
|
|
1045
|
+
const adminToken = env.ADMIN_API_TOKEN ?? DEFAULT_ADMIN_API_TOKEN;
|
|
1046
|
+
const memberToken = env.MEMBER_API_TOKEN ?? DEFAULT_MEMBER_API_TOKEN;
|
|
1047
|
+
const scimToken = env.SCIM_BEARER_TOKEN ?? DEFAULT_SCIM_BEARER_TOKEN;
|
|
1048
|
+
const encryptionEnabled = isEnabled(env.FEATURE_FIELD_ENCRYPTION, true);
|
|
1049
|
+
if (DEFAULT_TOKENS.has(adminToken) || DEFAULT_TOKENS.has(memberToken)) {
|
|
1050
|
+
throw new Error("Production startup blocked: rotate ADMIN_API_TOKEN and MEMBER_API_TOKEN away from default WorkHub test values.");
|
|
1051
|
+
}
|
|
1052
|
+
if (DEFAULT_SCIM_TOKENS.has(scimToken)) {
|
|
1053
|
+
throw new Error("Production startup blocked: rotate SCIM_BEARER_TOKEN away from default WorkHub test values.");
|
|
1054
|
+
}
|
|
1055
|
+
if (encryptionEnabled && !env.KMS_ENCRYPTION_KEY?.trim()) {
|
|
1056
|
+
throw new Error("Production startup blocked: set KMS_ENCRYPTION_KEY when field encryption is enabled.");
|
|
1057
|
+
}
|
|
1058
|
+
if (!env.SIEM_EXPORT_URL?.trim() && isEnabled(env.FEATURE_SIEM_EXPORT, true)) {
|
|
1059
|
+
console.warn("[secrets] SIEM_EXPORT_URL is not configured; audit logs remain database-only.");
|
|
1060
|
+
}
|
|
1061
|
+
if (isEnabled(env.FEATURE_BILLING, true) && !env.STRIPE_WEBHOOK_SECRET?.trim()) {
|
|
1062
|
+
throw new Error("Production startup blocked: set STRIPE_WEBHOOK_SECRET when billing webhooks are enabled.");
|
|
1063
|
+
}
|
|
1064
|
+
const corsOrigins = (env.CORS_ALLOWED_ORIGINS ?? "*").split(",").map((origin) => origin.trim());
|
|
1065
|
+
if (corsOrigins.includes("*")) {
|
|
1066
|
+
throw new Error("Production startup blocked: set explicit CORS_ALLOWED_ORIGINS instead of wildcard.");
|
|
1067
|
+
}
|
|
1068
|
+
if (isEnabled(env.FEATURE_PUBLIC_READS, true)) {
|
|
1069
|
+
throw new Error("Production startup blocked: set FEATURE_PUBLIC_READS=false for authenticated-only reads.");
|
|
1070
|
+
}
|
|
1071
|
+
if (!env.OAUTH_STATE_SECRET?.trim()) {
|
|
1072
|
+
throw new Error("Production startup blocked: set OAUTH_STATE_SECRET for OAuth CSRF protection.");
|
|
1073
|
+
}
|
|
1074
|
+
if (!env.TOKEN_HASH_PEPPER?.trim()) {
|
|
1075
|
+
throw new Error("Production startup blocked: set TOKEN_HASH_PEPPER for API token hashing.");
|
|
1076
|
+
}
|
|
1077
|
+
if (!env.API_TOKEN_DEFAULT_EXPIRY_DAYS?.trim()) {
|
|
1078
|
+
throw new Error("Production startup blocked: set API_TOKEN_DEFAULT_EXPIRY_DAYS to enforce token rotation.");
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
function assertSiblingProductionSecrets(env) {
|
|
1082
|
+
if (isEnabled(env.FEATURE_SCIM, false)) {
|
|
1083
|
+
const scimToken = env.SCIM_BEARER_TOKEN ?? DEFAULT_SCIM_BEARER_TOKEN;
|
|
1084
|
+
if (DEFAULT_SCIM_TOKENS.has(scimToken) || !env.SCIM_BEARER_TOKEN?.trim()) {
|
|
1085
|
+
throw new Error("Production startup blocked: rotate SCIM_BEARER_TOKEN away from default WorkHub test values.");
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
if (isEnabled(env.FEATURE_FIELD_ENCRYPTION, false) && !env.KMS_ENCRYPTION_KEY?.trim()) {
|
|
1089
|
+
throw new Error("Production startup blocked: set KMS_ENCRYPTION_KEY when field encryption is enabled.");
|
|
1090
|
+
}
|
|
1091
|
+
if (isEnabled(env.FEATURE_BILLING, false) && !env.STRIPE_WEBHOOK_SECRET?.trim()) {
|
|
1092
|
+
throw new Error("Production startup blocked: set STRIPE_WEBHOOK_SECRET when billing webhooks are enabled.");
|
|
1093
|
+
}
|
|
1094
|
+
if (isOAuthEnabled(env) && !env.OAUTH_STATE_SECRET?.trim()) {
|
|
1095
|
+
throw new Error("Production startup blocked: set OAUTH_STATE_SECRET for OAuth CSRF protection.");
|
|
1096
|
+
}
|
|
1097
|
+
if (isCorsConfigured(env)) {
|
|
1098
|
+
const corsOrigins = (env.CORS_ALLOWED_ORIGINS ?? "*").split(",").map((origin) => origin.trim());
|
|
1099
|
+
if (corsOrigins.includes("*")) {
|
|
1100
|
+
throw new Error("Production startup blocked: set explicit CORS_ALLOWED_ORIGINS instead of wildcard.");
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
if (isEnabled(env.FEATURE_PUBLIC_READS, false)) {
|
|
1104
|
+
throw new Error("Production startup blocked: set FEATURE_PUBLIC_READS=false for authenticated-only reads.");
|
|
1105
|
+
}
|
|
1106
|
+
if (!env.SIEM_EXPORT_URL?.trim() && isEnabled(env.FEATURE_SIEM_EXPORT, false)) {
|
|
1107
|
+
console.warn("[secrets] SIEM_EXPORT_URL is not configured; audit logs remain database-only.");
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
function assertProductionSecrets(env = process.env) {
|
|
1111
|
+
const appEnv = env.APP_ENV ?? "local";
|
|
1112
|
+
if (appEnv !== "production") {
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
assertAuthDevHeadersDisabled(env);
|
|
1116
|
+
if (isTokenAuthEnabled(env)) {
|
|
1117
|
+
assertWorkHubProductionSecrets(env);
|
|
1118
|
+
} else {
|
|
1119
|
+
assertSiblingProductionSecrets(env);
|
|
1120
|
+
}
|
|
1121
|
+
const frontendMode = (env.FRONTEND_MODE ?? "api").trim();
|
|
1122
|
+
if (frontendMode === "server-htmx") {
|
|
1123
|
+
assertSessionSecret(env);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
963
1126
|
// ../../src/bootstrap/web/forms.ts
|
|
964
1127
|
import {
|
|
965
1128
|
createCsrfProtection
|
|
@@ -993,7 +1156,7 @@ async function parseFormBody(request) {
|
|
|
993
1156
|
return { fields, files };
|
|
994
1157
|
}
|
|
995
1158
|
// ../../src/bootstrap/web/routing.ts
|
|
996
|
-
import { withErrorHandling } from "@getstrata/core/http/response";
|
|
1159
|
+
import { withErrorHandling as withErrorHandling2 } from "@getstrata/core/http/response";
|
|
997
1160
|
function routeParams(request) {
|
|
998
1161
|
const normalized = {};
|
|
999
1162
|
const raw = request.params;
|
|
@@ -1015,14 +1178,14 @@ function toRouteRequest(request) {
|
|
|
1015
1178
|
return request;
|
|
1016
1179
|
}
|
|
1017
1180
|
function wrapSecuredRouteModelByKey(param, resolver, authorization, handler) {
|
|
1018
|
-
const bound =
|
|
1181
|
+
const bound = withErrorHandling2(securedBindRouteModelByKey(param, resolver, authorization, handler));
|
|
1019
1182
|
return async (request) => bound(toRouteRequest(request));
|
|
1020
1183
|
}
|
|
1021
1184
|
function wrapWebLogin(kernel, handler, onThrottled) {
|
|
1022
|
-
return wrapWebThrottle(kernel, "login", handler, onThrottled);
|
|
1185
|
+
return kernel.wrapWeb(wrapWebThrottle(kernel, "login", handler, onThrottled));
|
|
1023
1186
|
}
|
|
1024
1187
|
function wrapWebRegister(kernel, handler, onThrottled) {
|
|
1025
|
-
return wrapWebThrottle(kernel, "register", handler, onThrottled);
|
|
1188
|
+
return kernel.wrapWeb(wrapWebThrottle(kernel, "register", handler, onThrottled));
|
|
1026
1189
|
}
|
|
1027
1190
|
function wrapWebThrottle(kernel, scope, handler, onThrottled) {
|
|
1028
1191
|
const throttled = scope === "login" ? kernel.wrapLogin(handler) : kernel.wrapRegister(handler);
|
|
@@ -1091,15 +1254,40 @@ function createWebServer(options) {
|
|
|
1091
1254
|
}
|
|
1092
1255
|
// ../../src/bootstrap/web/session.ts
|
|
1093
1256
|
import { createHash, randomBytes } from "crypto";
|
|
1257
|
+
import { AuthManager as AuthManager2 } from "@getstrata/core/auth/guard";
|
|
1258
|
+
import { getBoundDatabaseConnection as getBoundDatabaseConnection2 } from "@getstrata/core/database/boundConnection";
|
|
1259
|
+
import { getDefaultDatabasePool as getDefaultDatabasePool2 } from "@getstrata/core/database/defaultConnection";
|
|
1094
1260
|
import { readRequestCookie } from "@getstrata/core/http/cookies";
|
|
1261
|
+
function isSqlClient(value) {
|
|
1262
|
+
return typeof value.unsafe === "function";
|
|
1263
|
+
}
|
|
1264
|
+
function resolveSql(source) {
|
|
1265
|
+
if (isSqlClient(source)) {
|
|
1266
|
+
return source;
|
|
1267
|
+
}
|
|
1268
|
+
return source();
|
|
1269
|
+
}
|
|
1270
|
+
function defaultSessionSql() {
|
|
1271
|
+
const bound = getBoundDatabaseConnection2();
|
|
1272
|
+
if (bound) {
|
|
1273
|
+
return bound;
|
|
1274
|
+
}
|
|
1275
|
+
return getDefaultDatabasePool2();
|
|
1276
|
+
}
|
|
1277
|
+
function defaultMapSessionUser(user) {
|
|
1278
|
+
return {
|
|
1279
|
+
id: user.id,
|
|
1280
|
+
role: user.is_admin ? "admin" : "member"
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1095
1283
|
|
|
1096
1284
|
class CookieSessionStore {
|
|
1097
|
-
|
|
1285
|
+
sqlSource;
|
|
1098
1286
|
secret;
|
|
1099
1287
|
cookieName;
|
|
1100
1288
|
maxAgeSeconds;
|
|
1101
|
-
constructor(
|
|
1102
|
-
this.
|
|
1289
|
+
constructor(sqlSource, secret, cookieName = "strata_session", maxAgeSeconds = 60 * 60 * 24 * 14) {
|
|
1290
|
+
this.sqlSource = sqlSource;
|
|
1103
1291
|
this.secret = secret;
|
|
1104
1292
|
this.cookieName = cookieName;
|
|
1105
1293
|
this.maxAgeSeconds = maxAgeSeconds;
|
|
@@ -1117,10 +1305,13 @@ class CookieSessionStore {
|
|
|
1117
1305
|
}
|
|
1118
1306
|
return header.includes("Secure") ? header : `${header}; Secure`;
|
|
1119
1307
|
}
|
|
1308
|
+
sql() {
|
|
1309
|
+
return resolveSql(this.sqlSource);
|
|
1310
|
+
}
|
|
1120
1311
|
async create(user) {
|
|
1121
1312
|
const id = randomBytes(32).toString("hex");
|
|
1122
1313
|
const expires = new Date(Date.now() + this.maxAgeSeconds * 1000);
|
|
1123
|
-
await this.sql.unsafe(`INSERT INTO sessions (id, user_id, expires_at) VALUES ($1, $2, $3)`, [
|
|
1314
|
+
await this.sql().unsafe(`INSERT INTO sessions (id, user_id, expires_at) VALUES ($1, $2, $3)`, [
|
|
1124
1315
|
id,
|
|
1125
1316
|
user.id,
|
|
1126
1317
|
expires
|
|
@@ -1128,7 +1319,7 @@ class CookieSessionStore {
|
|
|
1128
1319
|
return id;
|
|
1129
1320
|
}
|
|
1130
1321
|
async destroy(sessionId) {
|
|
1131
|
-
await this.sql.unsafe(`DELETE FROM sessions WHERE id = $1`, [sessionId]);
|
|
1322
|
+
await this.sql().unsafe(`DELETE FROM sessions WHERE id = $1`, [sessionId]);
|
|
1132
1323
|
}
|
|
1133
1324
|
async read(request) {
|
|
1134
1325
|
const cookie = readRequestCookie(request, this.cookieName);
|
|
@@ -1139,7 +1330,7 @@ class CookieSessionStore {
|
|
|
1139
1330
|
if (!sessionId || !signature || signature !== this.sign(sessionId)) {
|
|
1140
1331
|
return null;
|
|
1141
1332
|
}
|
|
1142
|
-
const rows = await this.sql.unsafe(`SELECT s.id, s.user_id, s.expires_at, u.name, u.email, u.learn_subscriber,
|
|
1333
|
+
const rows = await this.sql().unsafe(`SELECT s.id, s.user_id, s.expires_at, u.name, u.email, u.learn_subscriber,
|
|
1143
1334
|
COALESCE(u.is_admin, false) AS is_admin
|
|
1144
1335
|
FROM sessions s
|
|
1145
1336
|
INNER JOIN users u ON u.id = s.user_id
|
|
@@ -1159,19 +1350,42 @@ class CookieSessionStore {
|
|
|
1159
1350
|
return createHash("sha256").update(`${value}.${this.secret}`).digest("hex").slice(0, 32);
|
|
1160
1351
|
}
|
|
1161
1352
|
}
|
|
1353
|
+
|
|
1354
|
+
class CookieSessionGuard {
|
|
1355
|
+
store;
|
|
1356
|
+
mapUser;
|
|
1357
|
+
constructor(store, mapUser = defaultMapSessionUser) {
|
|
1358
|
+
this.store = store;
|
|
1359
|
+
this.mapUser = mapUser;
|
|
1360
|
+
}
|
|
1361
|
+
async resolve(request) {
|
|
1362
|
+
const user = await this.store.read(request);
|
|
1363
|
+
if (!user) {
|
|
1364
|
+
return null;
|
|
1365
|
+
}
|
|
1366
|
+
return this.mapUser(user);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
function createCookieSessionAuthManager(options = {}) {
|
|
1370
|
+
const store = options.store ?? new CookieSessionStore(options.sql ?? defaultSessionSql, options.secret ?? process.env.SESSION_SECRET?.trim() ?? "", options.cookieName, options.maxAgeSeconds);
|
|
1371
|
+
return new AuthManager2(new CookieSessionGuard(store, options.mapUser ?? defaultMapSessionUser));
|
|
1372
|
+
}
|
|
1162
1373
|
// ../../src/bootstrap/web/slug.ts
|
|
1163
1374
|
function slugify(value) {
|
|
1164
1375
|
return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
|
|
1165
1376
|
}
|
|
1166
1377
|
export {
|
|
1167
1378
|
APP_PORT_CONFIG_KEY,
|
|
1379
|
+
CORE_ABILITY_CHECKER_TOKEN,
|
|
1168
1380
|
CORE_AUTH_TOKEN,
|
|
1381
|
+
CORE_AUTH_USER_DIRECTORY_TOKEN,
|
|
1169
1382
|
CORE_CACHE_TOKEN,
|
|
1170
1383
|
CORE_CONFIG_TOKEN,
|
|
1171
1384
|
CORE_EVENT_BUS_TOKEN,
|
|
1172
1385
|
CORE_POLICY_GATE_TOKEN,
|
|
1173
1386
|
CORE_QUEUE_TOKEN,
|
|
1174
1387
|
CORE_TOKEN_SERVICE_TOKEN,
|
|
1388
|
+
CookieSessionGuard,
|
|
1175
1389
|
CookieSessionStore,
|
|
1176
1390
|
DATABASE_URL_CONFIG_KEY,
|
|
1177
1391
|
DEFAULT_APP_PORT,
|
|
@@ -1185,12 +1399,16 @@ export {
|
|
|
1185
1399
|
buildModuleRoutes,
|
|
1186
1400
|
buildWebModuleRoutes,
|
|
1187
1401
|
cacheTagsForModelWrite,
|
|
1402
|
+
checkDatabase,
|
|
1403
|
+
checkRedis,
|
|
1188
1404
|
collectProviders,
|
|
1189
1405
|
configureModulesDirectory,
|
|
1190
1406
|
coreProviders,
|
|
1191
1407
|
createAppContext,
|
|
1192
1408
|
createAppDependencies,
|
|
1409
|
+
createCookieSessionAuthManager,
|
|
1193
1410
|
createCsrfProtection,
|
|
1411
|
+
createHealthRoutes,
|
|
1194
1412
|
createHttpKernel,
|
|
1195
1413
|
createRouteKernel,
|
|
1196
1414
|
createWebRoutes,
|
|
@@ -1201,6 +1419,7 @@ export {
|
|
|
1201
1419
|
getRequiredDependency,
|
|
1202
1420
|
mergeWebRoutes,
|
|
1203
1421
|
parseFormBody,
|
|
1422
|
+
pingDatabase,
|
|
1204
1423
|
prefixRouteMap,
|
|
1205
1424
|
registerDefaultJobs,
|
|
1206
1425
|
resolveApplicationAuth,
|