@getstrata/core 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.
Files changed (90) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/core/auth/authContext.d.ts +5 -2
  3. package/dist/core/auth/emailVerification.d.ts +1 -1
  4. package/dist/core/auth/guard.d.ts +5 -1
  5. package/dist/core/auth/jwtGuard.d.ts +6 -2
  6. package/dist/core/auth/oauth/oidcIdToken.d.ts +19 -0
  7. package/dist/core/auth/oauth/oidcProvider.d.ts +16 -4
  8. package/dist/core/auth/oauth/samlProvider.d.ts +7 -4
  9. package/dist/core/auth/oneTimeToken.d.ts +19 -0
  10. package/dist/core/auth/passwordLogin.d.ts +20 -0
  11. package/dist/core/auth/saml/samlAssertionReplay.d.ts +20 -0
  12. package/dist/core/auth/saml/samlServiceProvider.d.ts +51 -0
  13. package/dist/core/contracts/authUserDirectory.d.ts +2 -3
  14. package/dist/core/database/errors.d.ts +2 -1
  15. package/dist/core/http/response.d.ts +3 -1
  16. package/dist/core/mail/mailer.d.ts +6 -1
  17. package/dist/core/security/oauthState.d.ts +6 -2
  18. package/dist/core/security/safeFetch.d.ts +1 -0
  19. package/dist/core/security/safePath.d.ts +4 -0
  20. package/dist/core/security/safeUrl.d.ts +14 -1
  21. package/dist/core/security/totp.d.ts +1 -1
  22. package/dist/core/tenant/databaseTenantContext.d.ts +2 -1
  23. package/dist/core/tenant/enableTenantRls.d.ts +6 -0
  24. package/dist/core/tenant/postgresAppRole.d.ts +75 -0
  25. package/dist/entries/audit/exportAuditLogs.js +223 -40
  26. package/dist/entries/auth/accessControl.js +1 -0
  27. package/dist/entries/auth/basicAuthGuard.js +638 -17
  28. package/dist/entries/auth/emailVerification.js +1 -1
  29. package/dist/entries/auth/intendedUrlCookie.js +32 -2
  30. package/dist/entries/auth/jwt.js +1 -1
  31. package/dist/entries/auth/jwtGuard.js +192 -3
  32. package/dist/entries/auth/membershipMiddleware.js +1 -0
  33. package/dist/entries/auth/membershipScope.js +1 -0
  34. package/dist/entries/auth/membershipService.js +1 -0
  35. package/dist/entries/auth/oauth/oidcProvider.js +542 -20
  36. package/dist/entries/auth/oauth/providers.js +313 -5
  37. package/dist/entries/auth/oauth/samlProvider.js +552 -74
  38. package/dist/entries/auth/oneTimeToken.js +362 -0
  39. package/dist/entries/auth/password.js +1 -1
  40. package/dist/entries/auth/passwordLogin.js +548 -0
  41. package/dist/entries/auth/policy.js +1 -0
  42. package/dist/entries/auth/saml/samlServiceProvider.js +571 -0
  43. package/dist/entries/auth/scimAuthMiddleware.js +10 -3
  44. package/dist/entries/auth/sessionCookie.js +1 -1
  45. package/dist/entries/auth/sessionGuard.js +1 -1
  46. package/dist/entries/auth/tokenHash.js +2 -6
  47. package/dist/entries/crypto/fieldEncryption.js +1 -1
  48. package/dist/entries/crypto/mfaSecret.js +31 -5
  49. package/dist/entries/database/errors.js +29 -12
  50. package/dist/entries/database/model.js +9 -9
  51. package/dist/entries/facades.js +72 -9
  52. package/dist/entries/http/authMiddleware.js +7 -14
  53. package/dist/entries/http/corsMiddleware.js +69 -2
  54. package/dist/entries/http/csrfMiddleware.js +33 -43
  55. package/dist/entries/http/csrfToken.js +2 -2
  56. package/dist/entries/http/flashMiddleware.js +4 -2
  57. package/dist/entries/http/flashSession.js +4 -2
  58. package/dist/entries/http/formRequest.js +2 -2
  59. package/dist/entries/http/memoryThrottleMiddleware.js +63 -0
  60. package/dist/entries/http/pagination.js +2 -2
  61. package/dist/entries/http/requirePasswordConfirmMiddleware.js +36 -3
  62. package/dist/entries/http/requireWebAuthMiddleware.js +32 -2
  63. package/dist/entries/http/response.js +101 -45
  64. package/dist/entries/http/routeModelBinding.js +2 -2
  65. package/dist/entries/http/safeInternalPath.js +32 -2
  66. package/dist/entries/http/scimThrottleMiddleware.js +7 -1
  67. package/dist/entries/http/securedRouteModelBinding.js +2 -2
  68. package/dist/entries/http/securityHeadersMiddleware.js +3 -64
  69. package/dist/entries/http/signedUrl.js +6 -5
  70. package/dist/entries/http/throttleMiddleware.js +63 -0
  71. package/dist/entries/http/validation.js +2 -2
  72. package/dist/entries/http/webFormRequest.js +2 -2
  73. package/dist/entries/jobs/exportAuditLogsJob.js +223 -40
  74. package/dist/entries/mail/mailer.js +54 -8
  75. package/dist/entries/media/imageTransform.js +12 -1
  76. package/dist/entries/openapi/generator.js +0 -7
  77. package/dist/entries/security/oauthState.js +30 -39
  78. package/dist/entries/security/publicReads.js +1 -1
  79. package/dist/entries/security/recoveryCodes.js +2 -2
  80. package/dist/entries/security/safeFetch.js +195 -94
  81. package/dist/entries/security/safePath.js +25 -0
  82. package/dist/entries/security/scimTenantTokens.js +10 -3
  83. package/dist/entries/security/totp.js +16 -4
  84. package/dist/entries/storage/storage.js +24 -2
  85. package/dist/entries/tenant/databaseTenantContext.js +34 -12
  86. package/dist/entries/tenant/enableTenantRls.js +385 -0
  87. package/dist/entries/tracing/tracingMiddleware.js +308 -5
  88. package/dist/framework/public-api.d.ts +10 -3
  89. package/dist/index.js +2767 -1515
  90. package/package.json +32 -3
