@zero-server/sdk 0.9.0 → 0.9.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.
Files changed (126) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +460 -437
  3. package/index.js +414 -412
  4. package/lib/app.js +1172 -1172
  5. package/lib/auth/authorize.js +399 -399
  6. package/lib/auth/enrollment.js +367 -367
  7. package/lib/auth/index.js +57 -57
  8. package/lib/auth/jwt.js +731 -731
  9. package/lib/auth/oauth.js +362 -362
  10. package/lib/auth/session.js +588 -588
  11. package/lib/auth/trustedDevice.js +409 -409
  12. package/lib/auth/twoFactor.js +1150 -1150
  13. package/lib/auth/webauthn.js +946 -946
  14. package/lib/body/index.js +14 -14
  15. package/lib/body/json.js +109 -109
  16. package/lib/body/multipart.js +440 -440
  17. package/lib/body/raw.js +71 -71
  18. package/lib/body/rawBuffer.js +160 -160
  19. package/lib/body/sendError.js +25 -25
  20. package/lib/body/text.js +75 -75
  21. package/lib/body/typeMatch.js +41 -41
  22. package/lib/body/urlencoded.js +235 -235
  23. package/lib/cli.js +845 -845
  24. package/lib/cluster.js +666 -666
  25. package/lib/debug.js +372 -372
  26. package/lib/env/index.js +460 -460
  27. package/lib/errors.js +683 -683
  28. package/lib/fetch/index.js +256 -256
  29. package/lib/grpc/balancer.js +378 -378
  30. package/lib/grpc/call.js +708 -708
  31. package/lib/grpc/client.js +764 -764
  32. package/lib/grpc/codec.js +1221 -1221
  33. package/lib/grpc/credentials.js +398 -398
  34. package/lib/grpc/frame.js +262 -262
  35. package/lib/grpc/health.js +287 -287
  36. package/lib/grpc/index.js +121 -121
  37. package/lib/grpc/metadata.js +461 -461
  38. package/lib/grpc/proto.js +821 -821
  39. package/lib/grpc/reflection.js +590 -590
  40. package/lib/grpc/server.js +445 -445
  41. package/lib/grpc/status.js +118 -118
  42. package/lib/grpc/watch.js +173 -173
  43. package/lib/http/index.js +10 -10
  44. package/lib/http/request.js +727 -727
  45. package/lib/http/response.js +799 -799
  46. package/lib/lifecycle.js +557 -557
  47. package/lib/middleware/compress.js +230 -230
  48. package/lib/middleware/cookieParser.js +237 -237
  49. package/lib/middleware/cors.js +93 -93
  50. package/lib/middleware/csrf.js +136 -136
  51. package/lib/middleware/errorHandler.js +101 -101
  52. package/lib/middleware/helmet.js +175 -175
  53. package/lib/middleware/index.js +19 -17
  54. package/lib/middleware/logger.js +74 -74
  55. package/lib/middleware/rateLimit.js +88 -88
  56. package/lib/middleware/requestId.js +53 -53
  57. package/lib/middleware/static.js +326 -326
  58. package/lib/middleware/timeout.js +71 -71
  59. package/lib/middleware/validator.js +254 -254
  60. package/lib/observe/health.js +326 -326
  61. package/lib/observe/index.js +50 -50
  62. package/lib/observe/logger.js +359 -359
  63. package/lib/observe/metrics.js +805 -805
  64. package/lib/observe/tracing.js +592 -592
  65. package/lib/orm/adapters/json.js +290 -290
  66. package/lib/orm/adapters/memory.js +764 -764
  67. package/lib/orm/adapters/mongo.js +764 -764
  68. package/lib/orm/adapters/mysql.js +933 -933
  69. package/lib/orm/adapters/postgres.js +1144 -1144
  70. package/lib/orm/adapters/redis.js +1534 -1534
  71. package/lib/orm/adapters/sql-base.js +212 -212
  72. package/lib/orm/adapters/sqlite.js +858 -858
  73. package/lib/orm/audit.js +649 -649
  74. package/lib/orm/cache.js +394 -394
  75. package/lib/orm/geo.js +387 -387
  76. package/lib/orm/index.js +784 -784
  77. package/lib/orm/migrate.js +432 -432
  78. package/lib/orm/model.js +1706 -1706
  79. package/lib/orm/plugin.js +375 -375
  80. package/lib/orm/procedures.js +836 -836
  81. package/lib/orm/profiler.js +233 -233
  82. package/lib/orm/query.js +1772 -1772
  83. package/lib/orm/replicas.js +241 -241
  84. package/lib/orm/schema.js +307 -307
  85. package/lib/orm/search.js +380 -380
  86. package/lib/orm/seed/data/commerce.js +136 -136
  87. package/lib/orm/seed/data/internet.js +111 -111
  88. package/lib/orm/seed/data/locations.js +204 -204
  89. package/lib/orm/seed/data/names.js +338 -338
  90. package/lib/orm/seed/data/person.js +128 -128
  91. package/lib/orm/seed/data/phone.js +211 -211
  92. package/lib/orm/seed/data/words.js +134 -134
  93. package/lib/orm/seed/factory.js +178 -178
  94. package/lib/orm/seed/fake.js +1186 -1186
  95. package/lib/orm/seed/index.js +18 -18
  96. package/lib/orm/seed/rng.js +70 -70
  97. package/lib/orm/seed/seeder.js +124 -124
  98. package/lib/orm/seed/unique.js +68 -68
  99. package/lib/orm/snapshot.js +366 -366
  100. package/lib/orm/tenancy.js +605 -605
  101. package/lib/orm/views.js +350 -350
  102. package/lib/router/index.js +436 -436
  103. package/lib/sse/index.js +8 -8
  104. package/lib/sse/stream.js +349 -349
  105. package/lib/ws/connection.js +451 -451
  106. package/lib/ws/handshake.js +125 -125
  107. package/lib/ws/index.js +14 -14
  108. package/lib/ws/room.js +223 -223
  109. package/package.json +73 -73
  110. package/types/app.d.ts +223 -223
  111. package/types/auth.d.ts +520 -520
  112. package/types/cluster.d.ts +75 -75
  113. package/types/env.d.ts +80 -80
  114. package/types/errors.d.ts +316 -316
  115. package/types/fetch.d.ts +43 -43
  116. package/types/grpc.d.ts +432 -432
  117. package/types/index.d.ts +384 -384
  118. package/types/lifecycle.d.ts +60 -60
  119. package/types/middleware.d.ts +320 -320
  120. package/types/observe.d.ts +304 -304
  121. package/types/orm.d.ts +1887 -1887
  122. package/types/request.d.ts +109 -109
  123. package/types/response.d.ts +157 -157
  124. package/types/router.d.ts +78 -78
  125. package/types/sse.d.ts +78 -78
  126. package/types/websocket.d.ts +126 -126
