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.
- package/LICENSE +21 -0
- package/README.md +943 -0
- package/dist/index.cjs +7078 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +4262 -0
- package/dist/index.d.ts +4262 -0
- package/dist/index.js +6963 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
- package/src/api-key/middleware.ts +157 -0
- package/src/api-key/types.ts +37 -0
- package/src/app/scope.ts +392 -0
- package/src/app.ts +1752 -0
- package/src/body/limit.ts +21 -0
- package/src/body/middleware.ts +30 -0
- package/src/body/multipart-types.ts +40 -0
- package/src/body/multipart.ts +254 -0
- package/src/context/body.ts +324 -0
- package/src/context/context.ts +650 -0
- package/src/context/cookies.ts +282 -0
- package/src/context/pool.ts +32 -0
- package/src/cors/middleware.ts +182 -0
- package/src/cors/types.ts +79 -0
- package/src/cron/parser.ts +311 -0
- package/src/cron/registry.ts +49 -0
- package/src/cron/scheduler.ts +153 -0
- package/src/csrf/middleware.ts +224 -0
- package/src/csrf/types.ts +65 -0
- package/src/errors.ts +148 -0
- package/src/idempotency/middleware.ts +197 -0
- package/src/idempotency/store.ts +70 -0
- package/src/idempotency/types.ts +87 -0
- package/src/index.ts +328 -0
- package/src/jobs/queue.ts +306 -0
- package/src/jobs/registry.ts +82 -0
- package/src/jobs/store-memory.ts +113 -0
- package/src/jobs/types.ts +135 -0
- package/src/jwt/jwks.ts +143 -0
- package/src/jwt/middleware.ts +313 -0
- package/src/jwt/types.ts +137 -0
- package/src/jwt/verify.ts +370 -0
- package/src/middleware/compose.ts +94 -0
- package/src/middleware/types.ts +37 -0
- package/src/negotiation/accept.ts +159 -0
- package/src/negotiation/etag.ts +30 -0
- package/src/negotiation/format.ts +88 -0
- package/src/negotiation/fresh.ts +89 -0
- package/src/negotiation/json-etag.ts +122 -0
- package/src/negotiation/negotiate.ts +97 -0
- package/src/openapi/describe.ts +79 -0
- package/src/openapi/extract-params.ts +62 -0
- package/src/openapi/generate.ts +251 -0
- package/src/openapi/handler.ts +73 -0
- package/src/openapi/types.ts +145 -0
- package/src/plugin/decorators.ts +100 -0
- package/src/plugin/hooks.ts +114 -0
- package/src/plugin/types.ts +189 -0
- package/src/problem/middleware.ts +55 -0
- package/src/problem/serialize.ts +121 -0
- package/src/problem/types.ts +68 -0
- package/src/proxy/trust.ts +247 -0
- package/src/rate-limit/middleware.ts +72 -0
- package/src/rate-limit/store.ts +129 -0
- package/src/rate-limit/types.ts +60 -0
- package/src/response/reflect.ts +93 -0
- package/src/router/router.ts +284 -0
- package/src/router/trie.ts +309 -0
- package/src/router/types.ts +54 -0
- package/src/schema/standard.ts +67 -0
- package/src/session/middleware.ts +379 -0
- package/src/session/store-memory.ts +79 -0
- package/src/session/types.ts +95 -0
- package/src/sinatra/filters.ts +129 -0
- package/src/sinatra/top-level.ts +151 -0
- package/src/sse/keep-alive.ts +52 -0
- package/src/sse/sse.ts +115 -0
- package/src/static/middleware.ts +254 -0
- package/src/static/types.ts +31 -0
- package/src/transport/http2-helpers.ts +242 -0
- package/src/transport/http2.ts +316 -0
- package/src/transport/node.ts +261 -0
- package/src/transport/shutdown.ts +86 -0
- package/src/transport/types.ts +72 -0
- package/src/util/safe-json.ts +66 -0
- package/src/ws/index.ts +164 -0
- package/src/ws/middleware.ts +178 -0
- package/src/ws/types.ts +52 -0
- 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
|