@getstrata/core 1.0.9 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +48 -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
@@ -1,4 +1,24 @@
1
1
  // @bun
2
+ // ../../src/core/runtime/appEnv.ts
3
+ var NON_PRODUCTION_APP_ENVS = new Set(["local", "development", "dev", "test", "testing", "ci"]);
4
+ function normalizeEnvValue(value) {
5
+ return (value ?? "").trim().toLowerCase();
6
+ }
7
+ function isProductionEnv(env = process.env) {
8
+ const appEnv = normalizeEnvValue(env.APP_ENV);
9
+ const nodeEnv = normalizeEnvValue(env.NODE_ENV);
10
+ if (appEnv === "production" || nodeEnv === "production") {
11
+ return true;
12
+ }
13
+ if (appEnv === "") {
14
+ return false;
15
+ }
16
+ return !NON_PRODUCTION_APP_ENVS.has(appEnv);
17
+ }
18
+ function envFlagEnabled(value) {
19
+ return value === "true";
20
+ }
21
+
2
22
  // ../../src/core/crypto/fieldEncryption.ts
3
23
  import { createCipheriv, createDecipheriv, createHmac, randomBytes } from "crypto";
4
24
  var ENCRYPTION_PREFIX = "enc:v1:";
@@ -26,7 +46,7 @@ function isFieldEncryptionEnabled() {
26
46
  if (featureFlag === "true") {
27
47
  return true;
28
48
  }
29
- return (process.env.APP_ENV ?? "local") === "production";
49
+ return Boolean(resolveEncryptionKey());
30
50
  }
