@orkestrel/middleware 0.0.1 → 0.0.3

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.
@@ -1,4 +1,4 @@
1
- import { isBoolean, isFiniteNumber, isFunction, isRecord, isString } from "@orkestrel/contract";
1
+ import { integerShape, isBoolean, isFiniteNumber, isFunction, isRecord, isString, jsonShape, stringShape } from "@orkestrel/contract";
2
2
  import { HTTPError, clearCookie, clientRateKey, computeBodyETag, isCompressibleType, isHTTPError, isValidRequestId, matchesETag, mergeVary, negotiateEncoding, readSignedCookie, resolveOrigin, resolveSecure, resolveSecurityHeader, signToken, verifyToken, writeSignedCookie } from "@orkestrel/server";
3
3
  import { linkSignal } from "@orkestrel/abort";
4
4
  import { createBudget } from "@orkestrel/budget";
@@ -82,6 +82,32 @@ var DEFAULT_CSRF_SAFE_METHODS = Object.freeze([
82
82
  "OPTIONS"
83
83
  ]);
84
84
  //#endregion
85
+ //#region src/core/Session.ts
86
+ /**
87
+ * A server-managed session's default entity — the `create` option's default
88
+ * value factory for `createSession` (ruling G: `Session` ships WITHOUT a
89
+ * `createSession` factory of its own, since that name belongs to the
90
+ * battery).
91
+ *
92
+ * @remarks
93
+ * `data` is a live, mutable `Map` a handler reads/writes directly;
94
+ * `createSession` persists it to the configured store on the way out.
95
+ *
96
+ * @example
97
+ * ```ts
98
+ * const session = new Session('abc123')
99
+ * session.data.set('userId', 'u_1')
100
+ * ```
101
+ */
102
+ var Session = class {
103
+ id;
104
+ data;
105
+ constructor(id) {
106
+ this.id = id;
107
+ this.data = /* @__PURE__ */ new Map();
108
+ }
109
+ };
110
+ //#endregion
85
111
  //#region src/core/helpers.ts
86
112
  /**
87
113
  * Derive `createLimiter`'s default rate-limit bucket key from a request's
@@ -443,7 +469,10 @@ function transferSessionData(from, to) {
443
469
  }
444
470
  /**
445
471
  * Determine whether a value implements {@link SessionInterface} — a total
446
- * structural guard (§14): an `id` string plus a `data` `Map`.
472
+ * structural guard (§14): an `id` string plus a `data` `Map`. Prototype-agnostic
473
+ * — accepts a plain object, a null-prototype object, AND a class instance
474
+ * (a real `Session`), since a restored/stored session is routinely a class
475
+ * instance, not a literal.
447
476
  *
448
477
  * @param value - The candidate value
449
478
  * @returns `true` when `value` is shaped like a {@link SessionInterface}
@@ -451,11 +480,14 @@ function transferSessionData(from, to) {
451
480
  * @example
452
481
  * ```ts
453
482
  * isSession({ id: 'a', data: new Map() }) // true
483
+ * isSession(new Session('a')) // true
454
484
  * ```
455
485
  */
456
486
  function isSession(value) {
457
- if (!isRecord(value)) return false;
458
- return isString(value.id) && value.data instanceof Map;
487
+ if (typeof value !== "object" || value === null) return false;
488
+ const id = Reflect.get(value, "id");
489
+ const data = Reflect.get(value, "data");
490
+ return isString(id) && data instanceof Map;
459
491
  }
