ingenium 0.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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +943 -0
  3. package/dist/index.cjs +7078 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +4262 -0
  6. package/dist/index.d.ts +4262 -0
  7. package/dist/index.js +6963 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +47 -0
  10. package/src/api-key/middleware.ts +157 -0
  11. package/src/api-key/types.ts +37 -0
  12. package/src/app/scope.ts +392 -0
  13. package/src/app.ts +1752 -0
  14. package/src/body/limit.ts +21 -0
  15. package/src/body/middleware.ts +30 -0
  16. package/src/body/multipart-types.ts +40 -0
  17. package/src/body/multipart.ts +254 -0
  18. package/src/context/body.ts +324 -0
  19. package/src/context/context.ts +650 -0
  20. package/src/context/cookies.ts +282 -0
  21. package/src/context/pool.ts +32 -0
  22. package/src/cors/middleware.ts +182 -0
  23. package/src/cors/types.ts +79 -0
  24. package/src/cron/parser.ts +311 -0
  25. package/src/cron/registry.ts +49 -0
  26. package/src/cron/scheduler.ts +153 -0
  27. package/src/csrf/middleware.ts +224 -0
  28. package/src/csrf/types.ts +65 -0
  29. package/src/errors.ts +148 -0
  30. package/src/idempotency/middleware.ts +197 -0
  31. package/src/idempotency/store.ts +70 -0
  32. package/src/idempotency/types.ts +87 -0
  33. package/src/index.ts +328 -0
  34. package/src/jobs/queue.ts +306 -0
  35. package/src/jobs/registry.ts +82 -0
  36. package/src/jobs/store-memory.ts +113 -0
  37. package/src/jobs/types.ts +135 -0
  38. package/src/jwt/jwks.ts +143 -0
  39. package/src/jwt/middleware.ts +313 -0
  40. package/src/jwt/types.ts +137 -0
  41. package/src/jwt/verify.ts +370 -0
  42. package/src/middleware/compose.ts +94 -0
  43. package/src/middleware/types.ts +37 -0
  44. package/src/negotiation/accept.ts +159 -0
  45. package/src/negotiation/etag.ts +30 -0
  46. package/src/negotiation/format.ts +88 -0
  47. package/src/negotiation/fresh.ts +89 -0
  48. package/src/negotiation/json-etag.ts +122 -0
  49. package/src/negotiation/negotiate.ts +97 -0
  50. package/src/openapi/describe.ts +79 -0
  51. package/src/openapi/extract-params.ts +62 -0
  52. package/src/openapi/generate.ts +251 -0
  53. package/src/openapi/handler.ts +73 -0
  54. package/src/openapi/types.ts +145 -0
  55. package/src/plugin/decorators.ts +100 -0
  56. package/src/plugin/hooks.ts +114 -0
  57. package/src/plugin/types.ts +189 -0
  58. package/src/problem/middleware.ts +55 -0
  59. package/src/problem/serialize.ts +121 -0
  60. package/src/problem/types.ts +68 -0
  61. package/src/proxy/trust.ts +247 -0
  62. package/src/rate-limit/middleware.ts +72 -0
  63. package/src/rate-limit/store.ts +129 -0
  64. package/src/rate-limit/types.ts +60 -0
  65. package/src/response/reflect.ts +93 -0
  66. package/src/router/router.ts +284 -0
  67. package/src/router/trie.ts +309 -0
  68. package/src/router/types.ts +54 -0
  69. package/src/schema/standard.ts +67 -0
  70. package/src/session/middleware.ts +379 -0
  71. package/src/session/store-memory.ts +79 -0
  72. package/src/session/types.ts +95 -0
  73. package/src/sinatra/filters.ts +129 -0
  74. package/src/sinatra/top-level.ts +151 -0
  75. package/src/sse/keep-alive.ts +52 -0
  76. package/src/sse/sse.ts +115 -0
  77. package/src/static/middleware.ts +254 -0
  78. package/src/static/types.ts +31 -0
  79. package/src/transport/http2-helpers.ts +242 -0
  80. package/src/transport/http2.ts +316 -0
  81. package/src/transport/node.ts +261 -0
  82. package/src/transport/shutdown.ts +86 -0
  83. package/src/transport/types.ts +72 -0
  84. package/src/util/safe-json.ts +66 -0
  85. package/src/ws/index.ts +164 -0
  86. package/src/ws/middleware.ts +178 -0
  87. package/src/ws/types.ts +52 -0
  88. package/src/ws/ws-node-adapter.ts +162 -0
