@getstrata/core 1.1.9 → 2.0.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 (36) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/core/database/transaction.d.ts +2 -1
  3. package/dist/core/database/transactionControl.d.ts +5 -0
  4. package/dist/core/http/loginThrottleMiddleware.d.ts +3 -0
  5. package/dist/core/http/metricsMiddleware.d.ts +1 -2
  6. package/dist/core/http/requestMetaContext.d.ts +2 -0
  7. package/dist/core/http/throttleMiddleware.d.ts +11 -2
  8. package/dist/core/metrics/prometheus.d.ts +11 -1
  9. package/dist/core/queue/jobRunner.d.ts +3 -1
  10. package/dist/core/queue/redisQueue.d.ts +20 -3
  11. package/dist/core/tenant/databaseTenantContext.d.ts +5 -1
  12. package/dist/entries/audit/exportAuditLogs.js +522 -62
  13. package/dist/entries/auth/basicAuthGuard.js +245 -219
  14. package/dist/entries/auth/oauth/samlProvider.js +185 -161
  15. package/dist/entries/auth/oneTimeToken.js +130 -127
  16. package/dist/entries/auth/passwordLogin.js +185 -180
  17. package/dist/entries/auth/saml/samlServiceProvider.js +185 -161
  18. package/dist/entries/auth/scimAuthMiddleware.js +525 -11
  19. package/dist/entries/database/migrations.js +132 -129
  20. package/dist/entries/database/schema.js +58 -39
  21. package/dist/entries/http/metricsMiddleware.js +124 -27
  22. package/dist/entries/http/response.js +107 -0
  23. package/dist/entries/http/routeMiddleware.js +40 -0
  24. package/dist/entries/http/throttleMiddleware.js +124 -11
  25. package/dist/entries/jobs/exportAuditLogsJob.js +522 -62
  26. package/dist/entries/metrics/prometheus.js +71 -11
  27. package/dist/entries/queue/createAppQueue.js +215 -21
  28. package/dist/entries/queue/jobRunner.js +3 -2
  29. package/dist/entries/queue/publicQueue.js +215 -21
  30. package/dist/entries/queue/queueMetrics.js +215 -21
  31. package/dist/entries/queue/redisQueue.js +223 -22
  32. package/dist/entries/tenant/databaseTenantContext.js +477 -16
  33. package/dist/entries/tenant/tenantDatabaseScope.js +523 -9
  34. package/dist/framework/public-api.d.ts +2 -2
  35. package/dist/index.js +954 -457
  36. package/package.json +1 -1
@@ -1,6 +1,23 @@
1
1
  // @bun
2
2
  // ../../src/core/metrics/prometheus.ts