460
492
  /**
461
493
  * Determine whether a value implements {@link SessionControlInterface} — a
@@ -565,34 +597,74 @@ function equalsConstantTime(a, b) {
565
597
  for (let index = 0; index < a.length; index += 1) diff |= a.charCodeAt(index) ^ b.charCodeAt(index);
566
598
  return diff === 0;
567
599
  }
568
- //#endregion
569
- //#region src/core/Session.ts
570
600
  /**
571
- * A server-managed session's default entity the `create` option's default
572
- * value factory for `createSession` (ruling G: `Session` ships WITHOUT a
573
- * `createSession` factory of its own, since that name belongs to the
574
- * battery).
601
+ * Whether a session has aged past its idle timeout or absolute lifetime as
602
+ * of `now` the pure expiry predicate `MemorySessionStore` delegates to.
603
+ *
604
+ * @param cursors - The session's `lastSeen` (idle) and `createdAt` (absolute) instants
605
+ * @param now - The current instant (same clock unit as `cursors`)
606
+ * @param limits - The optional `ttl` (idle) and `lifetime` (absolute) thresholds
607
+ * @returns `true` when either configured threshold has elapsed
608
+ *
609
+ * @example
610
+ * ```ts
611
+ * sessionExpired({ lastSeen: 0, createdAt: 0 }, 1_000, { ttl: 500 }) // true
612
+ * ```
613
+ */
614
+ function sessionExpired(cursors, now, limits) {
615
+ if (limits.ttl !== void 0 && now - cursors.lastSeen >= limits.ttl) return true;
616
+ if (limits.lifetime !== void 0 && now - cursors.createdAt >= limits.lifetime) return true;
617
+ return false;
618
+ }
619
+ /**
620
+ * Snapshot a session's `data` Map into a plain, serializable record — the
621
+ * projection a durable store's `set` writes to disk.
622
+ *
623
+ * @param session - The session to snapshot
624
+ * @returns A plain-object copy of `session.data`, keyed alongside `session.id`
575
625
  *
576
626
  * @remarks
577
- * `data` is a live, mutable `Map` a handler reads/writes directly;
578
- * `createSession` persists it to the configured store on the way out.
627
+ * `data` is built on a null-prototype object (`Object.create(null)`), never
628
+ * a `{}` literal a session key literally named `__proto__` must round-trip
629
+ * as an OWN enumerable property instead of hitting `Object.prototype`'s
630
+ * `__proto__` accessor (which would silently drop the entry and risk
631
+ * polluting the shared prototype).
579
632
  *
580
633
  * @example
581
634
  * ```ts
582
- * const session = new Session('abc123')
583
- * session.data.set('userId', 'u_1')
635
+ * snapshotSession(session) // { id: 'abc', data: { userId: 'u_1' } }
584
636
  * ```
585
637
  */
586
- var Session = class {
587
- id;
588
- data;
589
- constructor(id) {
590
- this.id = id;
591
- this.data = /* @__PURE__ */ new Map();
592
- }
593
- };
638
+ function snapshotSession(session) {
639
+ const data = Object.create(null);
640
+ for (const [key, value] of session.data) data[key] = value;
641
+ return {
642
+ id: session.id,
643
+ data
644
+ };
645
+ }
646
+ /**
647
+ * Rebuild a `Session` from an untrusted snapshot value (the inverse of
648
+ * {@link snapshotSession}) — a durable store's `get` deserialization step.
649
+ *
650
+ * @param value - The candidate snapshot, of unknown shape
651
+ * @returns A rebuilt `Session`, or `undefined` when `value` is malformed
652
+ *
653
+ * @example
654
+ * ```ts
655
+ * restoreSession({ id: 'abc', data: { userId: 'u_1' } }) // Session { id: 'abc', data: Map }
656
+ * restoreSession({ id: 1 }) // undefined
657
+ * ```
658
+ */
659
+ function restoreSession(value) {
660
+ if (!isRecord(value)) return void 0;
661
+ if (!isString(value.id) || !isRecord(value.data)) return void 0;
662
+ const session = new Session(value.id);
663
+ for (const [key, entry] of Object.entries(value.data)) session.data.set(key, entry);
664
+ return session;
665
+ }
594
666
  //#endregion