@@ -156,6 +156,69 @@ function readSpaPrefix() {
156
156
  // ../../src/core/view/webErrorView.ts
157
157
  import { currentRequestMeta as currentRequestMeta2 } from "@getstrata/core/http/requestMetaContext";
158
158
 
159
+ // ../../src/core/http/safeInternalPath.ts
160
+ var NESTED_REDIRECT_KEYS = new Set(["redirect", "next", "return", "returnTo", "return_to"]);
161
+ function looksLikeExternalTarget(value) {
162
+ const trimmed = value.trim();
163
+ if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
164
+ return true;
165
+ }
166
+ if (trimmed.includes("://") || trimmed.includes(":/") || trimmed.includes(":\\")) {
167
+ return true;
168
+ }
169
+ try {
170
+ const decoded = decodeURIComponent(trimmed);
171
+ if (decoded.startsWith("//") || decoded.includes("\\") || /https?:/i.test(decoded)) {
172
+ return true;
173
+ }
174
+ } catch {
175
+ return true;
176
+ }
177
+ return false;
178
+ }
179
+ function sanitizeNestedQuery(search) {
180
+ if (!search) {
181
+ return "";
182
+ }
183
+ const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
184
+ const next = new URLSearchParams;
185
+ for (const [key, value] of params.entries()) {
186
+ if (NESTED_REDIRECT_KEYS.has(key)) {
187
+ const sanitized = sanitizeInternalPath(value, "/");
188
+ next.set(key, sanitized);
189
+ continue;
190
+ }
191
+ if (looksLikeExternalTarget(value) && value.includes("://")) {
192
+ continue;
193
+ }
194
+ next.set(key, value);
195
+ }
196
+ const serialized = next.toString();
197
+ return serialized ? `?${serialized}` : "";
198
+ }
199
+ function sanitizeInternalPath(raw, fallback = "/") {
200
+ const trimmed = raw.trim();
201
+ if (looksLikeExternalTarget(trimmed)) {
202
+ return fallback;
203
+ }
204
+ const queryIndex = trimmed.indexOf("?");
205
+ const hashIndex = trimmed.indexOf("#");
206
+ const end = [queryIndex, hashIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0];
207
+ const pathname = end === undefined ? trimmed : trimmed.slice(0, end);
208
+ if (looksLikeExternalTarget(pathname)) {
209
+ return fallback;
210
+ }
211
+ const search = queryIndex >= 0 ? trimmed.slice(queryIndex, hashIndex >= 0 ? hashIndex : undefined) : "";
212
+ return `${pathname}${sanitizeNestedQuery(search)}`;
213
+ }
214
+ function safeInternalRedirectPath(request, fallback = "/") {
215
+ const url = new URL(request.url);
216
+ return sanitizeInternalPath(`${url.pathname}${url.search}`, fallback);
217
+ }
218
+ function loginRedirectLocation(request) {
219
+ return `/login?redirect=${encodeURIComponent(safeInternalRedirectPath(request))}`;
220
+ }
221
+
159
222
  // ../../src/core/view/htmlResponse.ts