3
+ var HTTP_DURATION_BUCKETS_MS = [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 1e4];
4
+ function emptyHistogram() {
5
+ return {
6
+ cumulative: HTTP_DURATION_BUCKETS_MS.map(() => 0),
7
+ sum: 0,
8
+ count: 0
9
+ };
10
+ }
11
+
3
12
  class PrometheusRegistry {
13
+ maxSeries;
14
+ seriesLabels = new Map;
15
+ constructor(maxSeries = 4096) {
16
+ this.maxSeries = maxSeries;
17
+ if (!Number.isSafeInteger(maxSeries) || maxSeries < 1) {
18
+ throw new Error("Metrics maxSeries must be a positive safe integer.");
19
+ }
20
+ }
4
21
  httpRequestsTotal = new Map;
5
22
  httpRequestDurationMs = new Map;
6
23
  incrementHttpRequest(labels) {
@@ -8,10 +25,20 @@ class PrometheusRegistry {
8
25
  this.httpRequestsTotal.set(key, (this.httpRequestsTotal.get(key) ?? 0) + 1);
9
26
  }
10
27
  observeHttpDuration(labels, durationMs) {
28
+ if (!Number.isFinite(durationMs) || durationMs < 0)
29
+ return;
11
30
  const key = this.metricKey(labels);
12
- const samples = this.httpRequestDurationMs.get(key) ?? [];
13
- samples.push(durationMs);
14
- this.httpRequestDurationMs.set(key, samples);
31
+ const histogram = this.httpRequestDurationMs.get(key) ?? emptyHistogram();
32
+ histogram.count += 1;
33
+ histogram.sum += durationMs;
34
+ for (let index = 0;index < HTTP_DURATION_BUCKETS_MS.length; index += 1) {
35
+ const bound = HTTP_DURATION_BUCKETS_MS[index];
36
+ if (bound !== undefined && durationMs <= bound) {
37
+ const count = histogram.cumulative[index] ?? 0;
38
+ histogram.cumulative[index] = count + 1;
39
+ }
40
+ }
41
+ this.httpRequestDurationMs.set(key, histogram);
15
42
  }
16
43
  renderMetrics() {
17
44
  const lines = [
@@ -21,16 +48,22 @@ class PrometheusRegistry {
21
48
  for (const [key, value] of this.httpRequestsTotal) {
22
49
  lines.push(`http_requests_total{${key}} ${value}`);
23
50
  }
24
- lines.push("# HELP http_request_duration_ms_sum Sum of HTTP request durations in milliseconds.", "# TYPE http_request_duration_ms_sum counter");
25
- for (const [key, samples] of this.httpRequestDurationMs) {
26
- const sum = samples.reduce((total, sample) => total + sample, 0);
27
- lines.push(`http_request_duration_ms_sum{${key}} ${sum}`);
51
+ lines.push("# HELP http_request_duration_ms HTTP request duration in milliseconds.", "# TYPE http_request_duration_ms histogram");
52
+ for (const [key, histogram] of this.httpRequestDurationMs) {
53
+ for (let index = 0;index < HTTP_DURATION_BUCKETS_MS.length; index += 1) {
54
+ const bound = HTTP_DURATION_BUCKETS_MS[index];
55
+ lines.push(`http_request_duration_ms_bucket{${key},le="${bound}"} ${histogram.cumulative[index] ?? 0}`);
56
+ }
57
+ lines.push(`http_request_duration_ms_bucket{${key},le="+Inf"} ${histogram.count}`);
58
+ lines.push(`http_request_duration_ms_sum{${key}} ${histogram.sum}`);
59
+ lines.push(`http_request_duration_ms_count{${key}} ${histogram.count}`);
28
60
  }
29
61
  return `${lines.join(`
30
62
  `)}
31
63
  `;
32
64
  }
33
65
  resetForTests() {
66
+ this.seriesLabels.clear();
34
67
  this.httpRequestsTotal.clear();
35
68
  this.httpRequestDurationMs.clear();
36
69
  }
@@ -40,9 +73,10 @@ class PrometheusRegistry {
40
73
  let totalRequests = 0;
41
74
  for (const [key, count] of this.httpRequestsTotal) {
42
75
  totalRequests += count;
43
- const method = key.match(/method="([^"]+)"/)?.[1] ?? "GET";
44
- const path = key.match(/path="([^"]+)"/)?.[1] ?? "/";
45
- const status = key.match(/status="([^"]+)"/)?.[1] ?? "200";
76
+ const labels = this.seriesLabels.get(key);
77
+ if (!labels)
78
+ continue;
79
+ const { method, path, status } = labels;
46
80
  byStatus[status] = (byStatus[status] ?? 0) + count;
47
81
  const pathKey = `${method} ${path}`;
48
82
  const existing = pathCounts.get(pathKey);
@@ -59,12 +93,38 @@ class PrometheusRegistry {
59
93
  topPaths
60
94
  };
61
95
  }
96
+ getStorageStats() {
97
+ return {
98
+ series: this.seriesLabels.size,
99
+ histograms: this.httpRequestDurationMs.size,
100
+ bucketCounters: this.httpRequestDurationMs.size * HTTP_DURATION_BUCKETS_MS.length
101
+ };
102
+ }
62
103
  metricKey(labels) {
63
- return `method="${labels.method}",path="${labels.path}",status="${labels.status}"`;
104
+ const normalized = {
105
+ method: /^(GET|HEAD|POST|PUT|PATCH|DELETE|CONNECT|OPTIONS|TRACE)$/.test(labels.method) ? labels.method : "OTHER",
106
+ path: labels.path.length <= 512 ? labels.path : "__overflow__",
107
+ status: /^[1-5][0-9]{2}$/.test(labels.status) ? labels.status : "000"
108
+ };
109
+ const escapeLabel = (value) => value.replaceAll("\\", "\\\\").replaceAll(`
110
+ `, "\\n").replaceAll('"', "\\\"");
111
+ const encode = (value) => `method="${value.method}",path="${escapeLabel(value.path)}",status="${value.status}"`;
112
+ let key = encode(normalized);
113
+ if (!this.seriesLabels.has(key)) {
114
+ if (this.seriesLabels.size >= this.maxSeries) {
115
+ const overflow = { method: "OTHER", path: "__overflow__", status: "000" };
116
+ key = encode(overflow);
117
+ this.seriesLabels.set(key, overflow);
118
+ } else {
119
+ this.seriesLabels.set(key, normalized);
120
+ }
121
+ }
122
+ return key;
64
123
  }
65
124
  }
66
125
  var prometheusRegistry = new PrometheusRegistry;
67
126
  export {
127
+ HTTP_DURATION_BUCKETS_MS,
68
128
  PrometheusRegistry,
69
129
  prometheusRegistry
70
130
  };
@@ -124,7 +124,7 @@ var queueConfig = {
124
124
  };
125
125
 
126
126
  // ../../src/core/queue/jobRunner.ts
127
- async function runQueueJob(envelope, failedJobs) {
127
+ async function runQueueJob(envelope, failedJobs, options = {}) {
128
128
  const job = jobRegistry.create(envelope.name);
129
129
  if (!job) {
130
130
  throw new Error(`Unknown job "${envelope.name}".`);
@@ -141,7 +141,7 @@ async function runQueueJob(envelope, failedJobs) {
141
141
  await runQueueJob({
142
142
  ...envelope,
143
143
  attempts: nextAttempt
144
- }, failedJobs);
144
+ }, failedJobs, options);
145
145
  return;
146
146
  }
147
147
  await failedJobs.recordFailure({
@@ -149,6 +149,7 @@ async function runQueueJob(envelope, failedJobs) {
149
149
  payload: envelope.payload,
150
150
  exception: error instanceof Error ? error.stack ?? error.message : String(error)
151
151
  });
152
+ options.onFailureRecorded?.();
152
153
  throw error;
153
154
  }
154
155
  }
@@ -256,6 +257,30 @@ var QUEUE_LIST_KEY = queueListKey();
256
257
  var QUEUE_HIGH_KEY = queueHighKey();
257
258
  var QUEUE_LOW_KEY = queueLowKey();
258
259
  var QUEUE_KEYS = [QUEUE_HIGH_KEY, QUEUE_LIST_KEY, QUEUE_LOW_KEY];
260
+ var DEFAULT_VISIBILITY_MS = 60000;
261
+ function queueProcessingKey(queueKey) {
262
+ return `${queueKey}:processing`;
263
+ }
264
+ function queueProcessingLeaseKey(queueKey) {
265
+ return `${queueKey}:processing:leases`;
266
+ }
267
+ function readVisibilityMs() {
268
+ const parsed = Number(process.env.QUEUE_VISIBILITY_MS ?? String(DEFAULT_VISIBILITY_MS));
269
+ if (!Number.isSafeInteger(parsed) || parsed < 30) {
270
+ throw new Error("QUEUE_VISIBILITY_MS must be an integer of at least 30 milliseconds.");
271
+ }
272
+ return parsed;
273
+ }
274
+ function reservationExpired(lease, now, visibilityMs) {
275
+ if (lease === null || lease.length === 0) {
276
+ return true;
277
+ }
278
+ const reservedAt = Number(lease);
279
+ if (!Number.isFinite(reservedAt)) {
280
+ return true;
281
+ }
282
+ return now - reservedAt >= visibilityMs;
283
+ }
259
284
  function queueKeyForPriority(priority = "default") {
260
285
  switch (priority) {
261
286
  case "high":
@@ -297,6 +322,129 @@ function parseQueueJobEnvelope(rawPayload) {
297
322
  attempts: typeof envelope.attempts === "number" ? envelope.attempts : 0
298
323
  };
299
324
  }
325
+ var SCRIPT_HEADER = `
326
+ local function checkType(key, expected)
327
+ local actual = redis.call('TYPE', key).ok
328
+ if actual ~= 'none' and actual ~= expected then
329
+ return redis.error_reply('Unexpected queue key type: ' .. key)
330
+ end
331
+ end
332
+ local function nowMs()
333
+ local time = redis.call('TIME')
334
+ return tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
335
+ end
336
+ `;
337
+ var RESERVE_JOB = `${SCRIPT_HEADER}
338
+ local err = checkType(KEYS[1], 'list') or checkType(KEYS[2], 'list') or checkType(KEYS[3], 'hash')
339
+ if err then return err end
340
+ local payload = redis.call('LINDEX', KEYS[1], -1)
341
+ if not payload then return nil end
342
+ local record = cjson.encode({strataReservation=1, id=ARGV[1], owner=ARGV[2], payload=payload})
343
+ local lease = cjson.encode({owner=ARGV[2], expiresAt=nowMs()+tonumber(ARGV[3])})
344
+ redis.call('LPUSH', KEYS[2], record)
345
+ redis.call('HSET', KEYS[3], ARGV[1], lease)
346
+ redis.call('RPOP', KEYS[1])
347
+ return record
348
+ `;
349
+ var RENEW_OR_ACK = `${SCRIPT_HEADER}
350
+ local err = checkType(KEYS[1], 'list') or checkType(KEYS[2], 'hash') or checkType(KEYS[3], 'list')
351
+ if err then return err end
352
+ local raw = redis.call('HGET', KEYS[2], ARGV[1])
353
+ if not raw then return 0 end
354
+ local ok, lease = pcall(cjson.decode, raw)
355
+ local now = nowMs()
356
+ if not ok or type(lease) ~= 'table' or lease.owner ~= ARGV[2] or not tonumber(lease.expiresAt) or tonumber(lease.expiresAt) <= now then
357
+ return 0
358
+ end
359
+ if ARGV[4] == 'renew' then
360
+ redis.call('HSET', KEYS[2], ARGV[1], cjson.encode({owner=ARGV[2], expiresAt=now+tonumber(ARGV[5])}))
361
+ return 1
362
+ end
363
+ if ARGV[4] == 'quarantine' then redis.call('LPUSH', KEYS[3], ARGV[6]) end
364
+ local removed = redis.call('LREM', KEYS[1], 1, ARGV[3])
365
+ redis.call('HDEL', KEYS[2], ARGV[1])
366
+ return removed
367
+ `;
368
+ var RECLAIM_JOBS = `${SCRIPT_HEADER}
369
+ local err = checkType(KEYS[1], 'list') or checkType(KEYS[2], 'list') or checkType(KEYS[3], 'hash')
370
+ if err then return err end
371
+ local now = nowMs()
372
+ local reclaimed = 0
373
+ for _, record in ipairs(redis.call('LRANGE', KEYS[2], 0, -1)) do
374
+ local payload, field = record, record
375
+ local modern = false
376
+ local ok, reservation = pcall(cjson.decode, record)
377
+ if ok and type(reservation) == 'table' and reservation.strataReservation == 1 then
378
+ payload, field, modern = reservation.payload, reservation.id, true
379
+ end
380
+ local raw = redis.call('HGET', KEYS[3], field)
381
+ local expired = true
382
+ if raw then
383
+ if modern then
384
+ local leaseOk, lease = pcall(cjson.decode, raw)
385
+ expired = not leaseOk or type(lease) ~= 'table' or not tonumber(lease.expiresAt) or tonumber(lease.expiresAt) <= now
386
+ else
387
+ local reservedAt = tonumber(raw)
388
+ expired = not reservedAt or now - reservedAt >= tonumber(ARGV[1])
389
+ end
390
+ end
391
+ if expired then
392
+ redis.call('LPUSH', KEYS[1], payload)
393
+ redis.call('LREM', KEYS[2], 1, record)
394
+ redis.call('HDEL', KEYS[3], field)
395
+ reclaimed = reclaimed + 1
396
+ end
397
+ end
398
+ return reclaimed
399
+ `;
400
+ function queueInvalidKey(queueKey) {
401
+ return `${queueKey}:invalid`;
402
+ }
403
+ async function reserveQueueJob(client, queueKey, owner) {
404
+ const raw = await client.send("EVAL", [
405
+ RESERVE_JOB,
406
+ "3",
407
+ queueKey,
408
+ queueProcessingKey(queueKey),
409
+ queueProcessingLeaseKey(queueKey),
410
+ crypto.randomUUID(),
411
+ owner,
412
+ String(readVisibilityMs())
413
+ ]);
414
+ if (typeof raw !== "string")
415
+ return null;
416
+ const reservation = JSON.parse(raw);
417
+ return { ...reservation, record: raw };
418
+ }
419
+ async function updateQueueReservation(client, queueKey, reservation, action) {
420
+ return Number(await client.send("EVAL", [
421
+ RENEW_OR_ACK,
422
+ "3",
423
+ queueProcessingKey(queueKey),
424
+ queueProcessingLeaseKey(queueKey),
425
+ queueInvalidKey(queueKey),
426
+ reservation.id,
427
+ reservation.owner,
428
+ reservation.record,
429
+ action,
430
+ String(readVisibilityMs()),
431
+ reservation.payload
432
+ ])) === 1;
433
+ }
434
+ async function reclaimExpiredQueueReservations(client, queueKeys = QUEUE_KEYS) {
435
+ let reclaimed = 0;
436
+ for (const key of queueKeys) {
437
+ reclaimed += Number(await client.send("EVAL", [
438
+ RECLAIM_JOBS,
439
+ "3",
440
+ key,
441
+ queueProcessingKey(key),
442
+ queueProcessingLeaseKey(key),
443
+ String(readVisibilityMs())
444
+ ]));
445
+ }
446
+ return reclaimed;
447
+ }
300
448
 
301
449
  class RedisQueue {
302
450
  client;
@@ -321,12 +469,15 @@ class RedisQueue {
321
469
  class QueueWorker {
322
470
  failedJobs;
323
471
  timeoutSeconds;
472
+ queueKeys;
473
+ owner = crypto.randomUUID();
324
474
  running = false;
325
475
  stopping = false;
326
476
  client;
327
- constructor(redisUrl, failedJobs, timeoutSeconds = 5) {
477
+ constructor(redisUrl, failedJobs, timeoutSeconds = 5, queueKeys = QUEUE_KEYS) {
328
478
  this.failedJobs = failedJobs;
329
479
  this.timeoutSeconds = timeoutSeconds;
480
+ this.queueKeys = queueKeys;
330
481
  this.client = new RedisClient(redisUrl);
331
482
  }
332
483
  requestStop() {
@@ -335,38 +486,81 @@ class QueueWorker {
335
486
  isRunning() {
336
487
  return this.running;
337
488
  }
338
- async processNext() {
339
- let result = null;
340
- for (const queueKey of QUEUE_KEYS) {
341
- result = await this.client.brpop(queueKey, 1);
342
- if (result) {
343
- break;
489
+ async reserve() {
490
+ const deadline = performance.now() + this.timeoutSeconds * 1000;
491
+ do {
492
+ for (const key of this.queueKeys) {
493
+ if (this.stopping)
494
+ return null;
495
+ const reservation = await reserveQueueJob(this.client, key, this.owner);
496
+ if (reservation)
497
+ return { queueKey: key, reservation };
344
498
  }
345
- }
346
- if (!result) {
347
- result = await this.client.brpop(QUEUE_LIST_KEY, this.timeoutSeconds);
348
- }
349
- if (!result) {
499
+ if (this.stopping || performance.now() >= deadline)
500
+ return null;
501
+ await Bun.sleep(Math.min(100, Math.max(1, deadline - performance.now())));
502
+ } while (!this.stopping);
503
+ return null;
504
+ }
505
+ async processNext() {
506
+ if (this.stopping)
350
507
  return false;
351
- }
352
- const [, rawPayload] = result;
353
- const envelope = parseQueueJobEnvelope(rawPayload);
508
+ await reclaimExpiredQueueReservations(this.client, this.queueKeys);
509
+ const reserved = await this.reserve();
510
+ if (!reserved)
511
+ return false;
512
+ const { queueKey, reservation } = reserved;
513
+ const envelope = parseQueueJobEnvelope(reservation.payload);
354
514
  if (!envelope) {
515
+ await updateQueueReservation(this.client, queueKey, reservation, "quarantine");
355
516
  return true;
356
517
  }
518
+ let acknowledge = false;
519
+ let ownershipLost = false;
520
+ let renewal;
521
+ const heartbeat = setInterval(() => {
522
+ if (renewal || ownershipLost)
523
+ return;
524
+ renewal = (async () => {
525
+ try {
526
+ if (!await updateQueueReservation(this.client, queueKey, reservation, "renew")) {
527
+ ownershipLost = true;
528
+ }
529
+ } catch (error) {
530
+ ownershipLost = true;
531
+ console.error("[QueueWorker] Lease renewal failed:", error);
532
+ }
533
+ })().finally(() => {
534
+ renewal = undefined;
535
+ });
536
+ }, Math.max(1, Math.floor(readVisibilityMs() / 3)));
357
537
  try {
358
- await runQueueJob(envelope, this.failedJobs);
538
+ await runQueueJob(envelope, this.failedJobs, {
539
+ onFailureRecorded: () => {
540
+ acknowledge = true;
541
+ }
542
+ });
543
+ acknowledge = true;
359
544
  } catch (error) {
360
545
  console.error("[QueueWorker] Job failed:", error);
546
+ } finally {
547
+ clearInterval(heartbeat);
548
+ await renewal;
549
+ }
550
+ if (acknowledge && !ownershipLost) {
551
+ await updateQueueReservation(this.client, queueKey, reservation, "ack");
361
552
  }
362
553
  return true;
363
554
  }
364
555
  async run() {
365
556
  this.running = true;
366
- while (!this.stopping) {
367
- await this.processNext();
557
+ try {
558
+ while (!this.stopping) {
559
+ await this.processNext();
560
+ }
561
+ } finally {
562
+ this.running = false;
368
563
  }
369
- this.running = false;
370
564
  }
371
565
  close() {
372
566
  this.client.close();
@@ -60,7 +60,7 @@ var queueConfig = {
60
60
  };
61
61
 
62
62
  // ../../src/core/queue/jobRunner.ts
63
- async function runQueueJob(envelope, failedJobs) {
63
+ async function runQueueJob(envelope, failedJobs, options = {}) {
64
64
  const job = jobRegistry.create(envelope.name);
65
65
  if (!job) {
66
66
  throw new Error(`Unknown job "${envelope.name}".`);
@@ -77,7 +77,7 @@ async function runQueueJob(envelope, failedJobs) {
77
77
  await runQueueJob({
78
78
  ...envelope,
79
79
  attempts: nextAttempt
80
- }, failedJobs);
80
+ }, failedJobs, options);
81
81
  return;
82
82
  }
83
83
  await failedJobs.recordFailure({
@@ -85,6 +85,7 @@ async function runQueueJob(envelope, failedJobs) {
85
85
  payload: envelope.payload,
86
86
  exception: error instanceof Error ? error.stack ?? error.message : String(error)
87
87
  });
88
+ options.onFailureRecorded?.();
88
89
  throw error;
89
90
  }
90
91
  }