595
- //#region src/core/MemorySessionStore.ts
667
+ //#region src/core/stores/MemorySessionStore.ts
596
668
  /**
597
669
  * The default in-process {@link SessionStoreInterface} — a `Map`-backed store
598
670
  * enforcing both an idle timeout and an absolute lifetime, with lazy
@@ -668,9 +740,10 @@ var MemorySessionStore = class {
668
740
  this.#entries.delete(id);
669
741
  }
670
742
  #expired(entry, now) {
671
- if (this.#ttl !== void 0 && now - entry.lastSeen >= this.#ttl) return true;
672
- if (this.#lifetime !== void 0 && now - entry.createdAt >= this.#lifetime) return true;
673
- return false;
743
+ return sessionExpired(entry, now, {
744
+ ttl: this.#ttl,
745
+ lifetime: this.#lifetime
746
+ });
674
747
  }
675
748
  #reserve(now) {
676
749
  if (this.#entries.size < this.#capacity) return;
@@ -694,6 +767,79 @@ var MemorySessionStore = class {
694
767
  }
695
768
  };
696
769
  //#endregion
770
+ //#region src/core/stores/DatabaseSessionStore.ts
771
+ /**
772
+ * A durable {@link SessionStoreInterface} over an `@orkestrel/database`
773
+ * table — the same idle-timeout + absolute-lifetime contract as
774
+ * {@link MemorySessionStore}, backed by a caller-supplied `TableInterface`
775
+ * instead of an in-process `Map`.
776
+ *
777
+ * @typeParam S - The session data payload type
778
+ *
779
+ * @remarks
780
+ * `get` reads the row, evicts (removes the row) once `sessionExpired`
781
+ * reports either threshold elapsed, then rebuilds the session via
782
+ * {@link restoreSession} — a malformed snapshot or one that fails the
783
+ * caller's `is` guard resolves `undefined` rather than throwing. A live read
784
+ * touches `lastSeen`. `set` preserves an existing row's `createdAt` across a
785
+ * re-`set` of the same id (stamped once at the first `set`), mirroring
786
+ * {@link MemorySessionStore}. `delete` of an absent id is a no-op (the
787
+ * table's `remove` contract).
788
+ *
789
+ * A malformed-snapshot or failed-guard `undefined` LEAVES the row in place —
790
+ * unlike the expired path, which removes it. This is deliberate: a
791
+ * caller-contextual `is` guard may reject a session that is still perfectly
792
+ * valid for another flow reading the same table (a differently-shaped `S`,
793
+ * a stricter guard mid-rollout), so `get` never destroys data on a guard
794
+ * miss. A row that no caller's guard ever accepts again self-heals once its
795
+ * `ttl`/`lifetime` elapses on a later `get`.
796
+ *
797
+ * @example
798
+ * ```ts
799
+ * const store = new DatabaseSessionStore(table, isSession, { ttl: 60_000 })
800
+ * await store.set('abc', new Session('abc'), Date.now())
801
+ * ```
802
+ */
803
+ var DatabaseSessionStore = class {
804
+ #table;
805
+ #is;
806
+ #ttl;
807
+ #lifetime;
808
+ constructor(table, is, options) {
809
+ this.#table = table;
810
+ this.#is = is;
811
+ this.#ttl = options?.ttl;
812
+ this.#lifetime = options?.lifetime;
813
+ }
814
+ async get(id, now) {
815
+ const row = await this.#table.get(id);
816
+ if (row === void 0) return void 0;
817
+ if (sessionExpired(row, now, {
818
+ ttl: this.#ttl,
819
+ lifetime: this.#lifetime
820
+ })) {
821
+ await this.#table.remove(id);
822
+ return;
823
+ }
824
+ const session = restoreSession(row.session);
825
+ if (session === void 0 || !this.#is(session)) return void 0;
826
+ await this.#table.update(id, { lastSeen: now });
827
+ return session;
828
+ }
829
+ async set(id, session, now) {
830
+ const createdAt = (await this.#table.get(id))?.createdAt ?? now;
831
+ await this.#table.set({
832
+ id,
833
+ session: snapshotSession(session),
834
+ lastSeen: now,
835
+ createdAt
836
+ });
837
+ }
838
+ async delete(id) {
839
+ await this.#table.remove(id);
840
+ }
841
+ };
842
+ //#endregion
697
843
  //#region src/core/middlewares.ts