160
223
  function htmlResponse(html, init = {}) {
161
224
  return new Response(html, {
@@ -16,7 +16,7 @@ function buildPaginationMeta(input) {
16
16
  // ../../src/core/http/validation.ts
17
17
  import { currentAuthUser } from "@getstrata/core/auth/authContext";
18
18
  import { BadRequestError } from "@getstrata/core/errors/http";
19
- import { currentTenantId } from "@getstrata/core/tenant/tenantContext";
19
+ import { currentTenant } from "@getstrata/core/tenant/tenantContext";
20
20
  function buildRequestCacheKey(fallbackPath, request) {
21
21
  if (!request) {
22
22
  return fallbackPath;
@@ -24,7 +24,7 @@ function buildRequestCacheKey(fallbackPath, request) {
24
24
  const url = new URL(request.url);
25
25
  const user = currentAuthUser();
26
26
  const authScope = user ? `u:${user.id}` : "guest";
27
- const tenantScope = `t:${currentTenantId()}`;
27
+ const tenantScope = `t:${currentTenant()?.id ?? "none"}`;
28
28
  return `${authScope}|${tenantScope}|${url.pathname}${url.search}`;
29
29
  }
30
30
  function getQueryParams(request) {
@@ -31,6 +31,7 @@ function requestPrefersJson(request) {
31
31
  }
32
32
 
33
33
  // ../../src/core/http/safeInternalPath.ts
34
+ var NESTED_REDIRECT_KEYS = new Set(["redirect", "next", "return", "returnTo", "return_to"]);
34
35
  function looksLikeExternalTarget(value) {
35
36
  const trimmed = value.trim();
36
37
  if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
@@ -49,11 +50,40 @@ function looksLikeExternalTarget(value) {
49
50
  }
50
51
  return false;
51
52
  }
53
+ function sanitizeNestedQuery(search) {
54
+ if (!search) {
55
+ return "";
56
+ }
57
+ const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
58
+ const next = new URLSearchParams;
59
+ for (const [key, value] of params.entries()) {
60
+ if (NESTED_REDIRECT_KEYS.has(key)) {
61
+ const sanitized = sanitizeInternalPath(value, "/");
62
+ next.set(key, sanitized);
63
+ continue;
64
+ }
65
+ if (looksLikeExternalTarget(value) && value.includes("://")) {
66
+ continue;
67
+ }
68
+ next.set(key, value);
69
+ }
70
+ const serialized = next.toString();
71
+ return serialized ? `?${serialized}` : "";
72
+ }
52
73
  function sanitizeInternalPath(raw, fallback = "/") {
53
- if (looksLikeExternalTarget(raw)) {
74
+ const trimmed = raw.trim();
75
+ if (looksLikeExternalTarget(trimmed)) {
76
+ return fallback;
77
+ }
78
+ const queryIndex = trimmed.indexOf("?");
79
+ const hashIndex = trimmed.indexOf("#");
80
+ const end = [queryIndex, hashIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0];
81
+ const pathname = end === undefined ? trimmed : trimmed.slice(0, end);
82
+ if (looksLikeExternalTarget(pathname)) {
54
83
  return fallback;
55
84
  }
56
- return raw;
85
+ const search = queryIndex >= 0 ? trimmed.slice(queryIndex, hashIndex >= 0 ? hashIndex : undefined) : "";
86
+ return `${pathname}${sanitizeNestedQuery(search)}`;
57
87
  }
58
88
  function safeInternalRedirectPath(request, fallback = "/") {
59
89
  const url = new URL(request.url);
@@ -70,7 +100,10 @@ function createRequirePasswordConfirmMiddleware() {
70
100
  return async (request, next) => {
71
101
  const user = currentAuthUser();
72
102
  if (!user) {
73
- return await next();
103
+ if (requestPrefersJson(request)) {
104
+ return Response.json({ error: "Authentication required." }, { status: 401 });
105
+ }
106
+ return Response.redirect("/login", 302);
74
107
  }
75
108
  const userId = resolveUserId(user);
76
109
  if (hasFreshPasswordConfirmation(request, userId)) {
@@ -31,6 +31,7 @@ function requestPrefersJson(request) {
31
31
  }
32
32
 
33
33
  // ../../src/core/http/safeInternalPath.ts
34
+ var NESTED_REDIRECT_KEYS = new Set(["redirect", "next", "return", "returnTo", "return_to"]);
34
35
  function looksLikeExternalTarget(value) {
35
36
  const trimmed = value.trim();
36
37
  if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
@@ -49,11 +50,40 @@ function looksLikeExternalTarget(value) {
49
50
  }
50
51
  return false;
51
52
  }
53
+ function sanitizeNestedQuery(search) {
54
+ if (!search) {
55
+ return "";
56
+ }
57
+ const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
58
+ const next = new URLSearchParams;
59
+ for (const [key, value] of params.entries()) {
60
+ if (NESTED_REDIRECT_KEYS.has(key)) {
61
+ const sanitized = sanitizeInternalPath(value, "/");
62
+ next.set(key, sanitized);
63
+ continue;
64
+ }
65
+ if (looksLikeExternalTarget(value) && value.includes("://")) {
66
+ continue;
67
+ }
68
+ next.set(key, value);
69
+ }
70
+ const serialized = next.toString();
71
+ return serialized ? `?${serialized}` : "";
72
+ }
52
73
  function sanitizeInternalPath(raw, fallback = "/") {
53
- if (looksLikeExternalTarget(raw)) {
74
+ const trimmed = raw.trim();
75
+ if (looksLikeExternalTarget(trimmed)) {
76
+ return fallback;
77
+ }
78
+ const queryIndex = trimmed.indexOf("?");
79
+ const hashIndex = trimmed.indexOf("#");
80
+ const end = [queryIndex, hashIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0];
81
+ const pathname = end === undefined ? trimmed : trimmed.slice(0, end);
82
+ if (looksLikeExternalTarget(pathname)) {
54
83
  return fallback;
55
84
  }
56
- return raw;
85
+ const search = queryIndex >= 0 ? trimmed.slice(queryIndex, hashIndex >= 0 ? hashIndex : undefined) : "";
86
+ return `${pathname}${sanitizeNestedQuery(search)}`;
57
87
  }
58
88
  function safeInternalRedirectPath(request, fallback = "/") {
59
89
  const url = new URL(request.url);
@@ -78,21 +78,25 @@ function mapDatabaseError(error) {
78
78
  const sqlState = getPostgresSqlState(error);
79
79
  switch (sqlState) {
80
80
  case "23505":
81
- return new ConflictError(error.detail ?? "A record with these values already exists.", {
82
- constraint: error.constraint
83
- });
81
+ if (error.detail) {
82
+ console.error("[database] unique violation", error.detail);
83
+ }
84
+ return new ConflictError("A record with these values already exists.");
84
85
  case "23503":
85
- return new UnprocessableEntityError(error.detail ?? "Referenced record does not exist.", {
86
- constraint: error.constraint
87
- });
86
+ if (error.detail) {
87
+ console.error("[database] foreign-key violation", error.detail);
88
+ }
89
+ return new UnprocessableEntityError("Referenced record does not exist.");
88
90
  case "23502":
89
- return new BadRequestError(error.detail ?? "Required field is missing.", {
90
- constraint: error.constraint
91
- });
91
+ if (error.detail) {
92
+ console.error("[database] not-null violation", error.detail);
93
+ }
94
+ return new BadRequestError("Required field is missing.");
92
95
  case "23514":
93
- return new BadRequestError(error.detail ?? "Value violates a database constraint.", {
94
- constraint: error.constraint
95
- });
96
+ if (error.detail) {
97
+ console.error("[database] check violation", error.detail);
98
+ }
99
+ return new BadRequestError("Value violates a database constraint.");
96
100
  default:
97
101
  return new InternalServerError("Database operation failed.");
98
102
  }
@@ -104,6 +108,18 @@ async function withDatabaseErrorHandling(operation) {
104
108
  throw mapDatabaseError(error);
105
109
  }
106
110
  }
111
+ function isUniqueConstraintError(error) {
112
+ if (!isPostgresError(error)) {
113
+ return false;
114
+ }
115
+ if (error.code === "SQLITE_CONSTRAINT_UNIQUE" || error.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
116
+ return true;
117
+ }
118
+ if (error.errno === 1062) {
119
+ return true;
120
+ }
121
+ return getPostgresSqlState(error) === "23505";
122
+ }
107
123
 
108
124
  // ../../src/core/http/webErrorResponse.ts
109
125
  import { toHttpError as toHttpError2, ValidationError } from "@getstrata/core/errors/http";
@@ -171,6 +187,69 @@ function readSpaPrefix() {
171
187
  // ../../src/core/view/webErrorView.ts
172
188
  import { currentRequestMeta } from "@getstrata/core/http/requestMetaContext";
173
189
 
190
+ // ../../src/core/http/safeInternalPath.ts
191
+ var NESTED_REDIRECT_KEYS = new Set(["redirect", "next", "return", "returnTo", "return_to"]);
192
+ function looksLikeExternalTarget(value) {
193
+ const trimmed = value.trim();
194
+ if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
195
+ return true;
196
+ }
197
+ if (trimmed.includes("://") || trimmed.includes(":/") || trimmed.includes(":\\")) {
198
+ return true;
199
+ }
200
+ try {
201
+ const decoded = decodeURIComponent(trimmed);
202
+ if (decoded.startsWith("//") || decoded.includes("\\") || /https?:/i.test(decoded)) {
203
+ return true;
204
+ }
205
+ } catch {
206
+ return true;
207
+ }
208
+ return false;
209
+ }
210
+ function sanitizeNestedQuery(search) {
211
+ if (!search) {
212
+ return "";
213
+ }
214
+ const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
215
+ const next = new URLSearchParams;
216
+ for (const [key, value] of params.entries()) {
217
+ if (NESTED_REDIRECT_KEYS.has(key)) {
218
+ const sanitized = sanitizeInternalPath(value, "/");
219
+ next.set(key, sanitized);
220
+ continue;
221
+ }
222
+ if (looksLikeExternalTarget(value) && value.includes("://")) {
223
+ continue;
224
+ }
225
+ next.set(key, value);
226
+ }
227
+ const serialized = next.toString();
228
+ return serialized ? `?${serialized}` : "";
229
+ }
230
+ function sanitizeInternalPath(raw, fallback = "/") {
231
+ const trimmed = raw.trim();
232
+ if (looksLikeExternalTarget(trimmed)) {
233
+ return fallback;
234
+ }
235
+ const queryIndex = trimmed.indexOf("?");
236
+ const hashIndex = trimmed.indexOf("#");
237
+ const end = [queryIndex, hashIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0];
238
+ const pathname = end === undefined ? trimmed : trimmed.slice(0, end);
239
+ if (looksLikeExternalTarget(pathname)) {
240
+ return fallback;
241
+ }
242
+ const search = queryIndex >= 0 ? trimmed.slice(queryIndex, hashIndex >= 0 ? hashIndex : undefined) : "";
243
+ return `${pathname}${sanitizeNestedQuery(search)}`;
244
+ }
245
+ function safeInternalRedirectPath(request, fallback = "/") {
246
+ const url = new URL(request.url);
247
+ return sanitizeInternalPath(`${url.pathname}${url.search}`, fallback);
248
+ }
249
+ function loginRedirectLocation(request) {
250
+ return `/login?redirect=${encodeURIComponent(safeInternalRedirectPath(request))}`;
251
+ }
252
+
174
253
  // ../../src/core/view/htmlResponse.ts
175
254
  function htmlResponse(html, init = {}) {
176
255
  return new Response(html, {
@@ -261,39 +340,6 @@ function requestPrefersJson(request) {
261
340
  return false;
262
341
  }
263
342
 
264
- // ../../src/core/http/safeInternalPath.ts
265
- function looksLikeExternalTarget(value) {
266
- const trimmed = value.trim();
267
- if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
268
- return true;
269
- }
270
- if (trimmed.includes("://") || trimmed.includes(":/") || trimmed.includes(":\\")) {
271
- return true;
272
- }
273
- try {
274
- const decoded = decodeURIComponent(trimmed);
275
- if (decoded.startsWith("//") || decoded.includes("\\") || /https?:/i.test(decoded)) {
276
- return true;
277
- }
278
- } catch {
279
- return true;
280
- }
281
- return false;
282
- }
283
- function sanitizeInternalPath(raw, fallback = "/") {
284
- if (looksLikeExternalTarget(raw)) {
285
- return fallback;
286
- }
287
- return raw;
288
- }
289
- function safeInternalRedirectPath(request, fallback = "/") {
290
- const url = new URL(request.url);
291
- return sanitizeInternalPath(`${url.pathname}${url.search}`, fallback);
292
- }
293
- function loginRedirectLocation(request) {
294
- return `/login?redirect=${encodeURIComponent(safeInternalRedirectPath(request))}`;
295
- }
296
-
297
343
  // ../../src/core/http/webErrorResponse.ts
298
344
  function normalizeFieldErrors(details) {
299
345
  if (!details || typeof details !== "object" || Array.isArray(details)) {
@@ -402,7 +448,17 @@ function withJsonErrorHandling(handler) {
402
448
  }
403
449
  };
404
450
  }
451
+ function createJsonErrorMiddleware() {
452
+ return async (_request, next) => {
453
+ try {
454
+ return await next();
455
+ } catch (error) {
456
+ return errorResponse(error);
457
+ }
458
+ };
459
+ }
405
460
  export {
461
+ createJsonErrorMiddleware,
406
462
  createdResponse,
407
463
  errorResponse,
408
464
  jsonResponse,
@@ -2,7 +2,7 @@
2
2
  // ../../src/core/http/validation.ts
3
3
  import { currentAuthUser } from "@getstrata/core/auth/authContext";
4
4
  import { BadRequestError } from "@getstrata/core/errors/http";
5
- import { currentTenantId } from "@getstrata/core/tenant/tenantContext";
5
+ import { currentTenant } from "@getstrata/core/tenant/tenantContext";
6
6
  function buildRequestCacheKey(fallbackPath, request) {
7
7
  if (!request) {
8
8
  return fallbackPath;
@@ -10,7 +10,7 @@ function buildRequestCacheKey(fallbackPath, request) {
10
10
  const url = new URL(request.url);
11
11
  const user = currentAuthUser();
12
12
  const authScope = user ? `u:${user.id}` : "guest";
13
- const tenantScope = `t:${currentTenantId()}`;
13
+ const tenantScope = `t:${currentTenant()?.id ?? "none"}`;
14
14
  return `${authScope}|${tenantScope}|${url.pathname}${url.search}`;
15
15
  }
16
16
  function getQueryParams(request) {
@@ -1,5 +1,6 @@
1
1
  // @bun
2
2
  // ../../src/core/http/safeInternalPath.ts
3
+ var NESTED_REDIRECT_KEYS = new Set(["redirect", "next", "return", "returnTo", "return_to"]);
3
4
  function looksLikeExternalTarget(value) {
4
5
  const trimmed = value.trim();
5
6
  if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
@@ -18,11 +19,40 @@ function looksLikeExternalTarget(value) {
18
19
  }
19
20
  return false;
20
21
  }
22
+ function sanitizeNestedQuery(search) {
23
+ if (!search) {
24
+ return "";
25
+ }
26
+ const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
27
+ const next = new URLSearchParams;
28
+ for (const [key, value] of params.entries()) {
29
+ if (NESTED_REDIRECT_KEYS.has(key)) {
30
+ const sanitized = sanitizeInternalPath(value, "/");
31
+ next.set(key, sanitized);
32
+ continue;
33
+ }
34
+ if (looksLikeExternalTarget(value) && value.includes("://")) {
35
+ continue;
36
+ }
37
+ next.set(key, value);
38
+ }
39
+ const serialized = next.toString();
40
+ return serialized ? `?${serialized}` : "";
41
+ }
21
42
  function sanitizeInternalPath(raw, fallback = "/") {
22
- if (looksLikeExternalTarget(raw)) {
43
+ const trimmed = raw.trim();
44
+ if (looksLikeExternalTarget(trimmed)) {
45
+ return fallback;
46
+ }
47
+ const queryIndex = trimmed.indexOf("?");
48
+ const hashIndex = trimmed.indexOf("#");
49
+ const end = [queryIndex, hashIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0];
50
+ const pathname = end === undefined ? trimmed : trimmed.slice(0, end);
51
+ if (looksLikeExternalTarget(pathname)) {
23
52
  return fallback;
24
53
  }
25
- return raw;
54
+ const search = queryIndex >= 0 ? trimmed.slice(queryIndex, hashIndex >= 0 ? hashIndex : undefined) : "";
55
+ return `${pathname}${sanitizeNestedQuery(search)}`;
26
56
  }
27
57
  function safeInternalRedirectPath(request, fallback = "/") {
28
58
  const url = new URL(request.url);
@@ -1,5 +1,6 @@
1
1
  // @bun
2
2
  // ../../src/core/http/scimThrottleMiddleware.ts
3
+ import { createHash } from "crypto";
3
4
  var {RedisClient } = globalThis.Bun;
4
5
 
5
6
  // ../../src/core/runtime/appEnv.ts
@@ -120,7 +121,12 @@ function readClientIp(request, env = process.env) {
120
121
  // ../../src/core/http/scimThrottleMiddleware.ts
121
122
  var memoryBuckets = new Map;
122
123
  function resolveScimIdentity(request) {
123
- return request.headers.get("authorization")?.slice("Bearer ".length, "Bearer ".length + 16) ?? readClientIp(request) ?? "unknown";
124
+ const authorization = request.headers.get("authorization") ?? "";
125
+ const presented = authorization.startsWith("Bearer ") ? authorization.slice("Bearer ".length).trim() : "";
126
+ if (presented.length > 0) {
127
+ return createHash("sha256").update(presented).digest("hex");
128
+ }
129
+ return readClientIp(request) ?? "unknown";
124
130
  }
125
131
  function throttleFromMemory(key, maxAttempts, decaySeconds) {
126
132
  const now = Date.now();
@@ -108,7 +108,7 @@ function applyConditionalGet(request, response, etag) {
108
108
  // ../../src/core/http/validation.ts
109
109
  import { currentAuthUser } from "@getstrata/core/auth/authContext";
110
110
  import { BadRequestError } from "@getstrata/core/errors/http";
111
- import { currentTenantId } from "@getstrata/core/tenant/tenantContext";
111
+ import { currentTenant } from "@getstrata/core/tenant/tenantContext";
112
112
  function buildRequestCacheKey(fallbackPath, request) {
113
113
  if (!request) {
114
114
  return fallbackPath;
@@ -116,7 +116,7 @@ function buildRequestCacheKey(fallbackPath, request) {
116
116
  const url = new URL(request.url);
117
117
  const user = currentAuthUser();
118
118
  const authScope = user ? `u:${user.id}` : "guest";
119
- const tenantScope = `t:${currentTenantId()}`;
119
+ const tenantScope = `t:${currentTenant()?.id ?? "none"}`;
120
120
  return `${authScope}|${tenantScope}|${url.pathname}${url.search}`;
121
121
  }
122
122
  function getQueryParams(request) {
@@ -27,69 +27,6 @@ function envFlagEnabled(value) {
27
27
  return value === "true";
28
28
  }
29
29
 
30
- // ../../src/core/runtime/appKeyPrefix.ts
31
- function appKeyPrefix() {
32
- return process.env.APP_KEY_PREFIX?.trim() || "strata";
33
- }
34
- function appCookieName(kind) {
35
- return `${appKeyPrefix()}_${kind}`;
36
- }
37
- function appDevSecret(kind) {
38
- return `${appKeyPrefix()}-dev-${kind}`;
39
- }
40
- function requireConfiguredSecret(names, devKind, env = process.env) {
41
- for (const name of names) {
42
- const value = env[name]?.trim();
43
- if (value) {
44
- return value;
45
- }
46
- }
47
- if (isProductionEnv(env)) {
48
- throw new Error(`${names[0]} must be set outside development. Do not derive secrets from the app name.`);
49
- }
50
- return appDevSecret(devKind);
51
- }
52
- function namespacedRedisKey(kind) {
53
- return `${appKeyPrefix()}:${kind}`;
54
- }
55
- function smtpEhloHost() {
56
- const raw = process.env.MAIL_EHLO?.trim() || `${appKeyPrefix()}.local`;
57
- const safe = raw.replace(/[^a-zA-Z0-9.-]/g, "");
58
- return safe || "strata.local";
59
- }
60
- function siemEventType() {
61
- return process.env.SIEM_EVENT_TYPE?.trim() || `${appKeyPrefix()}.audit`;
62
- }
63
- function appUserAgent() {
64
- return process.env.APP_USER_AGENT?.trim() || appKeyPrefix();
65
- }
66
- function otelServiceName() {
67
- return process.env.OTEL_SERVICE_NAME?.trim() || `${appKeyPrefix()}-api`;
68
- }
69
- function appDisplayName() {
70
- return process.env.APP_NAME?.trim() || "Strata";
71
- }
72
- function appEnv() {
73
- return process.env.APP_ENV?.trim() || "local";
74
- }
75
- function appUrl() {
76
- return (process.env.APP_URL?.trim() || "http://localhost:3000").replace(/\/$/, "");
77
- }
78
- function apiPrefix() {
79
- const raw = process.env.API_PREFIX?.trim() || "/api/v1";
80
- const withSlash = raw.startsWith("/") ? raw : `/${raw}`;
81
- const trimmed = withSlash.replace(/\/+$/, "");
82
- return trimmed || "/api/v1";
83
- }
84
- function sdkClientClassName() {
85
- const override = process.env.APP_SDK_CLASS?.trim();
86
- if (override && /^[A-Za-z_][A-Za-z0-9_]*$/.test(override)) {
87
- return override;
88
- }
89
- const fromName = appDisplayName().replace(/[^A-Za-z0-9]/g, "");
90
- return fromName ? `${fromName}Client` : "AppClient";
91
- }
92
-
93
30
  // ../../src/core/http/securityHeadersMiddleware.ts
94
31
  function createSecurityHeadersMiddleware(options = {}) {
95
32
  return async (request, next) => {
@@ -106,8 +43,10 @@ function createSecurityHeadersMiddleware(options = {}) {
106
43
  headers.set("X-Frame-Options", "DENY");
107
44
  headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
108
45
  headers.set("X-XSS-Protection", "0");
46
+ headers.set("Cross-Origin-Opener-Policy", "same-origin");
47
+ headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()");
109
48
  headers.set("Content-Security-Policy", resolveContentSecurityPolicy(response, { ...options, nonce }));
110
- if (appEnv() === "production") {
49
+ if (isProductionEnv()) {
111
50
  headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
112
51
  }
113
52
  return new Response(response.body, {
@@ -159,11 +159,12 @@ function hasValidSignature(input) {
159
159
  return false;
160
160
  }
161
161
  const expires = url.searchParams.get("expires");
162
- if (expires) {
163
- const expiresAt = Number.parseInt(expires, 10);
164
- if (!Number.isInteger(expiresAt) || expiresAt <= Math.floor(Date.now() / 1000)) {
165
- return false;
166
- }
162
+ if (!expires) {
163
+ return false;
164
+ }
165
+ const expiresAt = Number.parseInt(expires, 10);
166
+ if (!Number.isInteger(expiresAt) || expiresAt <= Math.floor(Date.now() / 1000)) {
167
+ return false;
167
168
  }
168
169
  const expected = signCanonicalPayload(url.pathname, sortedQueryString(url.searchParams));
169
170
  return timingSafeCompareString(signature, expected);
@@ -161,6 +161,69 @@ function readSpaPrefix() {
161
161
  // ../../src/core/view/webErrorView.ts
162
162
  import { currentRequestMeta as currentRequestMeta2 } from "@getstrata/core/http/requestMetaContext";
163
163
 
164
+ // ../../src/core/http/safeInternalPath.ts
165
+ var NESTED_REDIRECT_KEYS = new Set(["redirect", "next", "return", "returnTo", "return_to"]);
166
+ function looksLikeExternalTarget(value) {
167
+ const trimmed = value.trim();
168
+ if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
169
+ return true;
170
+ }
171
+ if (trimmed.includes("://") || trimmed.includes(":/") || trimmed.includes(":\\")) {
172
+ return true;
173
+ }
174
+ try {
175
+ const decoded = decodeURIComponent(trimmed);
176
+ if (decoded.startsWith("//") || decoded.includes("\\") || /https?:/i.test(decoded)) {
177
+ return true;
178
+ }
179
+ } catch {
180
+ return true;
181
+ }
182
+ return false;
183
+ }
184
+ function sanitizeNestedQuery(search) {
185
+ if (!search) {
186
+ return "";
187
+ }
188
+ const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
189
+ const next = new URLSearchParams;
190
+ for (const [key, value] of params.entries()) {
191
+ if (NESTED_REDIRECT_KEYS.has(key)) {
192
+ const sanitized = sanitizeInternalPath(value, "/");
193
+ next.set(key, sanitized);
194
+ continue;
195
+ }
196
+ if (looksLikeExternalTarget(value) && value.includes("://")) {
197
+ continue;
198
+ }
199
+ next.set(key, value);
200
+ }
201
+ const serialized = next.toString();
202
+ return serialized ? `?${serialized}` : "";
203
+ }
204
+ function sanitizeInternalPath(raw, fallback = "/") {
205
+ const trimmed = raw.trim();
206
+ if (looksLikeExternalTarget(trimmed)) {
207
+ return fallback;
208
+ }
209
+ const queryIndex = trimmed.indexOf("?");
210
+ const hashIndex = trimmed.indexOf("#");
211
+ const end = [queryIndex, hashIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0];
212
+ const pathname = end === undefined ? trimmed : trimmed.slice(0, end);
213
+ if (looksLikeExternalTarget(pathname)) {
214
+ return fallback;
215
+ }
216
+ const search = queryIndex >= 0 ? trimmed.slice(queryIndex, hashIndex >= 0 ? hashIndex : undefined) : "";
217
+ return `${pathname}${sanitizeNestedQuery(search)}`;
218
+ }
219
+ function safeInternalRedirectPath(request, fallback = "/") {
220
+ const url = new URL(request.url);
221
+ return sanitizeInternalPath(`${url.pathname}${url.search}`, fallback);
222
+ }
223
+ function loginRedirectLocation(request) {
224
+ return `/login?redirect=${encodeURIComponent(safeInternalRedirectPath(request))}`;
225
+ }
226
+
164
227
  // ../../src/core/view/htmlResponse.ts
165
228
  function htmlResponse(html, init = {}) {
166
229
  return new Response(html, {