@@ -0,0 +1,379 @@
1
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
2
+ import { Buffer } from 'node:buffer'
3
+ import type { IngeniumMiddleware } from '../middleware/types.ts'
4
+ import type { IngeniumContext } from '../context/context.ts'
5
+ import { MemoryStore } from './store-memory.ts'
6
+ import type { Session, SessionCookieOptions, SessionOptions, SessionStore } from './types.ts'
7
+
8
+ // ───── Constants ────────────────────────────────────────────────────────────
9
+
10
+ const DEFAULT_COOKIE_NAME = 'ingenium.sid'
11
+ const DEFAULT_MAX_AGE_SECONDS = 60 * 60 * 24 * 7 // 7 days
12
+ /** ID byte length — 18 bytes → 24 base64url chars, ~144 bits of entropy. */
13
+ const ID_BYTES = 18
14
+
15
+ // ───── Cookie helpers ───────────────────────────────────────────────────────
16
+ // TODO: migrate to ctx.cookies — kept inline for now because session has
17
+ // specific behaviours (rolling, secret-rotation re-signing, destroy-on-commit)
18
+ // that don't map 1:1 onto the generic cookie API.
19
+
20
+ /**
21
+ * Parse a `Cookie` request header into a name→value map. Handles:
22
+ * - Multiple cookies separated by `;` (with optional whitespace)
23
+ * - Quoted values: `name="quoted value"`
24
+ * - Percent-encoded characters via `decodeURIComponent`
25
+ * - Duplicate names: first occurrence wins (matches RFC 6265 §5.4 typical behaviour)
26
+ *
27
+ * Malformed pairs are skipped silently — this is a defensive parser that
28
+ * never throws on user input.
29
+ */
30
+ export function parseCookieHeader(header: string | undefined): Record<string, string> {
31
+ const out: Record<string, string> = Object.create(null) as Record<string, string>
32
+ if (!header) return out
33
+
34
+ const parts = header.split(';')
35
+ for (let i = 0; i < parts.length; i++) {
36
+ const part = parts[i]!
37
+ const eq = part.indexOf('=')
38
+ if (eq < 0) continue
39
+ const name = part.slice(0, eq).trim()
40
+ if (!name || name in out) continue
41
+ let value = part.slice(eq + 1).trim()
42
+ // Strip surrounding double quotes.
43
+ if (value.length >= 2 && value.charCodeAt(0) === 0x22 && value.charCodeAt(value.length - 1) === 0x22) {
44
+ value = value.slice(1, -1)
45
+ }
46
+ try {
47
+ out[name] = decodeURIComponent(value)
48
+ } catch {
49
+ // Bad percent-encoding — keep raw value rather than throwing.
50
+ out[name] = value
51
+ }
52
+ }
53
+ return out
54
+ }
55
+
56
+ /**
57
+ * Serialize a single `Set-Cookie` value. We implement this inline to avoid
58
+ * pulling in `cookie` as a dependency.
59
+ *
60
+ * `maxAge` is in seconds; when supplied we also emit an absolute `Expires`
61
+ * for older clients that ignore `Max-Age`.
62
+ */
63
+ export function serializeCookie(
64
+ name: string,
65
+ value: string,
66
+ opts: SessionCookieOptions & { maxAge?: number } = {},
67
+ ): string {
68
+ // Encode the value so semicolons / whitespace cannot break the header.
69
+ const segments: string[] = [`${name}=${encodeURIComponent(value)}`]
70
+ if (opts.domain) segments.push(`Domain=${opts.domain}`)
71
+ segments.push(`Path=${opts.path ?? '/'}`)
72
+
73
+ if (typeof opts.maxAge === 'number') {
74
+ // Floor — Max-Age must be an integer.
75
+ const ma = Math.floor(opts.maxAge)
76
+ segments.push(`Max-Age=${ma}`)
77
+ const expires = new Date(Date.now() + ma * 1000)
78
+ segments.push(`Expires=${expires.toUTCString()}`)
79
+ }
80
+
81
+ if (opts.httpOnly !== false) segments.push('HttpOnly')
82
+ if (opts.secure) segments.push('Secure')
83
+ const sameSite = opts.sameSite ?? 'lax'
84
+ segments.push(`SameSite=${sameSite[0]!.toUpperCase()}${sameSite.slice(1)}`)
85
+
86
+ return segments.join('; ')
87
+ }
88
+
89
+ /**
90
+ * Append a `Set-Cookie` value to the response, preserving any existing
91
+ * `Set-Cookie` header(s) from earlier middleware.
92
+ */
93
+ function appendSetCookie(ctx: IngeniumContext, value: string): void {
94
+ const existing = ctx.getHeader('set-cookie')
95
+ if (!existing) {
96
+ ctx.set('set-cookie', value)
97
+ } else if (Array.isArray(existing)) {
98
+ ctx.set('set-cookie', [...existing, value])
99
+ } else {
100
+ ctx.set('set-cookie', [existing, value])
101
+ }
102
+ }
103
+
104
+ // ───── Signing ──────────────────────────────────────────────────────────────
105
+
106
+ /** HMAC-SHA-256 the id with `secret`, base64url-encoded. */
107
+ function signId(id: string, secret: string): string {
108
+ return createHmac('sha256', secret).update(id).digest('base64url')
109
+ }
110
+
111
+ /**
112
+ * Verify `cookieValue` (`<id>.<sig>`) against any of the supplied secrets.
113
+ * Returns the id and the index of the matching secret, or `null`.
114
+ *
115
+ * Uses {@link timingSafeEqual} to defeat byte-wise timing oracles.
116
+ */
117
+ function verifySigned(
118
+ cookieValue: string,
119
+ secrets: readonly string[],
120
+ ): { id: string; secretIndex: number } | null {
121
+ const dot = cookieValue.lastIndexOf('.')
122
+ if (dot <= 0 || dot >= cookieValue.length - 1) return null
123
+ const id = cookieValue.slice(0, dot)
124
+ const sig = cookieValue.slice(dot + 1)
125
+ const sigBuf = Buffer.from(sig, 'base64url')
126
+ if (sigBuf.length === 0) return null
127
+
128
+ for (let i = 0; i < secrets.length; i++) {
129
+ const expected = Buffer.from(signId(id, secrets[i]!), 'base64url')
130
+ if (expected.length !== sigBuf.length) continue
131
+ if (timingSafeEqual(expected, sigBuf)) return { id, secretIndex: i }
132
+ }
133
+ return null
134
+ }
135
+
136
+ /** Generate a fresh, opaque session id. */
137
+ function newId(): string {
138
+ return randomBytes(ID_BYTES).toString('base64url')
139
+ }
140
+
141
+ // ───── Session implementation ───────────────────────────────────────────────
142
+
143
+ /**
144
+ * @internal Mutable-by-design implementation of {@link Session}. The public
145
+ * `data` field is exposed via `Object.freeze` to keep callers from mutating
146
+ * around the dirty-tracking surface.
147
+ */
148
+ class SessionImpl implements Session {
149
+ /** Tracks whether the session needs to be persisted on response. */
150
+ dirty: boolean
151
+ /** True when no record existed in the store at request start. */
152
+ readonly isNew: boolean
153
+ /** True after `destroy()` — middleware will clear cookie + store. */
154
+ destroyed = false
155
+
156
+ private _id: string
157
+ private _data: Record<string, unknown>
158
+
159
+ constructor(
160
+ id: string,
161
+ data: Record<string, unknown>,
162
+ isNew: boolean,
163
+ private readonly store: SessionStore,
164
+ /** Set to `true` when secret rotation requires re-signing on response. */
165
+ public needsResign: boolean,
166
+ ) {
167
+ this._id = id
168
+ this._data = data
169
+ this.isNew = isNew
170
+ // A brand-new session with no data is NOT dirty — we don't want to
171
+ // create empty rows or cookies for every anonymous request.
172
+ this.dirty = false
173
+ }
174
+
175
+ get id(): string {
176
+ return this._id
177
+ }
178
+
179
+ get data(): Readonly<Record<string, unknown>> {
180
+ return Object.freeze({ ...this._data })
181
+ }
182
+
183
+ get<T = unknown>(key: string): T | undefined {
184
+ return this._data[key] as T | undefined
185
+ }
186
+
187
+ set(key: string, value: unknown): void {
188
+ this._data[key] = value
189
+ this.dirty = true
190
+ }
191
+
192
+ delete(key: string): void {
193
+ if (key in this._data) {
194
+ delete this._data[key]
195
+ this.dirty = true
196
+ }
197
+ }
198
+
199
+ async destroy(): Promise<void> {
200
+ await this.store.destroy(this._id)
201
+ this._data = Object.create(null) as Record<string, unknown>
202
+ this.destroyed = true
203
+ this.dirty = false
204
+ }
205
+
206
+ async regenerate(): Promise<void> {
207
+ const oldId = this._id
208
+ this._id = newId()
209
+ // Old id must die so a stolen cookie cannot resurrect the session.
210
+ await this.store.destroy(oldId)
211
+ this.dirty = true
212
+ this.needsResign = true
213
+ }
214
+
215
+ /** @internal Snapshot for persistence; cheap shallow clone. */
216
+ snapshot(): Record<string, unknown> {
217
+ return { ...this._data }
218
+ }
219
+ }
220
+
221
+ // ───── Middleware factory ───────────────────────────────────────────────────
222
+
223
+ /**
224
+ * Cookie-backed session middleware.
225
+ *
226
+ * The middleware attaches a {@link Session} instance at `ctx.session`. To
227
+ * make this typesafe in user code, augment the `IngeniumContext` interface in
228
+ * your own project:
229
+ *
230
+ * @example
231
+ * ```ts
232
+ * declare module 'ingenium' {
233
+ * interface IngeniumContext { session: import('ingenium').Session }
234
+ * }
235
+ *
236
+ * import { ingenium, sessionMiddleware } from 'ingenium'
237
+ * const app = ingenium()
238
+ * app.use(sessionMiddleware({ secret: process.env.SESSION_SECRET! }))
239
+ *
240
+ * app.get('/me', (ctx) => ({ user: ctx.session.get('user') }))
241
+ * app.post('/login', async (ctx) => {
242
+ * ctx.session.set('user', { id: 1 })
243
+ * await ctx.session.regenerate() // mitigate session fixation
244
+ * })
245
+ * ```
246
+ *
247
+ * Security choices:
248
+ * - HMAC-SHA-256 over the session id, base64url-encoded; verified with
249
+ * `timingSafeEqual`.
250
+ * - 144-bit (18-byte) random ids.
251
+ * - Defaults: `HttpOnly`, `SameSite=Lax`, `Path=/`. Set `secure: true`
252
+ * behind TLS to enable `Secure`.
253
+ * - Tampered or unknown cookies silently issue a fresh session — never an
254
+ * error response, since this is an attacker-influenced surface.
255
+ */
256
+ export function sessionMiddleware(opts: SessionOptions): IngeniumMiddleware {
257
+ // ── Construction-time validation ─────────────────────────────────────────
258
+ const secrets: readonly string[] = Array.isArray(opts.secret)
259
+ ? opts.secret.slice()
260
+ : [opts.secret]
261
+ if (secrets.length === 0 || secrets.some((s) => typeof s !== 'string' || s.length === 0)) {
262
+ throw new Error('sessionMiddleware: `secret` must be a non-empty string or non-empty string[]')
263
+ }
264
+
265
+ const cookieName = opts.cookieName ?? DEFAULT_COOKIE_NAME
266
+ const maxAgeSeconds = opts.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS
267
+ const rolling = opts.rolling ?? false
268
+ const cookieOpts: SessionCookieOptions = opts.cookie ?? {}
269
+ const store: SessionStore = opts.store ?? new MemoryStore()
270
+
271
+ return async (ctx, next) => {
272
+ const cookies = parseCookieHeader(ctx.headers.cookie as string | undefined)
273
+ const raw = cookies[cookieName]
274
+
275
+ let id: string
276
+ let data: Record<string, unknown>
277
+ let isNew: boolean
278
+ let needsResign = false
279
+
280
+ if (raw) {
281
+ const verified = verifySigned(raw, secrets)
282
+ if (verified) {
283
+ const loaded = await store.get(verified.id)
284
+ if (loaded) {
285
+ id = verified.id
286
+ data = { ...loaded }
287
+ isNew = false
288
+ // If verified by anything other than the active key, re-sign.
289
+ if (verified.secretIndex !== 0) needsResign = true
290
+ } else {
291
+ // Cookie validly signed but store has nothing — treat as new.
292
+ id = newId()
293
+ data = Object.create(null) as Record<string, unknown>
294
+ isNew = true
295
+ }
296
+ } else {
297
+ // Bad signature → silently issue a new session.
298
+ id = newId()
299
+ data = Object.create(null) as Record<string, unknown>
300
+ isNew = true
301
+ }
302
+ } else {
303
+ id = newId()
304
+ data = Object.create(null) as Record<string, unknown>
305
+ isNew = true
306
+ }
307
+
308
+ const session = new SessionImpl(id, data, isNew, store, needsResign)
309
+ // Decorator-by-assignment. Type augmentation (see JSDoc above) keeps
310
+ // this typesafe in user code without polluting the shared prototype.
311
+ ;(ctx as unknown as { session: Session }).session = session
312
+
313
+ try {
314
+ await next()
315
+ } finally {
316
+ await commit(ctx, session, secrets[0]!, cookieName, maxAgeSeconds, rolling, cookieOpts, store)
317
+ }
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Persist session changes and write the appropriate `Set-Cookie` header.
323
+ * Runs in `finally` so we still clean up after handler errors.
324
+ */
325
+ async function commit(
326
+ ctx: IngeniumContext,
327
+ session: SessionImpl,
328
+ signingSecret: string,
329
+ cookieName: string,
330
+ maxAgeSeconds: number,
331
+ rolling: boolean,
332
+ cookieOpts: SessionCookieOptions,
333
+ store: SessionStore,
334
+ ): Promise<void> {
335
+ if (session.destroyed) {
336
+ // Clear cookie. Max-Age=0 is the cross-browser way to expire immediately.
337
+ appendSetCookie(
338
+ ctx,
339
+ serializeCookie(cookieName, '', { ...cookieOpts, maxAge: 0 }),
340
+ )
341
+ return
342
+ }
343
+
344
+ // Spec: persist + cookie when session is dirty OR new. Persisting empty
345
+ // new sessions is intentional — it lets handlers rely on a stable id
346
+ // across requests for anon flows (CSRF tokens, A/B buckets, etc.).
347
+ const shouldPersist = session.dirty || session.isNew
348
+
349
+ if (shouldPersist) {
350
+ await store.set(session.id, session.snapshot(), maxAgeSeconds)
351
+ const signed = `${session.id}.${signId(session.id, signingSecret)}`
352
+ appendSetCookie(
353
+ ctx,
354
+ serializeCookie(cookieName, signed, { ...cookieOpts, maxAge: maxAgeSeconds }),
355
+ )
356
+ return
357
+ }
358
+
359
+ // Re-sign without re-persisting (e.g. secret rotation on a clean read).
360
+ if (session.needsResign && !session.isNew) {
361
+ const signed = `${session.id}.${signId(session.id, signingSecret)}`
362
+ appendSetCookie(
363
+ ctx,
364
+ serializeCookie(cookieName, signed, { ...cookieOpts, maxAge: maxAgeSeconds }),
365
+ )
366
+ if (rolling && store.touch) await store.touch(session.id, maxAgeSeconds)
367
+ return
368
+ }
369
+
370
+ // Rolling: refresh TTL + cookie even when nothing changed.
371
+ if (rolling && !session.isNew) {
372
+ if (store.touch) await store.touch(session.id, maxAgeSeconds)
373
+ const signed = `${session.id}.${signId(session.id, signingSecret)}`
374
+ appendSetCookie(
375
+ ctx,
376
+ serializeCookie(cookieName, signed, { ...cookieOpts, maxAge: maxAgeSeconds }),
377
+ )
378
+ }
379
+ }
@@ -0,0 +1,79 @@
1
+ import type { SessionStore } from './types.ts'
2
+
3
+ interface Entry {
4
+ data: Record<string, unknown>
5
+ expiresAt: number
6
+ }
7
+
8
+ /**
9
+ * In-process session store backed by a `Map`. Suitable for development and
10
+ * single-instance deployments. NOT shared across workers/replicas.
11
+ *
12
+ * Expired entries are evicted lazily on access AND periodically by a
13
+ * background sweep. The sweep timer is `unref()`'d so it never keeps the
14
+ * Node process alive on its own.
15
+ */
16
+ export class MemoryStore implements SessionStore {
17
+ private readonly map = new Map<string, Entry>()
18
+ private readonly sweep: NodeJS.Timeout | null
19
+
20
+ /**
21
+ * @param sweepIntervalMs How often to scan the map for expired entries.
22
+ * Defaults to 60s. Pass `0` to disable the timer entirely (tests).
23
+ */
24
+ constructor(sweepIntervalMs = 60_000) {
25
+ if (sweepIntervalMs > 0) {
26
+ this.sweep = setInterval(() => this.purge(), sweepIntervalMs)
27
+ // Don't keep the event loop alive just for the sweep.
28
+ this.sweep.unref?.()
29
+ } else {
30
+ this.sweep = null
31
+ }
32
+ }
33
+
34
+ async get(id: string): Promise<Record<string, unknown> | null> {
35
+ const entry = this.map.get(id)
36
+ if (!entry) return null
37
+ if (entry.expiresAt <= Date.now()) {
38
+ this.map.delete(id)
39
+ return null
40
+ }
41
+ return entry.data
42
+ }
43
+
44
+ async set(id: string, data: Record<string, unknown>, ttlSeconds: number): Promise<void> {
45
+ this.map.set(id, { data, expiresAt: Date.now() + ttlSeconds * 1000 })
46
+ }
47
+
48
+ async destroy(id: string): Promise<void> {
49
+ this.map.delete(id)
50
+ }
51
+
52
+ async touch(id: string, ttlSeconds: number): Promise<void> {
53
+ const entry = this.map.get(id)
54
+ if (!entry) return
55
+ entry.expiresAt = Date.now() + ttlSeconds * 1000
56
+ }
57
+
58
+ /**
59
+ * Stop the background sweep timer. Useful in tests / graceful shutdown.
60
+ * After this call the store still works but expired entries are only
61
+ * evicted on access.
62
+ */
63
+ stop(): void {
64
+ if (this.sweep) clearInterval(this.sweep)
65
+ }
66
+
67
+ /** @internal Test helper: number of live (non-expired) entries. */
68
+ size(): number {
69
+ this.purge()
70
+ return this.map.size
71
+ }
72
+
73
+ private purge(): void {
74
+ const now = Date.now()
75
+ for (const [id, entry] of this.map) {
76
+ if (entry.expiresAt <= now) this.map.delete(id)
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Session middleware types.
3
+ *
4
+ * @see ./middleware.ts for the {@link sessionMiddleware} factory and the
5
+ * module-augmentation pattern users opt into for typed `ctx.session`.
6
+ */
7
+
8
+ /** Cookie attribute overrides. */
9
+ export interface SessionCookieOptions {
10
+ /** Cookie `Domain` attribute. Omitted when undefined. */
11
+ domain?: string
12
+ /** Cookie `Path` attribute. @default '/' */
13
+ path?: string
14
+ /** Cookie `HttpOnly` attribute. @default true */
15
+ httpOnly?: boolean
16
+ /** Cookie `SameSite` attribute. @default 'lax' */
17
+ sameSite?: 'lax' | 'strict' | 'none'
18
+ /** Cookie `Secure` attribute. @default false */
19
+ secure?: boolean
20
+ }
21
+
22
+ /** Options accepted by {@link sessionMiddleware}. */
23
+ export interface SessionOptions {
24
+ /**
25
+ * HMAC secret(s) for signing the session-id cookie.
26
+ *
27
+ * - Single string: used for both signing and verification.
28
+ * - Array: index `0` is the active signing key; ALL entries are accepted
29
+ * for verification, enabling key rotation. Cookies signed with an older
30
+ * key are re-signed with the active key on the next response.
31
+ */
32
+ secret: string | string[]
33
+ /** Name of the session cookie. @default 'ingenium.sid' */
34
+ cookieName?: string
35
+ /** Cookie / store TTL in seconds. @default 604800 (7 days) */
36
+ maxAgeSeconds?: number
37
+ /**
38
+ * If true, the cookie expiry and store TTL are refreshed on every request,
39
+ * even when the session data did not change. @default false
40
+ */
41
+ rolling?: boolean
42
+ /** Cookie attribute overrides. */
43
+ cookie?: SessionCookieOptions
44
+ /**
45
+ * Backing store. Defaults to an in-process {@link MemoryStore} which is
46
+ * NOT suitable for clustered deployments — supply your own for Redis,
47
+ * Postgres, etc.
48
+ */
49
+ store?: SessionStore
50
+ }
51
+
52
+ /**
53
+ * Per-request session handle attached as `ctx.session`.
54
+ *
55
+ * Mutations (`set`, `delete`, `destroy`, `regenerate`) mark the session as
56
+ * dirty so the middleware persists changes after the handler returns.
57
+ */
58
+ export interface Session {
59
+ /** Stable, opaque session id (rotated by {@link Session.regenerate}). */
60
+ readonly id: string
61
+ /** Frozen view of the session data. */
62
+ readonly data: Readonly<Record<string, unknown>>
63
+ /** Read a value from the session. */
64
+ get<T = unknown>(key: string): T | undefined
65
+ /** Write a value into the session. Marks the session dirty. */
66
+ set(key: string, value: unknown): void
67
+ /** Remove a key from the session. Marks the session dirty. */
68
+ delete(key: string): void
69
+ /** Drop the session: remove from store + clear the cookie. */
70
+ destroy(): Promise<void>
71
+ /**
72
+ * Issue a new session id while preserving the current data. The old id is
73
+ * removed from the store. Use after privilege changes (e.g. login) to
74
+ * mitigate session-fixation attacks.
75
+ */
76
+ regenerate(): Promise<void>
77
+ }
78
+
79
+ /**
80
+ * Pluggable session storage. Implementations must be safe to call
81
+ * concurrently for distinct ids; per-id ordering is the caller's concern.
82
+ */
83
+ export interface SessionStore {
84
+ /** Look up a session by id. Returns `null` for unknown / expired ids. */
85
+ get(id: string): Promise<Record<string, unknown> | null>
86
+ /** Persist `data` under `id` with the given TTL (seconds). */
87
+ set(id: string, data: Record<string, unknown>, ttlSeconds: number): Promise<void>
88
+ /** Remove a session entirely. No-op if it does not exist. */
89
+ destroy(id: string): Promise<void>
90
+ /**
91
+ * OPTIONAL: extend an existing session's TTL without rewriting its data.
92
+ * Used by `rolling` sessions on requests that did not mutate state.
93
+ */
94
+ touch?(id: string, ttlSeconds: number): Promise<void>
95
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Sinatra-style `before` / `after` filters.
3
+ *
4
+ * `before(pattern?, handler)` runs BEFORE the route handler. Equivalent to
5
+ * app.use(prefix, mw) // when pattern is given
6
+ * app.use(mw) // when pattern is omitted
7
+ * The user writes only the body of the filter — the wrapper invokes
8
+ * `await next()` automatically. If the filter calls a response writer
9
+ * (`ctx.json`, `ctx.text`, ...) the chain short-circuits because `next()`
10
+ * is never called, so the route handler does not run.
11
+ *
12
+ * `after(pattern?, handler)` runs AFTER the route handler resolves but
13
+ * BEFORE the adapter writes to the wire. The wrapper calls `await next()`
14
+ * first and then runs the user filter, so the filter can observe the final
15
+ * response state on `ctx`.
16
+ *
17
+ * Pattern semantics in v0.0.1:
18
+ * - Simple boundary-respecting prefix match (reuses the same
19
+ * `pathStartsWith` rule the app uses for scoped middleware).
20
+ * - `'/admin/*'` and `'/admin'` both match `/admin` and `/admin/users`
21
+ * but neither matches `/administrator`. The trailing `/*` is sugar
22
+ * and is stripped before matching.
23
+ * - Regex patterns and trailing-slash flexibility are out of scope.
24
+ */
25
+
26
+ import type { IngeniumMiddleware } from '../middleware/types.ts'
27
+ import type { IngeniumApp } from '../app.ts'
28
+
29
+ /**
30
+ * Strip the Sinatra-style trailing `/*` (or bare `*`) from a prefix so that
31
+ * `/admin/*` and `/admin` both reduce to `/admin` for prefix matching.
32
+ * A bare `*` (or `/*`) means "every path" → empty prefix.
33
+ */
34
+ function normalizeFilterPattern(pattern: string): string {
35
+ if (pattern === '*' || pattern === '/*' || pattern === '/') return ''
36
+ if (pattern.endsWith('/*')) return pattern.slice(0, -2)
37
+ if (pattern.endsWith('*')) return pattern.slice(0, -1)
38
+ return pattern
39
+ }
40
+
41
+ /**
42
+ * Wrap a user `before` filter so it auto-calls `next()` after its body runs.
43
+ * If the filter throws, the error propagates to the framework error boundary.
44
+ * If the filter writes a response (and never calls `next()`), it
45
+ * short-circuits — but since the wrapper IS the one that calls `next()`, we
46
+ * detect short-circuit by checking `ctx._written` after the user filter
47
+ * resolves and skip the downstream chain in that case.
48
+ */
49
+ function wrapBefore(handler: IngeniumMiddleware): IngeniumMiddleware {
50
+ return async (ctx, next) => {
51
+ // The handler may receive a no-op `next` of its own — but for ergonomic
52
+ // Sinatra parity we want it to look handler-shaped (just `(ctx) => ...`).
53
+ // We pass a `noopNext` and inspect ctx._written afterward to decide
54
+ // whether to invoke the real downstream chain.
55
+ const noopNext = async (): Promise<void> => {}
56
+ await handler(ctx, noopNext)
57
+ // Short-circuit if the filter wrote a response — don't run the route.
58
+ if ((ctx as unknown as { _written?: boolean })._written) return
59
+ await next()
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Wrap a user `after` filter so it runs only AFTER the downstream chain
65
+ * resolves. The filter sees the final response state on `ctx` (status code,
66
+ * headers, body buffer). Errors thrown by the filter propagate to the
67
+ * framework error boundary just like errors from any other middleware.
68
+ */
69
+ function wrapAfter(handler: IngeniumMiddleware): IngeniumMiddleware {
70
+ return async (ctx, next) => {
71
+ await next()
72
+ const noopNext = async (): Promise<void> => {}
73
+ await handler(ctx, noopNext)
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Register a `before` filter on `app`. If `pattern` is omitted, the filter
79
+ * is registered as a global middleware (runs for every request). Otherwise
80
+ * the pattern is normalized and registered as a path-scoped middleware.
81
+ */
82
+ export function registerBefore(
83
+ app: IngeniumApp,
84
+ patternOrHandler: string | IngeniumMiddleware,
85
+ maybeHandler?: IngeniumMiddleware,
86
+ ): IngeniumApp {
87
+ if (typeof patternOrHandler === 'function') {
88
+ app.use(wrapBefore(patternOrHandler))
89
+ return app
90
+ }
91
+ if (typeof maybeHandler !== 'function') {
92
+ throw new TypeError('before(pattern, handler): handler must be a function')
93
+ }
94
+ const prefix = normalizeFilterPattern(patternOrHandler)
95
+ if (prefix === '') {
96
+ app.use(wrapBefore(maybeHandler))
97
+ } else {
98
+ app.use(prefix, wrapBefore(maybeHandler))
99
+ }
100
+ return app
101
+ }
102
+
103
+ /**
104
+ * Register an `after` filter on `app`. Same pattern semantics as
105
+ * `registerBefore`, but the user body runs after the downstream chain.
106
+ */
107
+ export function registerAfter(
108
+ app: IngeniumApp,
109
+ patternOrHandler: string | IngeniumMiddleware,
110
+ maybeHandler?: IngeniumMiddleware,
111
+ ): IngeniumApp {
112
+ if (typeof patternOrHandler === 'function') {
113
+ app.use(wrapAfter(patternOrHandler))
114
+ return app
115
+ }
116
+ if (typeof maybeHandler !== 'function') {
117
+ throw new TypeError('after(pattern, handler): handler must be a function')
118
+ }
119
+ const prefix = normalizeFilterPattern(patternOrHandler)
120
+ if (prefix === '') {
121
+ app.use(wrapAfter(maybeHandler))
122
+ } else {
123
+ app.use(prefix, wrapAfter(maybeHandler))
124
+ }
125
+ return app
126
+ }
127
+
128
+ /** @internal Exposed for tests. */
129
+ export const _internal_normalizeFilterPattern = normalizeFilterPattern