698
844
  /**
699
845
  * The outermost error-rendering battery — catches a downstream throw and
@@ -1136,9 +1282,10 @@ function createLimiter(options) {
1136
1282
  }
1137
1283
  /**
1138
1284
  * The body-driving battery — eagerly awaits the cached `context.body()` so
1139
- * its throws (or a malformed-JSON `undefined`) surface before the handler runs.
1285
+ * its throws (or a malformed-JSON `undefined`) surface before the handler
1286
+ * runs, and stashes the resolved value onto {@link BodyState.body}.
1140
1287
  *
1141
- * @typeParam TState - The consumer's opaque per-request state type
1288
+ * @typeParam TState - The consumer's opaque per-request state type, must carry {@link BodyState}
1142
1289
  * @returns A `MiddlewareHandler<TState>`
1143
1290
  * @throws {HTTPError} `400` when the request declares `application/json` and the body resolves `undefined`
1144
1291
  *
@@ -1146,7 +1293,9 @@ function createLimiter(options) {
1146
1293
  * The shipped `MiddlewareContext.body()` is a parameterless, server-owned
1147
1294
  * cache (`ServerOptions.limit` governs its size cap) — this battery carries
1148
1295
  * no `limit`/`decompression` options (a deliberate break from the deleted old
1149
- * `createBodyParser` surface, which configured them itself).
1296
+ * `createBodyParser` surface, which configured them itself). `state.body` is
1297
+ * stashed from the SAME awaited call the 400 check reads — `context.body()`
1298
+ * is never invoked twice.
1150
1299
  *
1151
1300
  * @example
1152
1301
  * ```ts
@@ -1156,6 +1305,7 @@ function createLimiter(options) {
1156
1305
  function createBody() {
1157
1306
  return async (request, context, next) => {
1158
1307
  const body = await context.body();
1308
+ context.state.body = body;
1159
1309
  const contentType = request.headers.get("content-type");
1160
1310
  if (contentType !== null && contentType.toLowerCase().startsWith("application/json") && body === void 0) throw new HTTPError(400, "invalid json");
1161
1311
  return next();
@@ -1306,6 +1456,48 @@ function createCSRF(options) {
1306
1456
  return next();
1307
1457
  };
1308
1458
  }
1459
+ /**
1460
+ * Scope a battery to run ONLY on a set of exact pathnames — elsewhere it
1461
+ * steps aside via `next()`.
1462
+ *
1463
+ * @typeParam TState - The consumer's opaque per-request state type
1464
+ * @param paths - One pathname, or a set of pathnames, matched exactly against `context.url.pathname`
1465
+ * @param handler - The battery to run when `paths` matches
1466
+ * @returns A `MiddlewareHandler<TState>`
1467
+ *
1468
+ * @example
1469
+ * ```ts
1470
+ * const scoped = only('/admin', createBearer({ secret }))
1471
+ * ```
1472
+ */
1473
+ function only(paths, handler) {
1474
+ const matches = new Set(typeof paths === "string" ? [paths] : paths);
1475
+ return async (request, context, next) => {
1476
+ if (matches.has(context.url.pathname)) return handler(request, context, next);
1477
+ return next();
1478
+ };
1479
+ }
1480
+ /**
1481
+ * Scope a battery to run everywhere EXCEPT a set of exact pathnames — there
1482
+ * it steps aside via `next()`.
1483
+ *
1484
+ * @typeParam TState - The consumer's opaque per-request state type
1485
+ * @param paths - One pathname, or a set of pathnames, matched exactly against `context.url.pathname`
1486
+ * @param handler - The battery to run when `paths` does not match
1487
+ * @returns A `MiddlewareHandler<TState>`
1488
+ *
1489
+ * @example
1490
+ * ```ts
1491
+ * const scoped = except('/health', createTelemetry({ record }))
1492
+ * ```
1493
+ */
1494
+ function except(paths, handler) {
1495
+ const matches = new Set(typeof paths === "string" ? [paths] : paths);
1496
+ return async (request, context, next) => {
1497
+ if (matches.has(context.url.pathname)) return next();
1498
+ return handler(request, context, next);
1499
+ };
1500
+ }
1309
1501
  //#endregion
1310
1502
  //#region src/core/factories.ts
1311
1503
  /**
@@ -1393,7 +1585,55 @@ function createHeaderTransport(options) {
1393
1585
  function createMemorySessionStore(options) {
1394
1586
  return new MemorySessionStore(options);
1395
1587
  }
1588
+ /**
1589
+ * The `@orkestrel/database` column shape for a {@link SessionRow} table — pass
1590
+ * as-is to `createDatabase({ tables: { sessions: sessionColumns } })` so an
1591
+ * app declaring a durable session table never hand-writes the shape.
1592
+ *
1593
+ * @remarks
1594
+ * `lastSeen`/`createdAt` are `integerShape({ min: 0 })` — the table VALIDATES
1595
+ * them as integers, so `DatabaseSessionStore`'s `now` clock must yield
1596
+ * integer milliseconds (`Date.now()`, the implicit default `createSession`
1597
+ * clock). A fractional clock (`performance.now()`) fails the write with a
1598
+ * validation error; `MemorySessionStore` carries no such column shape and
1599
+ * accepts a fractional clock without complaint.
1600
+ *
1601
+ * @example
1602
+ * ```ts
1603
+ * const db = createDatabase({ driver, tables: { sessions: sessionColumns } })
1604
+ * ```
1605
+ */
1606
+ var sessionColumns = {
1607
+ id: stringShape(),
1608
+ session: jsonShape(),
1609
+ lastSeen: integerShape({ min: 0 }),
1610
+ createdAt: integerShape({ min: 0 })
1611
+ };
1612
+ /**
1613
+ * Create a {@link DatabaseSessionStore} as a {@link SessionStoreInterface} —
1614
+ * the durable counterpart to `createMemorySessionStore`, over a caller-opened
1615
+ * `@orkestrel/database` table (declare it with {@link sessionColumns}).
1616
+ *
1617
+ * @typeParam S - The session data payload type
1618
+ * @param table - The backing `TableInterface<SessionRow>`
1619
+ * @param is - A {@link Guard} narrowing a restored snapshot to `S`
1620
+ * @param options - The idle `ttl` / absolute `lifetime` thresholds
1621
+ * @returns A {@link SessionStoreInterface}
1622
+ *
1623
+ * @remarks
1624
+ * This factory only wraps `new DatabaseSessionStore(...)` — it never opens a
1625
+ * database or driver itself; the caller owns that lifecycle and passes in an
1626
+ * already-open table.
1627
+ *
1628
+ * @example
1629
+ * ```ts
1630
+ * const store = createDatabaseSessionStore(db.table('sessions'), isSession, { ttl: 60_000 })
1631
+ * ```
1632
+ */
1633
+ function createDatabaseSessionStore(table, is, options) {
1634
+ return new DatabaseSessionStore(table, is, options);
1635
+ }
1396
1636
  //#endregion