31
51
  function encryptField(plaintext, key) {
32
52
  const iv = randomBytes(IV_LENGTH);
@@ -84,8 +104,8 @@ function emailLookupForQuery(email) {
84
104
  // ../../src/core/crypto/mfaSecret.ts
85
105
  function protectMfaSecret(secret) {
86
106
  const key = resolveEncryptionKey();
87
- if (!isFieldEncryptionEnabled() || !key) {
88
- return secret;
107
+ if (!key) {
108
+ throw new Error("MFA secrets require KMS_ENCRYPTION_KEY.");
89
109
  }
90
110
  return encryptField(secret, key);
91
111
  }
@@ -93,10 +113,16 @@ function revealMfaSecret(stored) {
93
113
  if (!stored) {
94
114
  return null;
95
115
  }
96
- const key = resolveEncryptionKey();
97
- if (!isFieldEncryptionEnabled() || !key || !stored.startsWith("enc:v1:")) {
116
+ if (!stored.startsWith("enc:v1:")) {
117
+ if (isProductionEnv() || resolveEncryptionKey()) {
118
+ throw new Error("MFA secrets must be encrypted.");
119
+ }
98
120
  return stored;
99
121
  }
122
+ const key = resolveEncryptionKey();
123
+ if (!key) {
124
+ throw new Error("MFA secrets require KMS_ENCRYPTION_KEY.");
125
+ }
100
126
  return decryptField(stored, key);
101
127
  }
102
128
  export {
@@ -75,21 +75,25 @@ function mapDatabaseError(error) {
75
75
  const sqlState = getPostgresSqlState(error);
76
76
  switch (sqlState) {
77
77
  case "23505":
78
- return new ConflictError(error.detail ?? "A record with these values already exists.", {
79
- constraint: error.constraint
80
- });
78
+ if (error.detail) {
79
+ console.error("[database] unique violation", error.detail);
80
+ }
81
+ return new ConflictError("A record with these values already exists.");
81
82
  case "23503":
82
- return new UnprocessableEntityError(error.detail ?? "Referenced record does not exist.", {
83
- constraint: error.constraint
84
- });
83
+ if (error.detail) {
84
+ console.error("[database] foreign-key violation", error.detail);
85
+ }
86
+ return new UnprocessableEntityError("Referenced record does not exist.");
85
87
  case "23502":
86
- return new BadRequestError(error.detail ?? "Required field is missing.", {
87
- constraint: error.constraint
88
- });
88
+ if (error.detail) {
89
+ console.error("[database] not-null violation", error.detail);
90
+ }
91
+ return new BadRequestError("Required field is missing.");
89
92
  case "23514":
90
- return new BadRequestError(error.detail ?? "Value violates a database constraint.", {
91
- constraint: error.constraint
92
- });
93
+ if (error.detail) {
94
+ console.error("[database] check violation", error.detail);
95
+ }
96
+ return new BadRequestError("Value violates a database constraint.");
93
97
  default:
94
98
  return new InternalServerError("Database operation failed.");
95
99
  }
@@ -101,8 +105,21 @@ async function withDatabaseErrorHandling(operation) {
101
105
  throw mapDatabaseError(error);
102
106
  }
103
107
  }
108
+ function isUniqueConstraintError(error) {
109
+ if (!isPostgresError(error)) {
110
+ return false;
111
+ }
112
+ if (error.code === "SQLITE_CONSTRAINT_UNIQUE" || error.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
113
+ return true;
114
+ }
115
+ if (error.errno === 1062) {
116
+ return true;
117
+ }
118
+ return getPostgresSqlState(error) === "23505";
119
+ }
104
120
  export {
105
121
  isPostgresError,
122
+ isUniqueConstraintError,
106
123
  mapDatabaseError,
107
124
  withDatabaseErrorHandling
108
125
  };
@@ -858,7 +858,7 @@ class BelongsToManyRelationQuery {
858
858
  }
859
859
  async get() {
860
860
  const parentId = this.parent.get(this.relation.parentKey);
861
- const pivotRows = await this.connection().unsafe(`SELECT * FROM ${this.relation.pivotTable} WHERE ${String(this.relation.foreignPivotKey)} = $1`, [parentId]);
861
+ const pivotRows = await this.connection().unsafe(`SELECT * FROM ${quoteIdentifier(this.relation.pivotTable)} WHERE ${quoteIdentifier(String(this.relation.foreignPivotKey))} = $1`, [parentId]);
862
862
  if (pivotRows.length === 0) {
863
863
  return [];
864
864
  }
@@ -881,7 +881,7 @@ class BelongsToManyRelationQuery {
881
881
  }
882
882
  async count() {
883
883
  const parentId = this.parent.get(this.relation.parentKey);
884
- const rows = await this.connection().unsafe(`SELECT COUNT(*) AS count FROM ${this.relation.pivotTable} WHERE ${String(this.relation.foreignPivotKey)} = $1`, [parentId]);
884
+ const rows = await this.connection().unsafe(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(this.relation.pivotTable)} WHERE ${quoteIdentifier(String(this.relation.foreignPivotKey))} = $1`, [parentId]);
885
885
  return Number(rows[0]?.count ?? 0);
886
886
  }
887
887
  async pluck(column, keyBy) {
@@ -893,7 +893,7 @@ class BelongsToManyRelationQuery {
893
893
  }
894
894
  async relatedQuery() {
895
895
  const parentId = this.parent.get(this.relation.parentKey);
896
- const pivotRows = await this.connection().unsafe(`SELECT * FROM ${this.relation.pivotTable} WHERE ${String(this.relation.foreignPivotKey)} = $1`, [parentId]);
896
+ const pivotRows = await this.connection().unsafe(`SELECT * FROM ${quoteIdentifier(this.relation.pivotTable)} WHERE ${quoteIdentifier(String(this.relation.foreignPivotKey))} = $1`, [parentId]);
897
897
  if (pivotRows.length === 0) {
898
898
  return null;
899
899
  }
@@ -916,18 +916,18 @@ class BelongsToManyRelationQuery {
916
916
  const list = Array.isArray(ids) ? ids : [ids];
917
917
  const parentId = this.parent.get(this.relation.parentKey);
918
918
  const extraKeys = Object.keys(this.pivotValues);
919
- const extraColumns = extraKeys.length > 0 ? `, ${extraKeys.join(", ")}` : "";
919
+ const extraColumns = extraKeys.length > 0 ? `, ${extraKeys.map((key) => quoteIdentifier(key)).join(", ")}` : "";
920
920
  const extraPlaceholders = extraKeys.map((_, index) => `$${index + 3}`).join(", ");
921
921
  const extraValues = extraKeys.map((key) => this.pivotValues[key]);
922
922
  for (const id of list) {
923
- await this.connection().unsafe(extraKeys.length > 0 ? `INSERT INTO ${this.relation.pivotTable} (${String(this.relation.foreignPivotKey)}, ${String(this.relation.relatedPivotKey)}${extraColumns}) VALUES ($1, $2, ${extraPlaceholders})` : `INSERT INTO ${this.relation.pivotTable} (${String(this.relation.foreignPivotKey)}, ${String(this.relation.relatedPivotKey)}) VALUES ($1, $2)`, [parentId, id, ...extraValues]);
923
+ await this.connection().unsafe(extraKeys.length > 0 ? `INSERT INTO ${quoteIdentifier(this.relation.pivotTable)} (${quoteIdentifier(String(this.relation.foreignPivotKey))}, ${quoteIdentifier(String(this.relation.relatedPivotKey))}${extraColumns}) VALUES ($1, $2, ${extraPlaceholders})` : `INSERT INTO ${quoteIdentifier(this.relation.pivotTable)} (${quoteIdentifier(String(this.relation.foreignPivotKey))}, ${quoteIdentifier(String(this.relation.relatedPivotKey))}) VALUES ($1, $2)`, [parentId, id, ...extraValues]);
924
924
  }
925
925
  }
926
926
  async toggle(ids) {
927
927
  const list = Array.isArray(ids) ? ids : [ids];
928
928
  const parentId = this.parent.get(this.relation.parentKey);
929
929
  for (const id of list) {
930
- const existing = await this.connection().unsafe(`SELECT 1 FROM ${this.relation.pivotTable} WHERE ${String(this.relation.foreignPivotKey)} = $1 AND ${String(this.relation.relatedPivotKey)} = $2 LIMIT 1`, [parentId, id]);
930
+ const existing = await this.connection().unsafe(`SELECT 1 FROM ${quoteIdentifier(this.relation.pivotTable)} WHERE ${quoteIdentifier(String(this.relation.foreignPivotKey))} = $1 AND ${quoteIdentifier(String(this.relation.relatedPivotKey))} = $2 LIMIT 1`, [parentId, id]);
931
931
  if (existing.length > 0) {
932
932
  await this.detach(id);
933
933
  } else {
@@ -938,12 +938,12 @@ class BelongsToManyRelationQuery {
938
938
  async detach(ids) {
939
939
  const parentId = this.parent.get(this.relation.parentKey);
940
940
  if (ids === undefined) {
941
- await this.connection().unsafe(`DELETE FROM ${this.relation.pivotTable} WHERE ${String(this.relation.foreignPivotKey)} = $1`, [parentId]);
941
+ await this.connection().unsafe(`DELETE FROM ${quoteIdentifier(this.relation.pivotTable)} WHERE ${quoteIdentifier(String(this.relation.foreignPivotKey))} = $1`, [parentId]);
942
942
  return;
943
943
  }
944
944
  const list = Array.isArray(ids) ? ids : [ids];
945
945
  const placeholders = list.map((_, index) => `$${index + 2}`).join(", ");
946
- await this.connection().unsafe(`DELETE FROM ${this.relation.pivotTable} WHERE ${String(this.relation.foreignPivotKey)} = $1 AND ${String(this.relation.relatedPivotKey)} IN (${placeholders})`, [parentId, ...list]);
946
+ await this.connection().unsafe(`DELETE FROM ${quoteIdentifier(this.relation.pivotTable)} WHERE ${quoteIdentifier(String(this.relation.foreignPivotKey))} = $1 AND ${quoteIdentifier(String(this.relation.relatedPivotKey))} IN (${placeholders})`, [parentId, ...list]);
947
947
  }
948
948
  async sync(ids) {
949
949
  await this.detach();
@@ -1610,7 +1610,7 @@ function hashCastValue(value) {
1610
1610
  if (isAlreadyHashed(plain)) {
1611
1611
  return plain;
1612
1612
  }
1613
- return Bun.password.hashSync(plain, { algorithm: "bcrypt", cost: 10 });
1613
+ return Bun.password.hashSync(plain, { algorithm: "bcrypt", cost: 12 });
1614
1614
  }
1615
1615
  function hydrateValue(value, cast) {
1616
1616
  if (value === null || value === undefined) {
@@ -117,6 +117,28 @@ function resolveSmtpConfig() {
117
117
  ...process.env.MAIL_PASSWORD?.trim() ? { password: process.env.MAIL_PASSWORD.trim() } : {}
118
118
  };
119
119
  }
120
+ var EMAIL_SHAPE = /^[^\s@\r\n]+@[^\s@\r\n]+\.[^\s@\r\n]+$/u;
121
+ function assertSafeSmtpField(value, field) {
122
+ if (/[\r\n]/u.test(value)) {
123
+ throw new Error(`Mail ${field} must not contain CR or LF.`);
124
+ }
125
+ return value;
126
+ }
127
+ function smtpEnvelopeAddress(value, field) {
128
+ const trimmed = assertSafeSmtpField(value, field).trim();
129
+ const address = trimmed.includes("<") ? trimmed.match(/<([^>]+)>/u)?.[1] ?? trimmed : trimmed;
130
+ if (!EMAIL_SHAPE.test(address)) {
131
+ throw new Error(`Mail ${field} must be a valid email address.`);
132
+ }
133
+ return address;
134
+ }
135
+ function assertSafeSmtpAddress(value, field) {
136
+ smtpEnvelopeAddress(value, field);
137
+ return assertSafeSmtpField(value, field).trim();
138
+ }
139
+ function redactMailBody(body) {
140
+ return body.replace(/https?:\/\/[^\s]+/giu, "[redacted-url]").replace(/[?&](token|signature|expires|code)=[^&\s]+/giu, "[redacted-query]");
141
+ }
120
142
  function encodeBase64(value) {
121
143
  return Buffer.from(value, "utf8").toString("base64");
122
144
  }
@@ -181,6 +203,12 @@ async function openSmtpConnection(config) {
181
203
  return { socket, readResponse };
182
204
  }
183
205
  async function defaultSmtpTransport(config, message) {
206
+ if (config.username) {
207
+ assertSafeSmtpField(config.username, "username");
208
+ }
209
+ if (config.password) {
210
+ assertSafeSmtpField(config.password, "password");
211
+ }
184
212
  const { socket, readResponse } = await openSmtpConnection(config);
185
213
  try {
186
214
  await waitForSmtpResponse(readResponse, ["220"]);
@@ -198,16 +226,24 @@ async function defaultSmtpTransport(config, message) {
198
226
  `);
199
227
  await waitForSmtpResponse(readResponse, ["235"]);
200
228
  }
201
- await socket.write(`MAIL FROM:<${config.from}>\r
229
+ const fromHeader = assertSafeSmtpAddress(config.from, "from");
230
+ const toHeader = assertSafeSmtpAddress(message.to, "to");
231
+ const from = smtpEnvelopeAddress(config.from, "from");
232
+ const to = smtpEnvelopeAddress(message.to, "to");
233
+ await socket.write(`MAIL FROM:<${from}>\r
202
234
  `);
203
235
  await waitForSmtpResponse(readResponse, ["250"]);
204
- await socket.write(`RCPT TO:<${message.to}>\r
236
+ await socket.write(`RCPT TO:<${to}>\r
205
237
  `);
206
238
  await waitForSmtpResponse(readResponse, ["250", "251"]);
207
239
  await socket.write(`DATA\r
208
240
  `);
209
241
  await waitForSmtpResponse(readResponse, ["354"]);
210
- const payload = buildSmtpPayload(config.from, message);
242
+ const payload = buildSmtpPayload(fromHeader, {
243
+ ...message,
244
+ to: toHeader,
245
+ subject: assertSafeSmtpField(message.subject, "subject")
246
+ });
211
247
  await socket.write(payload);
212
248
  await waitForSmtpResponse(readResponse, ["250"]);
213
249
  await socket.write(`QUIT\r
@@ -218,10 +254,13 @@ async function defaultSmtpTransport(config, message) {
218
254
  }
219
255
  }
220
256
  function buildSmtpPayload(from, message) {
257
+ const safeFrom = assertSafeSmtpAddress(from, "from");
258
+ const safeTo = assertSafeSmtpAddress(message.to, "to");
259
+ const safeSubject = assertSafeSmtpField(message.subject, "subject");
221
260
  const headers = [
222
- `From: ${from}`,
223
- `To: ${message.to}`,
224
- `Subject: ${message.subject}`,
261
+ `From: ${safeFrom}`,
262
+ `To: ${safeTo}`,
263
+ `Subject: ${safeSubject}`,
225
264
  "MIME-Version: 1.0"
226
265
  ];
227
266
  if (message.html) {
@@ -254,7 +293,7 @@ class LogMailDriver {
254
293
  channel: "mail",
255
294
  to: message.to,
256
295
  subject: message.subject,
257
- body: message.body,
296
+ body: redactMailBody(message.body),
258
297
  ...message.html ? { htmlBytes: Buffer.byteLength(message.html, "utf8") } : {}
259
298
  }));
260
299
  }
@@ -278,6 +317,8 @@ class Mailer {
278
317
  this.driver = driver;
279
318
  }
280
319
  send(message) {
320
+ assertSafeSmtpAddress(message.to, "to");
321
+ assertSafeSmtpField(message.subject, "subject");
281
322
  return this.driver.send(message);
282
323
  }
283
324
  }
@@ -295,9 +336,31 @@ function mailer() {
295
336
 
296
337
  // ../../src/core/storage/storage.ts
297
338
  import { mkdir, readFile, unlink, writeFile } from "fs/promises";
298
- import { dirname, join } from "path";
339
+ import { dirname } from "path";
299
340
  var {S3Client } = globalThis.Bun;
300
341
 
342
+ // ../../src/core/security/safePath.ts
343
+ import { isAbsolute, resolve, sep } from "path";
344
+ function assertPathUnderRoot(rootDirectory, userPath) {
345
+ const trimmed = userPath.trim();
346
+ if (trimmed.length === 0) {
347
+ throw new Error("Path must not be empty.");
348
+ }
349
+ if (trimmed.includes("\x00")) {
350
+ throw new Error("Path must not contain a null byte.");
351
+ }
352
+ const root = resolve(rootDirectory);
353
+ const candidate = isAbsolute(trimmed) ? resolve(trimmed) : resolve(root, trimmed.replace(/^\/+/u, ""));
354
+ if (candidate === root || candidate.startsWith(`${root}${sep}`)) {
355
+ return candidate;
356
+ }
357
+ throw new Error("Path escapes the storage root.");
358
+ }
359
+ function assertUrlPathUnderRoot(rootDirectory, urlPathname) {
360
+ return assertPathUnderRoot(rootDirectory, urlPathname.replace(/^\/+/u, ""));
361
+ }
362
+
363
+ // ../../src/core/storage/storage.ts
301
364
  class LocalStorageDriver {
302
365
  rootDirectory;
303
366
  constructor(rootDirectory) {
@@ -307,7 +370,7 @@ class LocalStorageDriver {
307
370
  return this.rootDirectory ?? process.env.STORAGE_PATH ?? "storage";
308
371
  }
309
372
  resolvePath(path) {
310
- return join(this.resolveRootDirectory(), path.replace(/^\/+/, ""));
373
+ return assertPathUnderRoot(this.resolveRootDirectory(), path);
311
374
  }
312
375
  async put(path, contents) {
313
376
  const absolutePath = this.resolvePath(path);
@@ -1,21 +1,14 @@
1
1
  // @bun
2
2
  // ../../src/core/http/authMiddleware.ts
3
- import { authContext, runWithAuthUser } from "@getstrata/core/auth/authContext";
3
+ import {
4
+ authContext,
5
+ runWithAuthContext
6
+ } from "@getstrata/core/auth/authContext";
4
7
  function createAuthMiddleware(auth) {
5
8
  return async (request, next) => {
6
- const user = await auth.resolve(request);
7
- return await runWithAuthUser(user, async () => {
8
- const response = await next();
9
- if (user) {
10
- const headers = new Headers(response.headers);
11
- headers.set("x-authenticated-user-id", String(user.id));
12
- return new Response(response.body, {
13
- status: response.status,
14
- statusText: response.statusText,
15
- headers
16
- });
17
- }
18
- return response;
9
+ const resolved = typeof auth.resolveWithSource === "function" ? await auth.resolveWithSource(request) : { user: await auth.resolve(request), credentialSource: null };
10
+ return await runWithAuthContext(resolved.user, resolved.credentialSource, async () => {
11
+ return await next();
19
12
  });
20
13
  };
21
14
  }
@@ -19,9 +19,72 @@ function envFlagEnabled(value) {
19
19
  return value === "true";
20
20
  }
21
21
 
22
+ // ../../src/core/runtime/appKeyPrefix.ts
23
+ function appKeyPrefix() {
24
+ return process.env.APP_KEY_PREFIX?.trim() || "strata";
25
+ }
26
+ function appCookieName(kind) {
27
+ return `${appKeyPrefix()}_${kind}`;
28
+ }
29
+ function appDevSecret(kind) {
30
+ return `${appKeyPrefix()}-dev-${kind}`;
31
+ }
32
+ function requireConfiguredSecret(names, devKind, env = process.env) {
33
+ for (const name of names) {
34
+ const value = env[name]?.trim();
35
+ if (value) {
36
+ return value;
37
+ }
38
+ }
39
+ if (isProductionEnv(env)) {
40
+ throw new Error(`${names[0]} must be set outside development. Do not derive secrets from the app name.`);
41
+ }
42
+ return appDevSecret(devKind);
43
+ }
44
+ function namespacedRedisKey(kind) {
45
+ return `${appKeyPrefix()}:${kind}`;
46
+ }
47
+ function smtpEhloHost() {
48
+ const raw = process.env.MAIL_EHLO?.trim() || `${appKeyPrefix()}.local`;
49
+ const safe = raw.replace(/[^a-zA-Z0-9.-]/g, "");
50
+ return safe || "strata.local";
51
+ }
52
+ function siemEventType() {
53
+ return process.env.SIEM_EVENT_TYPE?.trim() || `${appKeyPrefix()}.audit`;
54
+ }
55
+ function appUserAgent() {
56
+ return process.env.APP_USER_AGENT?.trim() || appKeyPrefix();
57
+ }
58
+ function otelServiceName() {
59
+ return process.env.OTEL_SERVICE_NAME?.trim() || `${appKeyPrefix()}-api`;
60
+ }
61
+ function appDisplayName() {
62
+ return process.env.APP_NAME?.trim() || "Strata";
63
+ }
64
+ function appEnv() {
65
+ return process.env.APP_ENV?.trim() || "local";
66
+ }
67
+ function appUrl() {
68
+ return (process.env.APP_URL?.trim() || "http://localhost:3000").replace(/\/$/, "");
69
+ }
70
+ function apiPrefix() {
71
+ const raw = process.env.API_PREFIX?.trim() || "/api/v1";
72
+ const withSlash = raw.startsWith("/") ? raw : `/${raw}`;
73
+ const trimmed = withSlash.replace(/\/+$/, "");
74
+ return trimmed || "/api/v1";
75
+ }
76
+ function sdkClientClassName() {
77
+ const override = process.env.APP_SDK_CLASS?.trim();
78
+ if (override && /^[A-Za-z_][A-Za-z0-9_]*$/.test(override)) {
79
+ return override;
80
+ }
81
+ const fromName = appDisplayName().replace(/[^A-Za-z0-9]/g, "");
82
+ return fromName ? `${fromName}Client` : "AppClient";
83
+ }
84
+
22
85
  // ../../src/core/http/corsMiddleware.ts
23
86
  function defaultAllowedOrigins() {
24
- return isProductionEnv() ? "" : "*";
87
+ return appUrl();
25
88
  }
26
89
  function resolveCorsConfig() {
27
90
  return {
@@ -35,7 +98,8 @@ function resolveCorsConfig() {
35
98
  "X-Authenticated-User-Id",
36
99
  "X-Authenticated-User-Role",
37
100
  "If-Match",
38
- "If-None-Match"
101
+ "If-None-Match",
102
+ "X-CSRF-Token"
39
103
  ],
40
104
  maxAgeSeconds: 86400
41
105
  };
@@ -74,6 +138,9 @@ function buildCorsHeaders(request) {
74
138
  headers.set("Access-Control-Allow-Methods", corsConfig.allowedMethods.join(", "));
75
139
  headers.set("Access-Control-Allow-Headers", corsConfig.allowedHeaders.join(", "));
76
140
  headers.set("Access-Control-Max-Age", String(corsConfig.maxAgeSeconds));
141
+ if (allowOrigin !== "*") {
142
+ headers.set("Access-Control-Allow-Credentials", "true");
143
+ }
77
144
  return headers;
78
145
  }
79
146
  export {
@@ -1,5 +1,6 @@
1
1
  // @bun
2
2
  // ../../src/core/http/csrfMiddleware.ts
3
+ import { currentCredentialSource } from "@getstrata/core/auth/authContext";
3
4
  import { ForbiddenError } from "@getstrata/core/errors/http";
4
5
 
5
6
  // ../../src/core/http/csrfToken.ts
@@ -149,7 +150,7 @@ function csrfCookieName() {
149
150
  return process.env.CSRF_COOKIE_NAME?.trim() || appCookieName("csrf");
150
151
  }
151
152
  function resolveCsrfSecret() {
152
- return requireConfiguredSecret(["SESSION_SECRET", "OAUTH_STATE_SECRET", "ADMIN_API_TOKEN"], "csrf-secret");
153
+ return requireConfiguredSecret(["SESSION_SECRET", "CSRF_SECRET"], "csrf-secret");
153
154
  }
154
155
  function csrfVerifyOptions() {
155
156
  return { secret: resolveCsrfSecret(), maxAge: CSRF_TTL_MS };
@@ -167,7 +168,7 @@ function createCsrfTokenCookie() {
167
168
  const secure = isProductionEnv() ? "; Secure" : "";
168
169
  return {
169
170
  token,
170
- cookie: `${csrfCookieName()}=${encodeURIComponent(token)}; Path=/; SameSite=Lax; Max-Age=${Math.floor(CSRF_TTL_MS / 1000)}${secure}`
171
+ cookie: `${csrfCookieName()}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(CSRF_TTL_MS / 1000)}${secure}`
171
172
  };
172
173
  }
173
174
  function resolveCsrfToken(request) {
@@ -224,50 +225,22 @@ function resolveCsrfTokenForRequest(request) {
224
225
  return resolveCsrfToken(request).token;
225
226
  }
226
227
 
227
- // ../../src/core/http/statelessAuth.ts
228
- function authorizationScheme(request) {
229
- const header = request.headers.get("authorization")?.trim() ?? "";
230
- const scheme = header.split(/\s+/, 1)[0];
231
- return scheme ? scheme.toLowerCase() : "";
232
- }
233
- function requestUsesHeaderCredentials(request) {
234
- const scheme = authorizationScheme(request);
235
- return scheme === "bearer" || scheme === "basic";
236
- }
237
- function readBearerToken(request) {
238
- const header = request.headers.get("authorization")?.trim() ?? "";
239
- if (!header.toLowerCase().startsWith("bearer ")) {
240
- return null;
241
- }
242
- const token = header.slice("Bearer ".length).trim();
243
- return token.length > 0 ? token : null;
244
- }
245
- function readBasicCredentials(request) {
246
- const header = request.headers.get("authorization")?.trim() ?? "";
247
- if (!header.toLowerCase().startsWith("basic ")) {
248
- return null;
249
- }
250
- const encoded = header.slice("Basic ".length).trim();
251
- if (!encoded) {
252
- return null;
228
+ // ../../src/core/http/csrfMiddleware.ts
229
+ var MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
230
+ function samlAcsPathname() {
231
+ const configured = process.env.SAML_ACS_URL?.trim();
232
+ if (!configured) {
233
+ return "/auth/saml/acs";
253
234
  }
254
235
  try {
255
- const decoded = Buffer.from(encoded, "base64").toString("utf8");
256
- const separator = decoded.indexOf(":");
257
- if (separator < 0) {
258
- return null;
259
- }
260
- return {
261
- username: decoded.slice(0, separator),
262
- password: decoded.slice(separator + 1)
263
- };
236
+ return new URL(configured, "http://strata.invalid").pathname;
264
237
  } catch {
265
- return null;
238
+ return "/auth/saml/acs";
266
239
  }
267
240
  }
268
-
269
- // ../../src/core/http/csrfMiddleware.ts
270
- var MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
241
+ function skipsCsrfPath(pathname) {
242
+ return pathname === samlAcsPathname() || pathname.startsWith("/scim/");
243
+ }
271
244
  function appendSetCookie(response, cookie) {
272
245
  const headers = new Headers(response.headers);
273
246
  headers.append("set-cookie", cookie);
@@ -279,13 +252,18 @@ function appendSetCookie(response, cookie) {
279
252
  }
280
253
  function createCsrfMiddleware() {
281
254
  return async (request, next) => {
282
- if (requestUsesHeaderCredentials(request)) {
255
+ const credentialSource = currentCredentialSource();
256
+ if (credentialSource === "bearer" || credentialSource === "basic") {
257
+ return await next();
258
+ }
259
+ const pathname = new URL(request.url).pathname;
260
+ if (skipsCsrfPath(pathname)) {
283
261
  return await next();
284
262
  }
285
263
  const method = request.method.toUpperCase();
286
264
  if (!MUTATING_METHODS.has(method)) {
287
- const csrf = resolveCsrfToken(request);
288
265
  const meta = currentRequestMeta();
266
+ const csrf = meta.csrfToken ? { token: meta.csrfToken } : resolveCsrfToken(request);
289
267
  meta.csrfToken = csrf.token;
290
268
  const response = await next();
291
269
  if (!csrf.cookie) {
@@ -297,6 +275,18 @@ function createCsrfMiddleware() {
297
275
  if (!verifyCsrfToken(request, submitted)) {
298
276
  throw new ForbiddenError("Invalid or missing CSRF token.");
299
277
  }
278
+ const originHeader = request.headers.get("origin");
279
+ let headerOrigin = null;
280
+ if (originHeader) {
281
+ try {
282
+ headerOrigin = new URL(originHeader).origin;
283
+ } catch {
284
+ headerOrigin = null;
285
+ }
286
+ }
287
+ if (!headerOrigin || headerOrigin !== new URL(request.url).origin) {
288
+ throw new ForbiddenError("Cross-site cookie requests are not allowed.");
289
+ }
300
290
  return await next();
301
291
  };
302
292
  }
@@ -146,7 +146,7 @@ function csrfCookieName() {
146
146
  return process.env.CSRF_COOKIE_NAME?.trim() || appCookieName("csrf");
147
147
  }
148
148
  function resolveCsrfSecret() {
149
- return requireConfiguredSecret(["SESSION_SECRET", "OAUTH_STATE_SECRET", "ADMIN_API_TOKEN"], "csrf-secret");
149
+ return requireConfiguredSecret(["SESSION_SECRET", "CSRF_SECRET"], "csrf-secret");
150
150
  }
151
151
  function csrfVerifyOptions() {
152
152
  return { secret: resolveCsrfSecret(), maxAge: CSRF_TTL_MS };
@@ -164,7 +164,7 @@ function createCsrfTokenCookie() {
164
164
  const secure = isProductionEnv() ? "; Secure" : "";
165
165
  return {
166
166
  token,
167
- cookie: `${csrfCookieName()}=${encodeURIComponent(token)}; Path=/; SameSite=Lax; Max-Age=${Math.floor(CSRF_TTL_MS / 1000)}${secure}`
167
+ cookie: `${csrfCookieName()}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(CSRF_TTL_MS / 1000)}${secure}`
168
168
  };
169
169
  }
170
170
  function resolveCsrfToken(request) {
@@ -155,10 +155,12 @@ function createFlashCookie(message) {
155
155
  const payload = Buffer.from(JSON.stringify(message), "utf8").toString("base64url");
156
156
  const issuedAt = Date.now();
157
157
  const value = signFlashPayload(payload, issuedAt);
158
- return `${flashCookieName()}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=60`;
158
+ const secure = isProductionEnv() ? "; Secure" : "";
159
+ return `${flashCookieName()}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=60${secure}`;
159
160
  }
160
161
  function clearFlashCookie() {
161
- return `${flashCookieName()}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
162
+ const secure = isProductionEnv() ? "; Secure" : "";
163
+ return `${flashCookieName()}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`;
162
164
  }
163
165
  function pullFlash(request) {
164
166
  const cookieValue = readFlashCookie(request);
@@ -155,10 +155,12 @@ function createFlashCookie(message) {
155
155
  const payload = Buffer.from(JSON.stringify(message), "utf8").toString("base64url");
156
156
  const issuedAt = Date.now();
157
157
  const value = signFlashPayload(payload, issuedAt);
158
- return `${flashCookieName()}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=60`;
158
+ const secure = isProductionEnv() ? "; Secure" : "";
159
+ return `${flashCookieName()}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=60${secure}`;
159
160
  }
160
161
  function clearFlashCookie() {
161
- return `${flashCookieName()}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
162
+ const secure = isProductionEnv() ? "; Secure" : "";
163
+ return `${flashCookieName()}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`;
162
164
  }
163
165
  function pullFlash(request) {
164
166
  const cookieValue = readFlashCookie(request);
@@ -5,7 +5,7 @@ import { ForbiddenError } from "@getstrata/core/errors/http";
5
5
  // ../../src/core/http/validation.ts
6
6
  import { currentAuthUser } from "@getstrata/core/auth/authContext";
7
7
  import { BadRequestError } from "@getstrata/core/errors/http";
8
- import { currentTenantId } from "@getstrata/core/tenant/tenantContext";
8
+ import { currentTenant } from "@getstrata/core/tenant/tenantContext";
9
9
  function buildRequestCacheKey(fallbackPath, request) {
10
10
  if (!request) {
11
11
  return fallbackPath;
@@ -13,7 +13,7 @@ function buildRequestCacheKey(fallbackPath, request) {
13
13
  const url = new URL(request.url);
14
14
  const user = currentAuthUser();
15
15
  const authScope = user ? `u:${user.id}` : "guest";
16
- const tenantScope = `t:${currentTenantId()}`;
16
+ const tenantScope = `t:${currentTenant()?.id ?? "none"}`;
17
17
  return `${authScope}|${tenantScope}|${url.pathname}${url.search}`;
18
18
  }
19
19
  function getQueryParams(request) {