@@ -1,588 +1,588 @@
1
- /**
2
- * @module auth/session
3
- * @description Zero-dependency session middleware.
4
- * Supports encrypted cookie sessions (stateless, AES-256-GCM)
5
- * and server-side session stores (memory and custom adapters).
6
- *
7
- * Cookie sessions embed the entire session in an encrypted cookie,
8
- * so no server-side storage is needed. Server-side sessions store
9
- * only a session ID in the cookie, keeping data on the server.
10
- *
11
- * @example
12
- * const { createApp, json, session } = require('@zero-server/sdk');
13
- * const app = createApp();
14
- *
15
- * app.use(session({ secret: process.env.SESSION_SECRET }));
16
- *
17
- * app.post('/login', json(), async (req, res) => {
18
- * const user = await db.users.findOne({ email: req.body.email });
19
- * if (!user) return res.status(401).json({ error: 'Invalid credentials' });
20
- * req.session.set('userId', user.id);
21
- * req.session.set('role', user.role);
22
- * res.json({ ok: true });
23
- * });
24
- *
25
- * app.get('/dashboard', (req, res) => {
26
- * if (!req.session.get('userId'))
27
- * return res.status(401).json({ error: 'Not logged in' });
28
- * res.json({ userId: req.session.get('userId'), role: req.session.get('role') });
29
- * });
30
- *
31
- * app.post('/logout', (req, res) => {
32
- * req.session.destroy();
33
- * res.json({ ok: true });
34
- * });
35
- *
36
- * @example | Server-side Session with Memory Store
37
- * app.use(session({
38
- * secret: process.env.SESSION_SECRET,
39
- * store: new MemoryStore(),
40
- * cookie: { maxAge: 3600000 },
41
- * }));
42
- */
43
- const crypto = require('crypto');
44
- const log = require('../debug')('zero:session');
45
-
46
- // -- Constants ---------------------------------------------------
47
-
48
- const DEFAULT_COOKIE_NAME = 'sid';
49
- const DEFAULT_MAX_AGE = 86400000; // 24 hours in ms
50
- const IV_LEN = 12; // AES-256-GCM IV
51
- const AUTH_TAG_LEN = 16; // GCM auth tag
52
- const KEY_LEN = 32; // AES-256 key
53
- const MAX_COOKIE_SIZE = 4096; // Browser max cookie size
54
- const SID_BYTES = 24; // Session ID entropy (base64url → 32 chars)
55
-
56
- // -- Encryption helpers ------------------------------------------
57
-
58
- /**
59
- * Derive an AES-256 key from a secret string.
60
- * Uses HKDF with SHA-256 for proper key derivation.
61
- *
62
- * @param {string} secret - User-provided secret.
63
- * @returns {Buffer} 32-byte key.
64
- * @private
65
- */
66
- function _deriveKey(secret)
67
- {
68
- return crypto.createHash('sha256').update(secret).digest();
69
- }
70
-
71
- /**
72
- * Encrypt plaintext with AES-256-GCM.
73
- * Each encryption uses a unique random IV.
74
- *
75
- * @param {string} plaintext - Data to encrypt.
76
- * @param {Buffer} key - 32-byte AES key.
77
- * @returns {string} Base64url-encoded `iv.ciphertext.tag`.
78
- * @private
79
- */
80
- function _encrypt(plaintext, key)
81
- {
82
- const iv = crypto.randomBytes(IV_LEN);
83
- const cipher = crypto.createCipheriv('aes-256-gcm', key, iv, { authTagLength: AUTH_TAG_LEN });
84
- const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
85
- const tag = cipher.getAuthTag();
86
- // Pack: iv + ciphertext + tag
87
- const packed = Buffer.concat([iv, enc, tag]);
88
- return packed.toString('base64url');
89
- }
90
-
91
- /**
92
- * Decrypt AES-256-GCM ciphertext.
93
- *
94
- * @param {string} encoded - Base64url `iv+ciphertext+tag` string.
95
- * @param {Buffer} key - 32-byte AES key.
96
- * @returns {string|null} Decrypted plaintext or `null` on failure.
97
- * @private
98
- */
99
- function _decrypt(encoded, key)
100
- {
101
- try
102
- {
103
- const packed = Buffer.from(encoded, 'base64url');
104
- if (packed.length < IV_LEN + AUTH_TAG_LEN + 1) return null;
105
-
106
- const iv = packed.subarray(0, IV_LEN);
107
- const tag = packed.subarray(packed.length - AUTH_TAG_LEN);
108
- const enc = packed.subarray(IV_LEN, packed.length - AUTH_TAG_LEN);
109
-
110
- const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv, { authTagLength: AUTH_TAG_LEN });
111
- decipher.setAuthTag(tag);
112
- const dec = Buffer.concat([decipher.update(enc), decipher.final()]);
113
- return dec.toString('utf8');
114
- }
115
- catch (_) { return null; }
116
- }
117
-
118
- // -- Session class ------------------------------------------------
119
-
120
- /**
121
- * Session data container with a Map-like API.
122
- *
123
- * @example
124
- * req.session.set('user', { id: 1, name: 'Alice' });
125
- * req.session.get('user'); // { id: 1, name: 'Alice' }
126
- * req.session.has('user'); // true
127
- * req.session.delete('user');
128
- * req.session.destroy(); // wipe + expire cookie
129
- */
130
- class Session
131
- {
132
- /**
133
- * @param {string} id - Session ID.
134
- * @param {object} [data] - Initial data.
135
- */
136
- constructor(id, data = {})
137
- {
138
- this.id = id;
139
- this._data = { ...data };
140
- this._dirty = false;
141
- this._destroyed = false;
142
- this._regenerated = false;
143
- this._flash = {};
144
- this._flashOut = {};
145
- }
146
-
147
- /**
148
- * Get a session value by key.
149
- * @param {string} key
150
- * @returns {*}
151
- */
152
- get(key) { return this._data[key]; }
153
-
154
- /**
155
- * Set a session value.
156
- * @param {string} key
157
- * @param {*} value
158
- * @returns {Session} this (chainable)
159
- */
160
- set(key, value)
161
- {
162
- this._data[key] = value;
163
- this._dirty = true;
164
- return this;
165
- }
166
-
167
- /**
168
- * Check if a key exists in the session.
169
- * @param {string} key
170
- * @returns {boolean}
171
- */
172
- has(key) { return key in this._data; }
173
-
174
- /**
175
- * Delete a session key.
176
- * @param {string} key
177
- * @returns {boolean} true if the key existed.
178
- */
179
- delete(key)
180
- {
181
- const existed = key in this._data;
182
- delete this._data[key];
183
- if (existed) this._dirty = true;
184
- return existed;
185
- }
186
-
187
- /**
188
- * Get all session data as a plain object.
189
- * @returns {object}
190
- */
191
- all() { return { ...this._data }; }
192
-
193
- /**
194
- * Number of session entries.
195
- * @returns {number}
196
- */
197
- get size() { return Object.keys(this._data).length; }
198
-
199
- /**
200
- * Clear all session data.
201
- * @returns {Session}
202
- */
203
- clear()
204
- {
205
- this._data = {};
206
- this._dirty = true;
207
- return this;
208
- }
209
-
210
- /**
211
- * Destroy the session.
212
- * Clears all data and marks cookie for expiry.
213
- */
214
- destroy()
215
- {
216
- this._data = {};
217
- this._flash = {};
218
- this._flashOut = {};
219
- this._dirty = true;
220
- this._destroyed = true;
221
- }
222
-
223
- /**
224
- * Regenerate the session ID (prevents session fixation).
225
- * Preserves existing data under a new ID.
226
- */
227
- regenerate()
228
- {
229
- this.id = _generateSid();
230
- this._regenerated = true;
231
- this._dirty = true;
232
- }
233
-
234
- /**
235
- * Set a flash message (available only on the next request).
236
- *
237
- * @param {string} key - Flash key (e.g. `'success'`, `'error'`).
238
- * @param {*} value - Flash value.
239
- * @returns {Session}
240
- *
241
- * @example
242
- * req.session.flash('success', 'Post created!');
243
- * // Next request:
244
- * req.session.flashes('success'); // ['Post created!']
245
- */
246
- flash(key, value)
247
- {
248
- if (!this._flashOut[key]) this._flashOut[key] = [];
249
- this._flashOut[key].push(value);
250
- this._dirty = true;
251
- return this;
252
- }
253
-
254
- /**
255
- * Read flash messages for a key (consumes them).
256
- *
257
- * @param {string} [key] - Flash key. If omitted, returns all flashes.
258
- * @returns {*[]|object} Array of messages for key, or object of all flashes.
259
- */
260
- flashes(key)
261
- {
262
- if (key) return this._flash[key] || [];
263
- return { ...this._flash };
264
- }
265
-
266
- /** @private — serialize to JSON for cookie/store */
267
- _serialize()
268
- {
269
- const obj = { d: this._data };
270
- if (Object.keys(this._flashOut).length) obj.f = this._flashOut;
271
- return JSON.stringify(obj);
272
- }
273
-
274
- /** @private — deserialize from JSON */
275
- static _deserialize(json, id)
276
- {
277
- try
278
- {
279
- const obj = typeof json === 'string' ? JSON.parse(json) : json;
280
- const sess = new Session(id, obj.d || {});
281
- sess._flash = obj.f || {};
282
- return sess;
283
- }
284
- catch (_) { return new Session(id); }
285
- }
286
- }
287
-
288
- // -- Memory Store ------------------------------------------------
289
-
290
- /**
291
- * In-memory session store.
292
- * Suitable for development and single-process deployments.
293
- * Sessions are lost on restart.
294
- *
295
- * @example
296
- * const store = new MemoryStore({ ttl: 3600000 });
297
- * app.use(session({ secret: 's3cret', store }));
298
- */
299
- class MemoryStore
300
- {
301
- /**
302
- * @param {object} [opts]
303
- * @param {number} [opts.ttl=86400000] - Session TTL in ms (default 24h).
304
- * @param {number} [opts.pruneInterval=300000] - Cleanup interval in ms (default 5min).
305
- * @param {number} [opts.maxSessions=10000] - Maximum stored sessions.
306
- */
307
- constructor(opts = {})
308
- {
309
- this._sessions = new Map();
310
- this._ttl = opts.ttl || DEFAULT_MAX_AGE;
311
- this._maxSessions = opts.maxSessions || 10000;
312
- this._pruneTimer = null;
313
-
314
- const pruneMs = opts.pruneInterval || 300000;
315
- if (pruneMs > 0)
316
- {
317
- this._pruneTimer = setInterval(() => this._prune(), pruneMs);
318
- if (this._pruneTimer.unref) this._pruneTimer.unref();
319
- }
320
- }
321
-
322
- /** @param {string} sid */
323
- async get(sid)
324
- {
325
- const entry = this._sessions.get(sid);
326
- if (!entry) return null;
327
- if (Date.now() > entry.expires)
328
- {
329
- this._sessions.delete(sid);
330
- return null;
331
- }
332
- return entry.data;
333
- }
334
-
335
- /**
336
- * @param {string} sid
337
- * @param {string} data - Serialized session.
338
- * @param {number} [maxAge] - TTL in ms.
339
- */
340
- async set(sid, data, maxAge)
341
- {
342
- if (this._sessions.size >= this._maxSessions && !this._sessions.has(sid))
343
- {
344
- this._prune();
345
- if (this._sessions.size >= this._maxSessions)
346
- {
347
- log.warn('MemoryStore at capacity (%d), rejecting new session', this._maxSessions);
348
- return;
349
- }
350
- }
351
- this._sessions.set(sid, {
352
- data,
353
- expires: Date.now() + (maxAge || this._ttl),
354
- });
355
- }
356
-
357
- /** @param {string} sid */
358
- async destroy(sid)
359
- {
360
- this._sessions.delete(sid);
361
- }
362
-
363
- /** Prune expired sessions. */
364
- _prune()
365
- {
366
- const now = Date.now();
367
- for (const [sid, entry] of this._sessions)
368
- {
369
- if (now > entry.expires) this._sessions.delete(sid);
370
- }
371
- }
372
-
373
- /** Number of active sessions. */
374
- get length() { return this._sessions.size; }
375
-
376
- /** Clear all sessions. */
377
- clear()
378
- {
379
- this._sessions.clear();
380
- }
381
-
382
- /** Stop the prune timer. */
383
- close()
384
- {
385
- if (this._pruneTimer) { clearInterval(this._pruneTimer); this._pruneTimer = null; }
386
- }
387
- }
388
-
389
- // -- Session ID --------------------------------------------------
390
-
391
- /** @private */
392
- function _generateSid()
393
- {
394
- return crypto.randomBytes(SID_BYTES).toString('base64url');
395
- }
396
-
397
- // -- Session Middleware ------------------------------------------
398
-
399
- /**
400
- * Create session middleware.
401
- *
402
- * Two modes:
403
- * 1. **Cookie session** (no `store`): Entire session encrypted in a cookie.
404
- * Great for small payloads (< 4 KB). Zero server state.
405
- * 2. **Server-side session** (with `store`): Only session ID in cookie,
406
- * data lives in the store. Scales to large payloads.
407
- *
408
- * @param {object} opts - Configuration.
409
- * @param {string|string[]} opts.secret - Encryption secret(s). First secret used for
410
- * encryption, all are tried for decryption (supports rotation).
411
- * @param {object} [opts.store] - Server-side session store (must implement `get`, `set`, `destroy`).
412
- * @param {string} [opts.name='sid'] - Cookie name.
413
- * @param {object} [opts.cookie] - Cookie options.
414
- * @param {number} [opts.cookie.maxAge=86400000] - Cookie max-age in ms (default 24h).
415
- * @param {string} [opts.cookie.path='/'] - Cookie path.
416
- * @param {string} [opts.cookie.domain] - Cookie domain.
417
- * @param {boolean} [opts.cookie.secure] - Secure-only flag (default: auto-detect via `req.secure`).
418
- * @param {boolean} [opts.cookie.httpOnly=true] - HttpOnly flag.
419
- * @param {string} [opts.cookie.sameSite='Lax'] - SameSite attribute.
420
- * @param {boolean} [opts.rolling=false] - Reset cookie maxAge on every response.
421
- * @param {Function} [opts.genid] - Custom session ID generator.
422
- * @returns {Function} Middleware `(req, res, next) => void`.
423
- *
424
- * @example | Cookie Session with Rotation
425
- * app.use(session({
426
- * secret: ['new-key', 'old-key'],
427
- * cookie: { maxAge: 3600000, secure: true },
428
- * }));
429
- *
430
- * @example | Server-side with Memory Store
431
- * const store = new MemoryStore({ ttl: 3600000 });
432
- * app.use(session({ secret: 'key', store, rolling: true }));
433
- */
434
- function session(opts = {})
435
- {
436
- if (!opts.secret) throw new Error('session() requires a secret');
437
-
438
- const secrets = Array.isArray(opts.secret) ? opts.secret : [opts.secret];
439
- const keys = secrets.map(s => _deriveKey(s));
440
- const store = opts.store || null;
441
- const cookieName = opts.name || DEFAULT_COOKIE_NAME;
442
- const rolling = opts.rolling === true;
443
- const genid = typeof opts.genid === 'function' ? opts.genid : _generateSid;
444
-
445
- const cookieOpts = {
446
- path: '/',
447
- httpOnly: true,
448
- sameSite: 'Lax',
449
- ...(opts.cookie || {}),
450
- };
451
- const maxAge = cookieOpts.maxAge || DEFAULT_MAX_AGE;
452
-
453
- return async function sessionMiddleware(req, res, next)
454
- {
455
- // Prevent double-initialisation
456
- if (req.session) return next();
457
-
458
- const rawCookie = req.cookies?.[cookieName] || req.signedCookies?.[cookieName];
459
- let sess = null;
460
-
461
- if (store)
462
- {
463
- // Server-side mode: cookie holds the session ID
464
- sess = await _loadServerSession(rawCookie, store, genid);
465
- }
466
- else
467
- {
468
- // Cookie mode: decrypt session from cookie
469
- sess = _loadCookieSession(rawCookie, keys, genid);
470
- }
471
-
472
- req.session = sess;
473
-
474
- // Intercept response to persist session
475
- // Hook into res.raw.end (Node ServerResponse) — the Response wrapper
476
- // has no .end() method; its .send()/.json() helpers call raw.end().
477
- const raw = res.raw;
478
- const origEnd = raw.end.bind(raw);
479
- raw.end = function sessionEnd(...args)
480
- {
481
- try
482
- {
483
- // _saveSession calls res.cookie() / res.clearCookie() which set
484
- // Set-Cookie headers on raw via raw.setHeader — safe because
485
- // headers aren't flushed until the original end() runs.
486
- // NOTE: store-based sessions are sync-compatible because
487
- // MemoryStore.set/get return resolved promises synchronously
488
- // for the in-process case. For truly async stores the cookie
489
- // will still be set correctly because setHeader precedes end().
490
- _saveSession(req, res, sess, {
491
- store, keys, cookieName, cookieOpts, maxAge, rolling,
492
- });
493
- }
494
- catch (err)
495
- {
496
- log.error('session save error: %s', err.message);
497
- }
498
- return origEnd(...args);
499
- };
500
-
501
- next();
502
- };
503
- }
504
-
505
- // -- Internal load/save ------------------------------------------
506
-
507
- /** @private */
508
- async function _loadServerSession(rawCookie, store, genid)
509
- {
510
- if (rawCookie)
511
- {
512
- const data = await store.get(rawCookie);
513
- if (data)
514
- {
515
- log.debug('session loaded: %s', rawCookie);
516
- return Session._deserialize(data, rawCookie);
517
- }
518
- }
519
- // New session
520
- const id = genid();
521
- log.debug('new session: %s', id);
522
- return new Session(id);
523
- }
524
-
525
- /** @private */
526
- function _loadCookieSession(rawCookie, keys, genid)
527
- {
528
- if (rawCookie)
529
- {
530
- // Try each key for decryption (rotation support)
531
- for (const key of keys)
532
- {
533
- const json = _decrypt(rawCookie, key);
534
- if (json)
535
- {
536
- const sess = Session._deserialize(json, 'cookie');
537
- log.debug('cookie session decrypted');
538
- return sess;
539
- }
540
- }
541
- }
542
- return new Session(genid());
543
- }
544
-
545
- /** @private */
546
- function _saveSession(req, res, sess, ctx)
547
- {
548
- if (sess._destroyed)
549
- {
550
- // Clear cookie and destroy store entry
551
- res.clearCookie(ctx.cookieName, { path: ctx.cookieOpts.path || '/' });
552
- if (ctx.store) ctx.store.destroy(sess.id);
553
- log.debug('session destroyed: %s', sess.id);
554
- return;
555
- }
556
-
557
- const shouldSave = sess._dirty || ctx.rolling;
558
- if (!shouldSave) return;
559
-
560
- const cOpts = { ...ctx.cookieOpts, maxAge: Math.floor(ctx.maxAge / 1000) };
561
- if (cOpts.secure === undefined) cOpts.secure = req.secure;
562
-
563
- if (ctx.store)
564
- {
565
- // Server-side: persist data in store, session ID in cookie
566
- ctx.store.set(sess.id, sess._serialize(), ctx.maxAge);
567
- res.cookie(ctx.cookieName, sess.id, cOpts);
568
- log.debug('server session saved: %s', sess.id);
569
- }
570
- else
571
- {
572
- // Cookie mode: encrypt session and set as cookie
573
- const payload = sess._serialize();
574
- const encrypted = _encrypt(payload, ctx.keys[0]);
575
- if (encrypted.length > MAX_COOKIE_SIZE)
576
- {
577
- log.warn('session cookie exceeds %d bytes — consider using a store', MAX_COOKIE_SIZE);
578
- }
579
- res.cookie(ctx.cookieName, encrypted, cOpts);
580
- log.debug('cookie session saved');
581
- }
582
- }
583
-
584
- module.exports = {
585
- session,
586
- Session,
587
- MemoryStore,
588
- };
1
+ /**
2
+ * @module auth/session
3
+ * @description Zero-dependency session middleware.
4
+ * Supports encrypted cookie sessions (stateless, AES-256-GCM)
5
+ * and server-side session stores (memory and custom adapters).
6
+ *
7
+ * Cookie sessions embed the entire session in an encrypted cookie,
8
+ * so no server-side storage is needed. Server-side sessions store
9
+ * only a session ID in the cookie, keeping data on the server.
10
+ *
11
+ * @example
12
+ * const { createApp, json, session } = require('@zero-server/sdk');
13
+ * const app = createApp();
14
+ *
15
+ * app.use(session({ secret: process.env.SESSION_SECRET }));
16
+ *
17
+ * app.post('/login', json(), async (req, res) => {
18
+ * const user = await db.users.findOne({ email: req.body.email });
19
+ * if (!user) return res.status(401).json({ error: 'Invalid credentials' });
20
+ * req.session.set('userId', user.id);
21
+ * req.session.set('role', user.role);
22
+ * res.json({ ok: true });
23
+ * });
24
+ *
25
+ * app.get('/dashboard', (req, res) => {
26
+ * if (!req.session.get('userId'))
27
+ * return res.status(401).json({ error: 'Not logged in' });
28
+ * res.json({ userId: req.session.get('userId'), role: req.session.get('role') });
29
+ * });
30
+ *
31
+ * app.post('/logout', (req, res) => {
32
+ * req.session.destroy();
33
+ * res.json({ ok: true });
34
+ * });
35
+ *
36
+ * @example | Server-side Session with Memory Store
37
+ * app.use(session({
38
+ * secret: process.env.SESSION_SECRET,
39
+ * store: new MemoryStore(),
40
+ * cookie: { maxAge: 3600000 },
41
+ * }));
42
+ */
43
+ const crypto = require('crypto');
44
+ const log = require('../debug')('zero:session');
45
+
46
+ // -- Constants ---------------------------------------------------
47
+
48
+ const DEFAULT_COOKIE_NAME = 'sid';
49
+ const DEFAULT_MAX_AGE = 86400000; // 24 hours in ms
50
+ const IV_LEN = 12; // AES-256-GCM IV
51
+ const AUTH_TAG_LEN = 16; // GCM auth tag
52
+ const KEY_LEN = 32; // AES-256 key
53
+ const MAX_COOKIE_SIZE = 4096; // Browser max cookie size
54
+ const SID_BYTES = 24; // Session ID entropy (base64url → 32 chars)
55
+
56
+ // -- Encryption helpers ------------------------------------------
57
+
58
+ /**
59
+ * Derive an AES-256 key from a secret string.
60
+ * Uses HKDF with SHA-256 for proper key derivation.
61
+ *
62
+ * @param {string} secret - User-provided secret.
63
+ * @returns {Buffer} 32-byte key.
64
+ * @private
65
+ */
66
+ function _deriveKey(secret)
67
+ {
68
+ return crypto.createHash('sha256').update(secret).digest();
69
+ }
70
+
71
+ /**
72
+ * Encrypt plaintext with AES-256-GCM.
73
+ * Each encryption uses a unique random IV.
74
+ *
75
+ * @param {string} plaintext - Data to encrypt.
76
+ * @param {Buffer} key - 32-byte AES key.
77
+ * @returns {string} Base64url-encoded `iv.ciphertext.tag`.
78
+ * @private
79
+ */
80
+ function _encrypt(plaintext, key)
81
+ {
82
+ const iv = crypto.randomBytes(IV_LEN);
83
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv, { authTagLength: AUTH_TAG_LEN });
84
+ const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
85
+ const tag = cipher.getAuthTag();
86
+ // Pack: iv + ciphertext + tag
87
+ const packed = Buffer.concat([iv, enc, tag]);
88
+ return packed.toString('base64url');
89
+ }
90
+
91
+ /**
92
+ * Decrypt AES-256-GCM ciphertext.
93
+ *
94
+ * @param {string} encoded - Base64url `iv+ciphertext+tag` string.
95
+ * @param {Buffer} key - 32-byte AES key.
96
+ * @returns {string|null} Decrypted plaintext or `null` on failure.
97
+ * @private
98
+ */
99
+ function _decrypt(encoded, key)
100
+ {
101
+ try
102
+ {
103
+ const packed = Buffer.from(encoded, 'base64url');
104
+ if (packed.length < IV_LEN + AUTH_TAG_LEN + 1) return null;
105
+
106
+ const iv = packed.subarray(0, IV_LEN);
107
+ const tag = packed.subarray(packed.length - AUTH_TAG_LEN);
108
+ const enc = packed.subarray(IV_LEN, packed.length - AUTH_TAG_LEN);
109
+
110
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv, { authTagLength: AUTH_TAG_LEN });
111
+ decipher.setAuthTag(tag);
112
+ const dec = Buffer.concat([decipher.update(enc), decipher.final()]);
113
+ return dec.toString('utf8');
114
+ }
115
+ catch (_) { return null; }
116
+ }
117
+
118
+ // -- Session class ------------------------------------------------
119
+
120
+ /**
121
+ * Session data container with a Map-like API.
122
+ *
123
+ * @example
124
+ * req.session.set('user', { id: 1, name: 'Alice' });
125
+ * req.session.get('user'); // { id: 1, name: 'Alice' }
126
+ * req.session.has('user'); // true
127
+ * req.session.delete('user');
128
+ * req.session.destroy(); // wipe + expire cookie
129
+ */
130
+ class Session
131
+ {
132
+ /**
133
+ * @param {string} id - Session ID.
134
+ * @param {object} [data] - Initial data.
135
+ */
136
+ constructor(id, data = {})
137
+ {
138
+ this.id = id;
139
+ this._data = { ...data };
140
+ this._dirty = false;
141
+ this._destroyed = false;
142
+ this._regenerated = false;
143
+ this._flash = {};
144
+ this._flashOut = {};
145
+ }
146
+
147
+ /**
148
+ * Get a session value by key.
149
+ * @param {string} key
150
+ * @returns {*}
151
+ */
152
+ get(key) { return this._data[key]; }
153
+
154
+ /**
155
+ * Set a session value.
156
+ * @param {string} key
157
+ * @param {*} value
158
+ * @returns {Session} this (chainable)
159
+ */
160
+ set(key, value)
161
+ {
162
+ this._data[key] = value;
163
+ this._dirty = true;
164
+ return this;
165
+ }
166
+
167
+ /**
168
+ * Check if a key exists in the session.
169
+ * @param {string} key
170
+ * @returns {boolean}
171
+ */
172
+ has(key) { return key in this._data; }
173
+
174
+ /**
175
+ * Delete a session key.
176
+ * @param {string} key
177
+ * @returns {boolean} true if the key existed.
178
+ */
179
+ delete(key)
180
+ {
181
+ const existed = key in this._data;
182
+ delete this._data[key];
183
+ if (existed) this._dirty = true;
184
+ return existed;
185
+ }
186
+
187
+ /**
188
+ * Get all session data as a plain object.
189
+ * @returns {object}
190
+ */
191
+ all() { return { ...this._data }; }
192
+
193
+ /**
194
+ * Number of session entries.
195
+ * @returns {number}
196
+ */
197
+ get size() { return Object.keys(this._data).length; }
198
+
199
+ /**
200
+ * Clear all session data.
201
+ * @returns {Session}
202
+ */
203
+ clear()
204
+ {
205
+ this._data = {};
206
+ this._dirty = true;
207
+ return this;
208
+ }
209
+
210
+ /**
211
+ * Destroy the session.
212
+ * Clears all data and marks cookie for expiry.
213
+ */
214
+ destroy()
215
+ {
216
+ this._data = {};
217
+ this._flash = {};
218
+ this._flashOut = {};
219
+ this._dirty = true;
220
+ this._destroyed = true;
221
+ }
222
+
223
+ /**
224
+ * Regenerate the session ID (prevents session fixation).
225
+ * Preserves existing data under a new ID.
226
+ */
227
+ regenerate()
228
+ {
229
+ this.id = _generateSid();
230
+ this._regenerated = true;
231
+ this._dirty = true;
232
+ }
233
+
234
+ /**
235
+ * Set a flash message (available only on the next request).
236
+ *
237
+ * @param {string} key - Flash key (e.g. `'success'`, `'error'`).
238
+ * @param {*} value - Flash value.
239
+ * @returns {Session}
240
+ *
241
+ * @example
242
+ * req.session.flash('success', 'Post created!');
243
+ * // Next request:
244
+ * req.session.flashes('success'); // ['Post created!']
245
+ */
246
+ flash(key, value)
247
+ {
248
+ if (!this._flashOut[key]) this._flashOut[key] = [];
249
+ this._flashOut[key].push(value);
250
+ this._dirty = true;
251
+ return this;
252
+ }
253
+
254
+ /**
255
+ * Read flash messages for a key (consumes them).
256
+ *
257
+ * @param {string} [key] - Flash key. If omitted, returns all flashes.
258
+ * @returns {*[]|object} Array of messages for key, or object of all flashes.
259
+ */
260
+ flashes(key)
261
+ {
262
+ if (key) return this._flash[key] || [];
263
+ return { ...this._flash };
264
+ }
265
+
266
+ /** @private — serialize to JSON for cookie/store */
267
+ _serialize()
268
+ {
269
+ const obj = { d: this._data };
270
+ if (Object.keys(this._flashOut).length) obj.f = this._flashOut;
271
+ return JSON.stringify(obj);
272
+ }
273
+
274
+ /** @private — deserialize from JSON */
275
+ static _deserialize(json, id)
276
+ {
277
+ try
278
+ {
279
+ const obj = typeof json === 'string' ? JSON.parse(json) : json;
280
+ const sess = new Session(id, obj.d || {});
281
+ sess._flash = obj.f || {};
282
+ return sess;
283
+ }
284
+ catch (_) { return new Session(id); }
285
+ }
286
+ }
287
+
288
+ // -- Memory Store ------------------------------------------------
289
+
290
+ /**
291
+ * In-memory session store.
292
+ * Suitable for development and single-process deployments.
293
+ * Sessions are lost on restart.
294
+ *
295
+ * @example
296
+ * const store = new MemoryStore({ ttl: 3600000 });
297
+ * app.use(session({ secret: 's3cret', store }));
298
+ */
299
+ class MemoryStore
300
+ {
301
+ /**
302
+ * @param {object} [opts]
303
+ * @param {number} [opts.ttl=86400000] - Session TTL in ms (default 24h).
304
+ * @param {number} [opts.pruneInterval=300000] - Cleanup interval in ms (default 5min).
305
+ * @param {number} [opts.maxSessions=10000] - Maximum stored sessions.
306
+ */
307
+ constructor(opts = {})
308
+ {
309
+ this._sessions = new Map();
310
+ this._ttl = opts.ttl || DEFAULT_MAX_AGE;
311
+ this._maxSessions = opts.maxSessions || 10000;
312
+ this._pruneTimer = null;
313
+
314
+ const pruneMs = opts.pruneInterval || 300000;
315
+ if (pruneMs > 0)
316
+ {
317
+ this._pruneTimer = setInterval(() => this._prune(), pruneMs);
318
+ if (this._pruneTimer.unref) this._pruneTimer.unref();
319
+ }
320
+ }
321
+
322
+ /** @param {string} sid */
323
+ async get(sid)
324
+ {
325
+ const entry = this._sessions.get(sid);
326
+ if (!entry) return null;
327
+ if (Date.now() > entry.expires)
328
+ {
329
+ this._sessions.delete(sid);
330
+ return null;
331
+ }
332
+ return entry.data;
333
+ }
334
+
335
+ /**
336
+ * @param {string} sid
337
+ * @param {string} data - Serialized session.
338
+ * @param {number} [maxAge] - TTL in ms.
339
+ */
340
+ async set(sid, data, maxAge)
341
+ {
342
+ if (this._sessions.size >= this._maxSessions && !this._sessions.has(sid))
343
+ {
344
+ this._prune();
345
+ if (this._sessions.size >= this._maxSessions)
346
+ {
347
+ log.warn('MemoryStore at capacity (%d), rejecting new session', this._maxSessions);
348
+ return;
349
+ }
350
+ }
351
+ this._sessions.set(sid, {
352
+ data,
353
+ expires: Date.now() + (maxAge || this._ttl),
354
+ });
355
+ }
356
+
357
+ /** @param {string} sid */
358
+ async destroy(sid)
359
+ {
360
+ this._sessions.delete(sid);
361
+ }
362
+
363
+ /** Prune expired sessions. */
364
+ _prune()
365
+ {
366
+ const now = Date.now();
367
+ for (const [sid, entry] of this._sessions)
368
+ {
369
+ if (now > entry.expires) this._sessions.delete(sid);
370
+ }
371
+ }
372
+
373
+ /** Number of active sessions. */
374
+ get length() { return this._sessions.size; }
375
+
376
+ /** Clear all sessions. */
377
+ clear()
378
+ {
379
+ this._sessions.clear();
380
+ }
381
+
382
+ /** Stop the prune timer. */
383
+ close()
384
+ {
385
+ if (this._pruneTimer) { clearInterval(this._pruneTimer); this._pruneTimer = null; }
386
+ }
387
+ }
388
+
389
+ // -- Session ID --------------------------------------------------
390
+
391
+ /** @private */
392
+ function _generateSid()
393
+ {
394
+ return crypto.randomBytes(SID_BYTES).toString('base64url');
395
+ }
396
+
397
+ // -- Session Middleware ------------------------------------------
398
+
399
+ /**
400
+ * Create session middleware.
401
+ *
402
+ * Two modes:
403
+ * 1. **Cookie session** (no `store`): Entire session encrypted in a cookie.
404
+ * Great for small payloads (< 4 KB). Zero server state.
405
+ * 2. **Server-side session** (with `store`): Only session ID in cookie,
406
+ * data lives in the store. Scales to large payloads.
407
+ *
408
+ * @param {object} opts - Configuration.
409
+ * @param {string|string[]} opts.secret - Encryption secret(s). First secret used for
410
+ * encryption, all are tried for decryption (supports rotation).
411
+ * @param {object} [opts.store] - Server-side session store (must implement `get`, `set`, `destroy`).
412
+ * @param {string} [opts.name='sid'] - Cookie name.
413
+ * @param {object} [opts.cookie] - Cookie options.
414
+ * @param {number} [opts.cookie.maxAge=86400000] - Cookie max-age in ms (default 24h).
415
+ * @param {string} [opts.cookie.path='/'] - Cookie path.
416
+ * @param {string} [opts.cookie.domain] - Cookie domain.
417
+ * @param {boolean} [opts.cookie.secure] - Secure-only flag (default: auto-detect via `req.secure`).
418
+ * @param {boolean} [opts.cookie.httpOnly=true] - HttpOnly flag.
419
+ * @param {string} [opts.cookie.sameSite='Lax'] - SameSite attribute.
420
+ * @param {boolean} [opts.rolling=false] - Reset cookie maxAge on every response.
421
+ * @param {Function} [opts.genid] - Custom session ID generator.
422
+ * @returns {Function} Middleware `(req, res, next) => void`.
423
+ *
424
+ * @example | Cookie Session with Rotation
425
+ * app.use(session({
426
+ * secret: ['new-key', 'old-key'],
427
+ * cookie: { maxAge: 3600000, secure: true },
428
+ * }));
429
+ *
430
+ * @example | Server-side with Memory Store
431
+ * const store = new MemoryStore({ ttl: 3600000 });
432
+ * app.use(session({ secret: 'key', store, rolling: true }));
433
+ */
434
+ function session(opts = {})
435
+ {
436
+ if (!opts.secret) throw new Error('session() requires a secret');
437
+
438
+ const secrets = Array.isArray(opts.secret) ? opts.secret : [opts.secret];
439
+ const keys = secrets.map(s => _deriveKey(s));
440
+ const store = opts.store || null;
441
+ const cookieName = opts.name || DEFAULT_COOKIE_NAME;
442
+ const rolling = opts.rolling === true;
443
+ const genid = typeof opts.genid === 'function' ? opts.genid : _generateSid;
444
+
445
+ const cookieOpts = {
446
+ path: '/',
447
+ httpOnly: true,
448
+ sameSite: 'Lax',
449
+ ...(opts.cookie || {}),
450
+ };
451
+ const maxAge = cookieOpts.maxAge || DEFAULT_MAX_AGE;
452
+
453
+ return async function sessionMiddleware(req, res, next)
454
+ {
455
+ // Prevent double-initialisation
456
+ if (req.session) return next();
457
+
458
+ const rawCookie = req.cookies?.[cookieName] || req.signedCookies?.[cookieName];
459
+ let sess = null;
460
+
461
+ if (store)
462
+ {
463
+ // Server-side mode: cookie holds the session ID
464
+ sess = await _loadServerSession(rawCookie, store, genid);
465
+ }
466
+ else
467
+ {
468
+ // Cookie mode: decrypt session from cookie
469
+ sess = _loadCookieSession(rawCookie, keys, genid);
470
+ }
471
+
472
+ req.session = sess;
473
+
474
+ // Intercept response to persist session
475
+ // Hook into res.raw.end (Node ServerResponse) — the Response wrapper
476
+ // has no .end() method; its .send()/.json() helpers call raw.end().
477
+ const raw = res.raw;
478
+ const origEnd = raw.end.bind(raw);
479
+ raw.end = function sessionEnd(...args)
480
+ {
481
+ try
482
+ {
483
+ // _saveSession calls res.cookie() / res.clearCookie() which set
484
+ // Set-Cookie headers on raw via raw.setHeader — safe because
485
+ // headers aren't flushed until the original end() runs.
486
+ // NOTE: store-based sessions are sync-compatible because
487
+ // MemoryStore.set/get return resolved promises synchronously
488
+ // for the in-process case. For truly async stores the cookie
489
+ // will still be set correctly because setHeader precedes end().
490
+ _saveSession(req, res, sess, {
491
+ store, keys, cookieName, cookieOpts, maxAge, rolling,
492
+ });
493
+ }
494
+ catch (err)
495
+ {
496
+ log.error('session save error: %s', err.message);
497
+ }
498
+ return origEnd(...args);
499
+ };
500
+
501
+ next();
502
+ };
503
+ }
504
+
505
+ // -- Internal load/save ------------------------------------------
506
+
507
+ /** @private */
508
+ async function _loadServerSession(rawCookie, store, genid)
509
+ {
510
+ if (rawCookie)
511
+ {
512
+ const data = await store.get(rawCookie);
513
+ if (data)
514
+ {
515
+ log.debug('session loaded: %s', rawCookie);
516
+ return Session._deserialize(data, rawCookie);
517
+ }
518
+ }
519
+ // New session
520
+ const id = genid();
521
+ log.debug('new session: %s', id);
522
+ return new Session(id);
523
+ }
524
+
525
+ /** @private */
526
+ function _loadCookieSession(rawCookie, keys, genid)
527
+ {
528
+ if (rawCookie)
529
+ {
530
+ // Try each key for decryption (rotation support)
531
+ for (const key of keys)
532
+ {
533
+ const json = _decrypt(rawCookie, key);
534
+ if (json)
535
+ {
536
+ const sess = Session._deserialize(json, 'cookie');
537
+ log.debug('cookie session decrypted');
538
+ return sess;
539
+ }
540
+ }
541
+ }
542
+ return new Session(genid());
543
+ }
544
+
545
+ /** @private */
546
+ function _saveSession(req, res, sess, ctx)
547
+ {
548
+ if (sess._destroyed)
549
+ {
550
+ // Clear cookie and destroy store entry
551
+ res.clearCookie(ctx.cookieName, { path: ctx.cookieOpts.path || '/' });
552
+ if (ctx.store) ctx.store.destroy(sess.id);
553
+ log.debug('session destroyed: %s', sess.id);
554
+ return;
555
+ }
556
+
557
+ const shouldSave = sess._dirty || ctx.rolling;
558
+ if (!shouldSave) return;
559
+
560
+ const cOpts = { ...ctx.cookieOpts, maxAge: Math.floor(ctx.maxAge / 1000) };
561
+ if (cOpts.secure === undefined) cOpts.secure = req.secure;
562
+
563
+ if (ctx.store)
564
+ {
565
+ // Server-side: persist data in store, session ID in cookie
566
+ ctx.store.set(sess.id, sess._serialize(), ctx.maxAge);
567
+ res.cookie(ctx.cookieName, sess.id, cOpts);
568
+ log.debug('server session saved: %s', sess.id);
569
+ }
570
+ else
571
+ {
572
+ // Cookie mode: encrypt session and set as cookie
573
+ const payload = sess._serialize();
574
+ const encrypted = _encrypt(payload, ctx.keys[0]);
575
+ if (encrypted.length > MAX_COOKIE_SIZE)
576
+ {
577
+ log.warn('session cookie exceeds %d bytes — consider using a store', MAX_COOKIE_SIZE);
578
+ }
579
+ res.cookie(ctx.cookieName, encrypted, cOpts);
580
+ log.debug('cookie session saved');
581
+ }
582
+ }
583
+
584
+ module.exports = {
585
+ session,
586
+ Session,
587
+ MemoryStore,
588
+ };