1397
- export { DEFAULT_BEARER_HEADER, DEFAULT_BEARER_SCHEME, DEFAULT_CLUSTER, DEFAULT_COEP, DEFAULT_COMPRESSION_ENCODINGS, DEFAULT_COMPRESSION_THRESHOLD, DEFAULT_COOP, DEFAULT_CORP, DEFAULT_CORS_HEADERS, DEFAULT_CORS_METHODS, DEFAULT_CSP, DEFAULT_CSRF_COOKIE, DEFAULT_CSRF_FIELD, DEFAULT_CSRF_HEADER, DEFAULT_CSRF_SAFE_METHODS, DEFAULT_DEADLINE_STATUS, DEFAULT_FRAME_OPTIONS, DEFAULT_HSTS, DEFAULT_IDENTIFIER_HEADER, DEFAULT_LIMITER_CAPACITY, DEFAULT_LIMITER_MESSAGE, DEFAULT_PERMISSIONS_POLICY, DEFAULT_REFERRER_POLICY, DEFAULT_SESSION_CAPACITY, DEFAULT_SESSION_COOKIE, DEFAULT_SESSION_HEADER, MemorySessionStore, Session, buildClientInfo, buildRateLimitField, buildRateLimitPolicyField, buildRetryAfter, compressResponse, createBearer, createBody, createBoundary, createCSRF, createCompression, createCookieTransport, createCors, createDeadline, createETag, createForwarded, createHeaderTransport, createLimiter, createMemorySessionStore, createSecurity, createSession, createTelemetry, detectEncodings, equalsConstantTime, isBufferingIneligible, isCompressionNegotiated, isMultipartBody, isMultipartFile, isPreflight, isSession, isSessionControl, matchesTrustedEntry, rebuildResponse, resolveForwardedFor, resolveKey, resolveOptInHeader, transferSessionData };
1637
+ export { DEFAULT_BEARER_HEADER, DEFAULT_BEARER_SCHEME, DEFAULT_CLUSTER, DEFAULT_COEP, DEFAULT_COMPRESSION_ENCODINGS, DEFAULT_COMPRESSION_THRESHOLD, DEFAULT_COOP, DEFAULT_CORP, DEFAULT_CORS_HEADERS, DEFAULT_CORS_METHODS, DEFAULT_CSP, DEFAULT_CSRF_COOKIE, DEFAULT_CSRF_FIELD, DEFAULT_CSRF_HEADER, DEFAULT_CSRF_SAFE_METHODS, DEFAULT_DEADLINE_STATUS, DEFAULT_FRAME_OPTIONS, DEFAULT_HSTS, DEFAULT_IDENTIFIER_HEADER, DEFAULT_LIMITER_CAPACITY, DEFAULT_LIMITER_MESSAGE, DEFAULT_PERMISSIONS_POLICY, DEFAULT_REFERRER_POLICY, DEFAULT_SESSION_CAPACITY, DEFAULT_SESSION_COOKIE, DEFAULT_SESSION_HEADER, DatabaseSessionStore, MemorySessionStore, Session, buildClientInfo, buildRateLimitField, buildRateLimitPolicyField, buildRetryAfter, compressResponse, createBearer, createBody, createBoundary, createCSRF, createCompression, createCookieTransport, createCors, createDatabaseSessionStore, createDeadline, createETag, createForwarded, createHeaderTransport, createLimiter, createMemorySessionStore, createSecurity, createSession, createTelemetry, detectEncodings, equalsConstantTime, except, isBufferingIneligible, isCompressionNegotiated, isMultipartBody, isMultipartFile, isPreflight, isSession, isSessionControl, matchesTrustedEntry, only, rebuildResponse, resolveForwardedFor, resolveKey, resolveOptInHeader, restoreSession, sessionColumns, sessionExpired, snapshotSession, transferSessionData };
1398
1638
 
1399
1639
  //# sourceMappingURL=index.js.map