@orkestrel/middleware 0.0.1 → 0.0.2

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