@bakery-framework/core 1.0.0
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 +19 -0
- package/README.md +89 -0
- package/package.json +69 -0
- package/src/cache/index.ts +8 -0
- package/src/cache/lru.ts +41 -0
- package/src/cache/shared-db.ts +51 -0
- package/src/cache/string.ts +150 -0
- package/src/cache/tiered.ts +493 -0
- package/src/client/globals.d.ts +74 -0
- package/src/client/livereload.ts +437 -0
- package/src/client/utils.ts +315 -0
- package/src/compiler/compiler.ts +263 -0
- package/src/compiler/dev-service.ts +660 -0
- package/src/compiler/index.ts +2 -0
- package/src/compiler/prompt-tracker.ts +36 -0
- package/src/compiler/tsconfig-sync.ts +71 -0
- package/src/core/bakery.ts +96 -0
- package/src/core/cache-version.ts +119 -0
- package/src/core/config.ts +296 -0
- package/src/core/context.ts +121 -0
- package/src/core/index.ts +61 -0
- package/src/core/init.ts +90 -0
- package/src/core/jsx.ts +152 -0
- package/src/core/paths.ts +24 -0
- package/src/core/plugins.ts +120 -0
- package/src/core/port.ts +73 -0
- package/src/global.d.ts +374 -0
- package/src/handlers/assets/google-font.ts +225 -0
- package/src/handlers/assets/image.ts +136 -0
- package/src/handlers/assets/nm.ts +73 -0
- package/src/handlers/assets/public.ts +17 -0
- package/src/handlers/assets/static.ts +86 -0
- package/src/handlers/assets/ts.ts +61 -0
- package/src/handlers/assets/tsx.ts +106 -0
- package/src/handlers/assets/virtual-asset.ts +104 -0
- package/src/handlers/core/$base.ts +256 -0
- package/src/handlers/core/$dynamic.ts +285 -0
- package/src/handlers/core/$error.ts +301 -0
- package/src/handlers/core/$middleware.ts +71 -0
- package/src/handlers/core/$mounts.ts +84 -0
- package/src/handlers/core/$registry.ts +153 -0
- package/src/handlers/core/$routing.ts +205 -0
- package/src/handlers/core/$static.ts +100 -0
- package/src/handlers/core/$websocket.ts +52 -0
- package/src/handlers/index.ts +21 -0
- package/src/handlers/routes/api.ts +95 -0
- package/src/handlers/routes/html.ts +95 -0
- package/src/handlers/routes/livereload.ts +54 -0
- package/src/handlers/routes/proxy.ts +74 -0
- package/src/logger/clients.ts +12 -0
- package/src/logger/index.ts +3 -0
- package/src/logger/logger.ts +375 -0
- package/src/logger/serve-log.ts +206 -0
- package/src/plugins/index.ts +15 -0
- package/src/plugins/routes.ts +110 -0
- package/src/plugins/types.ts +19 -0
- package/src/router.ts +351 -0
- package/src/session.ts +556 -0
- package/src/shared.d.ts +63 -0
- package/src/startup.ts +154 -0
- package/src/types.d.ts +111 -0
- package/src/utils/common/case.ts +11 -0
- package/src/utils/common/index.ts +5 -0
- package/src/utils/common/json.ts +35 -0
- package/src/utils/common/match.ts +6 -0
- package/src/utils/common/misc.ts +53 -0
- package/src/utils/common/try.ts +6 -0
- package/src/utils/constants.ts +153 -0
- package/src/utils/fs.ts +621 -0
- package/src/utils/http/body.ts +65 -0
- package/src/utils/http/csrf.ts +111 -0
- package/src/utils/http/dom.ts +238 -0
- package/src/utils/http/escape.ts +8 -0
- package/src/utils/http/etag.ts +318 -0
- package/src/utils/http/html.ts +525 -0
- package/src/utils/http/index.ts +8 -0
- package/src/utils/http/ip.ts +32 -0
- package/src/utils/http/response.ts +129 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/isomorphic/case.ts +52 -0
- package/src/utils/isomorphic/escape.ts +43 -0
- package/src/utils/isomorphic/index.ts +15 -0
- package/src/utils/isomorphic/is.ts +36 -0
- package/src/utils/isomorphic/match.ts +50 -0
- package/src/utils/isomorphic/math.ts +11 -0
- package/src/utils/isomorphic/misc.ts +22 -0
- package/src/utils/isomorphic/stringify.ts +42 -0
- package/src/utils/isomorphic/try.ts +94 -0
- package/src/utils/jsonc.ts +10 -0
- package/src/utils/shared-pool.ts +193 -0
- package/tsconfig.app.json +34 -0
package/src/session.ts
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
import { TieredCache } from './cache/tiered'
|
|
2
|
+
import { Bakery, hostKey } from './core/bakery'
|
|
3
|
+
import type { MapOf } from './types'
|
|
4
|
+
import { hasDeferredValue } from './utils'
|
|
5
|
+
import { DEFAULT_SESSION_PERSIST, DEFAULT_SESSION_TTL } from './utils/constants'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Keys under this prefix are framework-internal privilege markers. They share
|
|
9
|
+
* the same bag as application data, so anything that writes a caller-supplied
|
|
10
|
+
* key must refuse this prefix — otherwise a preferences endpoint (or the
|
|
11
|
+
* dashboard's own session editor) becomes a privilege-escalation primitive.
|
|
12
|
+
*/
|
|
13
|
+
export const RESERVED_SESSION_PREFIX = '__bakery.'
|
|
14
|
+
|
|
15
|
+
/** Marks a session as having passed the DASHPASS check. */
|
|
16
|
+
export const DASHPASS_SESSION_KEY = `${RESERVED_SESSION_PREFIX}dashpass`
|
|
17
|
+
|
|
18
|
+
export function isReservedSessionKey(key: string): boolean {
|
|
19
|
+
return key.startsWith(RESERVED_SESSION_PREFIX)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Clock seam — the cookie half-life bookkeeping is time-based, and its tests
|
|
24
|
+
* inject a clock instead of sleeping (see `__setTestDb` / `__setTestConfig`
|
|
25
|
+
* for the pattern). Only the cookie-reissue math reads this; `createdAt` and
|
|
26
|
+
* TTL expiry keep `Date.now()`.
|
|
27
|
+
*/
|
|
28
|
+
let clock: () => number = Date.now
|
|
29
|
+
|
|
30
|
+
export function __setTestClock(fn: () => number): void {
|
|
31
|
+
clock = fn
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function __resetTestClock(): void {
|
|
35
|
+
clock = Date.now
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The session id is the sole bearer token, so it must be unguessable. UUIDv7
|
|
40
|
+
* carries a monotonic millisecond timestamp and only ~74 random bits, which
|
|
41
|
+
* makes ids issued at a known time partially predictable.
|
|
42
|
+
*/
|
|
43
|
+
export function newSessionId(): string {
|
|
44
|
+
return Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString(
|
|
45
|
+
'base64url',
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The cache key for a session id under the current host.
|
|
51
|
+
*
|
|
52
|
+
* There is one `TieredCache('sessions')` for the whole process, and until this
|
|
53
|
+
* existed every id went into it unqualified — so under a multi-tenant `hosts`
|
|
54
|
+
* config one tenant's dashboard listed, read and deleted every other tenant's
|
|
55
|
+
* sessions. `hostKey()` is the guard the five other per-tenant caches already
|
|
56
|
+
* use; sessions were the one that never got it.
|
|
57
|
+
*
|
|
58
|
+
* An unconfigured host resolves to the empty prefix (see `hostKey`), which is
|
|
59
|
+
* also the single-host case, so a single-host app's keys are unchanged.
|
|
60
|
+
*/
|
|
61
|
+
function sessionKey(id: string): string {
|
|
62
|
+
return hostKey(id)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `'host:'` under a configured host, `''` otherwise. */
|
|
66
|
+
function sessionScope(): string {
|
|
67
|
+
return hostKey('')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether a raw cache key belongs to `scope`.
|
|
72
|
+
*
|
|
73
|
+
* `startsWith` alone is not enough for the default scope, where every key would
|
|
74
|
+
* match. Session ids are base64url and can never contain `:`, so what follows
|
|
75
|
+
* the prefix identifies the bucket unambiguously.
|
|
76
|
+
*/
|
|
77
|
+
function inScope(key: string, scope: string): boolean {
|
|
78
|
+
return key.startsWith(scope) && !key.slice(scope.length).includes(':')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class Session<
|
|
82
|
+
T extends MapOf<any> = MapOf<any>,
|
|
83
|
+
TK extends keyof T | (string & {}) = keyof T | (string & {}),
|
|
84
|
+
> {
|
|
85
|
+
public static cache = new TieredCache<string, Session<any>>('sessions', {
|
|
86
|
+
memoryThreshold: 1000,
|
|
87
|
+
flushInterval: 30000,
|
|
88
|
+
reviver: (json: any) =>
|
|
89
|
+
Session.reconstruct({
|
|
90
|
+
id: json.id,
|
|
91
|
+
createdAt: json.createdAt,
|
|
92
|
+
persistKeys: json.persistKeys,
|
|
93
|
+
data: json.data,
|
|
94
|
+
cookieIssuedAt: json.cookieIssuedAt,
|
|
95
|
+
}),
|
|
96
|
+
shouldPersist: session => session.hasPersistedKeys() || session.hasData(),
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Process-wide session count, deliberately not host-scoped: it is a capacity
|
|
101
|
+
* metric (analytics samples it on a timer), not tenant data, and scoping it
|
|
102
|
+
* would mean walking every key on every sample.
|
|
103
|
+
*/
|
|
104
|
+
public static get count() {
|
|
105
|
+
return Session.cache.count
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
public static bind(req: Request, response?: Response) {
|
|
109
|
+
if (!response) return response
|
|
110
|
+
|
|
111
|
+
const cookieValue = Session.getCookie(req)
|
|
112
|
+
if (cookieValue) response.headers.append('Set-Cookie', cookieValue)
|
|
113
|
+
|
|
114
|
+
return response
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
public static getCookie(req: Request): string {
|
|
118
|
+
if (!hasDeferredValue(req, 'session')) return ''
|
|
119
|
+
const session = req.session
|
|
120
|
+
|
|
121
|
+
// Issue only when the session actually changed, or when the read path
|
|
122
|
+
// flagged a half-life refresh (see `markAccessed`) — not on every read.
|
|
123
|
+
// A per-request Set-Cookie made every session-carrying response unique,
|
|
124
|
+
// defeating If-None-Match, and dirtied merely-read sessions into the
|
|
125
|
+
// flush timer's write batch.
|
|
126
|
+
if (!session.modified && !session.cookieRefreshDue) return ''
|
|
127
|
+
|
|
128
|
+
// Storing here serves both cases: a modified session must persist, and a
|
|
129
|
+
// half-life refresh must renew the row's accessedAt so the pruner's
|
|
130
|
+
// server-side TTL slides with the cookie (and `cookieIssuedAt` rides
|
|
131
|
+
// along in the same write).
|
|
132
|
+
Session.cache.set(sessionKey(session.id), session)
|
|
133
|
+
session.modified = false
|
|
134
|
+
session.cookieRefreshDue = false
|
|
135
|
+
session.cookieIssuedAt = clock()
|
|
136
|
+
|
|
137
|
+
// `persistedKeys` allocates an Array off the Set purely to read `.length`;
|
|
138
|
+
// `hasPersistedKeys()` reads `Set.size` and answers the same question.
|
|
139
|
+
const hasPersistedKeys = session.hasPersistedKeys()
|
|
140
|
+
const maxAgeSeconds = hasPersistedKeys
|
|
141
|
+
? Math.floor(DEFAULT_SESSION_PERSIST / 1000)
|
|
142
|
+
: Math.floor(DEFAULT_SESSION_TTL / 1000)
|
|
143
|
+
|
|
144
|
+
// Only believe x-forwarded-proto behind a trusted proxy — the same rule
|
|
145
|
+
// getHostname and getClientIp already apply. In production default to
|
|
146
|
+
// Secure, so a terminator that omits the header can't downgrade the cookie.
|
|
147
|
+
const trustProxy = Bakery.config.trustProxy
|
|
148
|
+
const forwardedHttps =
|
|
149
|
+
trustProxy && req.headers.get('x-forwarded-proto') === 'https'
|
|
150
|
+
const isHttps = Boolean(
|
|
151
|
+
req.url?.startsWith('https:') || forwardedHttps || import.meta.env.PROD,
|
|
152
|
+
)
|
|
153
|
+
const secureFlag = isHttps ? '; Secure' : ''
|
|
154
|
+
return `sId=${session.id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSeconds}${secureFlag}`
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
public static delete(reqOrId: string | Request): boolean {
|
|
158
|
+
const id = this.getSessionId(reqOrId)
|
|
159
|
+
return id ? Session.cache.delete(sessionKey(id)) : false
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
public static create<T extends MapOf<any>>(metadata: {
|
|
163
|
+
id: string
|
|
164
|
+
persistKeys: string[] | Set<string>
|
|
165
|
+
data: Partial<T>
|
|
166
|
+
}): Session<T> {
|
|
167
|
+
const session = Session.reconstruct<T>(metadata)
|
|
168
|
+
Session.cache.set(sessionKey(session.id), session)
|
|
169
|
+
return session
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
public static reconstruct<T extends MapOf<any>>(metadata: {
|
|
173
|
+
id: string
|
|
174
|
+
createdAt?: number
|
|
175
|
+
persistKeys: string[] | Set<string>
|
|
176
|
+
data: Partial<T>
|
|
177
|
+
cookieIssuedAt?: number
|
|
178
|
+
}): Session<T> {
|
|
179
|
+
const session = new Session<T>(metadata.id, metadata.createdAt)
|
|
180
|
+
session.persistKeys = new Set(metadata.persistKeys)
|
|
181
|
+
session.rawData = { ...metadata.data }
|
|
182
|
+
session.data = session.initProxy()
|
|
183
|
+
// Rows written before the field existed revive as 0 = "unknown", which
|
|
184
|
+
// `markAccessed` reads as long past half-life — at worst one extra
|
|
185
|
+
// reissue, never a cookie that silently expires under an active user.
|
|
186
|
+
session.cookieIssuedAt = metadata.cookieIssuedAt ?? 0
|
|
187
|
+
return session
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private static getSessionId(request: Request | string): string {
|
|
191
|
+
if (typeof request === 'string') return request || newSessionId()
|
|
192
|
+
const rawReq = request as any
|
|
193
|
+
if (rawReq._session) return rawReq._session.id
|
|
194
|
+
|
|
195
|
+
if (!request.headers.has('cookie')) return ''
|
|
196
|
+
const cookieHeader = request.headers.get('cookie') || ''
|
|
197
|
+
return cookieHeader.match(/(?:^|;\s*)sId=([^;]+)/)?.[1] || ''
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
public static from<T extends MapOf<any> = MapOf<any>>(
|
|
201
|
+
request: Request,
|
|
202
|
+
): Session<T> {
|
|
203
|
+
const rawReq = request as any
|
|
204
|
+
if (rawReq._session) return rawReq._session as any
|
|
205
|
+
const sessionId = Session.getSessionId(request)
|
|
206
|
+
|
|
207
|
+
if (sessionId) {
|
|
208
|
+
const existing = Session.cache.get(sessionKey(sessionId))
|
|
209
|
+
if (existing) {
|
|
210
|
+
// Enforce the TTL on read. Previously expiry relied solely on the
|
|
211
|
+
// 15-minute prune interval, so a stolen id stayed usable past its TTL.
|
|
212
|
+
if (existing.isExpired()) {
|
|
213
|
+
Session.cache.delete(sessionKey(sessionId))
|
|
214
|
+
} else {
|
|
215
|
+
// Accessed, not modified — see `markAccessed`. `touch()` here made
|
|
216
|
+
// every session read count as a write.
|
|
217
|
+
existing.markAccessed()
|
|
218
|
+
return existing
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return new Session()
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
public readonly id!: string
|
|
227
|
+
public readonly createdAt!: number
|
|
228
|
+
|
|
229
|
+
protected modified: boolean = false
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Epoch ms when `getCookie` last emitted `Set-Cookie` for this id; 0 means
|
|
233
|
+
* never (or unknown — a row written before the field existed). Persisted
|
|
234
|
+
* through `toJSON`, so it only costs a write when a write is already
|
|
235
|
+
* happening; while the instance lives in the memory tier it carries across
|
|
236
|
+
* requests for free.
|
|
237
|
+
*/
|
|
238
|
+
protected cookieIssuedAt: number = 0
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Set by `markAccessed` when the cookie has crossed half its Max-Age;
|
|
242
|
+
* cleared when `getCookie` issues. Deliberately not persisted — it is
|
|
243
|
+
* per-instance request bookkeeping, and losing it costs nothing (the next
|
|
244
|
+
* read recomputes it from `cookieIssuedAt`).
|
|
245
|
+
*/
|
|
246
|
+
protected cookieRefreshDue: boolean = false
|
|
247
|
+
|
|
248
|
+
protected persistKeys!: Set<string>
|
|
249
|
+
|
|
250
|
+
protected rawData: Partial<T> = {}
|
|
251
|
+
public data!: Partial<T>
|
|
252
|
+
|
|
253
|
+
constructor(sessid?: string, createdAt?: number) {
|
|
254
|
+
sessid = sessid || newSessionId()
|
|
255
|
+
|
|
256
|
+
this.id = sessid
|
|
257
|
+
this.createdAt = createdAt || Date.now()
|
|
258
|
+
this.persistKeys = new Set()
|
|
259
|
+
this.rawData = {}
|
|
260
|
+
this.data = this.initProxy()
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private initProxy(): Partial<T> {
|
|
264
|
+
return new Proxy(this.rawData, {
|
|
265
|
+
get: (target, prop: string) => target[prop as keyof T],
|
|
266
|
+
set: (target, prop: string, value) => {
|
|
267
|
+
target[prop as keyof T] = value
|
|
268
|
+
this.modified = true
|
|
269
|
+
return true
|
|
270
|
+
},
|
|
271
|
+
deleteProperty: (target, prop: string) => {
|
|
272
|
+
delete target[prop as keyof T]
|
|
273
|
+
this.modified = true
|
|
274
|
+
return true
|
|
275
|
+
},
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Force the session to count as modified: stored on the next flush and its
|
|
281
|
+
* cookie re-issued. This is the published "force a cookie refresh" surface
|
|
282
|
+
* (docs/guides/sessions.md) and keeps its dirty semantics. It is *not* the
|
|
283
|
+
* read path — `Session.from` uses `markAccessed`, which slides expiry
|
|
284
|
+
* without paying write costs.
|
|
285
|
+
*/
|
|
286
|
+
public touch(): this {
|
|
287
|
+
this.modified = true
|
|
288
|
+
return this
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The read-path bump — what `Session.from` does instead of `touch()`.
|
|
293
|
+
* Reading a session must not dirty it: that made every read-only request
|
|
294
|
+
* pay a JSON.stringify + synchronous SQLite write on the next flush and
|
|
295
|
+
* re-issue Set-Cookie, which defeated If-None-Match caching.
|
|
296
|
+
*
|
|
297
|
+
* Liveness needs no work here: the `Session.cache.get` in `from` has
|
|
298
|
+
* already re-stamped the memory tier's accessedAt. What this method owns is
|
|
299
|
+
* sliding cookie expiration — once more than half the cookie's Max-Age has
|
|
300
|
+
* elapsed since it was last issued, flag a reissue. `getCookie` then emits
|
|
301
|
+
* the cookie *and* re-persists the session, which renews the DB row's
|
|
302
|
+
* accessedAt at a cadence of at most maxAge/2 against the pruner's cutoff
|
|
303
|
+
* of maxAge — so active sessions never age out server-side either.
|
|
304
|
+
*/
|
|
305
|
+
private markAccessed(): void {
|
|
306
|
+
const maxAgeMs = this.hasPersistedKeys()
|
|
307
|
+
? DEFAULT_SESSION_PERSIST
|
|
308
|
+
: DEFAULT_SESSION_TTL
|
|
309
|
+
if (clock() - this.cookieIssuedAt > maxAgeMs / 2) {
|
|
310
|
+
this.cookieRefreshDue = true
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
public get isModified() {
|
|
315
|
+
return this.modified
|
|
316
|
+
}
|
|
317
|
+
public get accessedAt() {
|
|
318
|
+
return Session.cache.getAccessedAt(sessionKey(this.id)) ?? Date.now()
|
|
319
|
+
}
|
|
320
|
+
public get persistedKeys() {
|
|
321
|
+
return Array.from(this.persistKeys)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
public hasPersistedKeys() {
|
|
325
|
+
return this.persistKeys.size > 0
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
public hasData() {
|
|
329
|
+
return Object.keys(this.rawData).length > 0
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
public isExpired(): boolean {
|
|
333
|
+
const accessed = this.accessedAt
|
|
334
|
+
return this.hasPersistedKeys()
|
|
335
|
+
? Date.now() - accessed > DEFAULT_SESSION_PERSIST
|
|
336
|
+
: Date.now() - accessed > DEFAULT_SESSION_TTL
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
public persist(key: keyof T | (string & {}), state: boolean = true): this {
|
|
340
|
+
this.persistKeys[state ? 'add' : 'delete'](key as string)
|
|
341
|
+
this.modified = true
|
|
342
|
+
|
|
343
|
+
if (this.persistKeys.size > 0) {
|
|
344
|
+
Session.cache.set(sessionKey(this.id), this)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return this
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Mint a new session id, carrying the data and persisted keys across, and
|
|
352
|
+
* drop the entry under the old one.
|
|
353
|
+
*
|
|
354
|
+
* This is the missing anti-fixation primitive. `reset()` clears data but
|
|
355
|
+
* keeps the id, so an app that does `req.session.set('userId', …)` on login
|
|
356
|
+
* finishes authentication on the *same* id the visitor arrived with —
|
|
357
|
+
* including one an attacker planted. Call it at the privilege boundary:
|
|
358
|
+
*
|
|
359
|
+
* ```ts no-check — illustrative call site, not a compiled example
|
|
360
|
+
* req.session.regenerate().set('userId', user.id, true)
|
|
361
|
+
* ```
|
|
362
|
+
*
|
|
363
|
+
* Marking the session modified is what re-issues the cookie: `getCookie`
|
|
364
|
+
* emits `sId=` from the current id and only when `modified` is set, and it is
|
|
365
|
+
* the same object `req.session` already holds, so the rest of the request
|
|
366
|
+
* sees the new id.
|
|
367
|
+
*
|
|
368
|
+
* `createdAt` is deliberately preserved — the session continues, only its
|
|
369
|
+
* bearer token is replaced.
|
|
370
|
+
*/
|
|
371
|
+
public regenerate(): this {
|
|
372
|
+
const previous = this.id
|
|
373
|
+
// `id` is readonly to callers; rotating it is the one legitimate write.
|
|
374
|
+
;(this as { id: string }).id = newSessionId()
|
|
375
|
+
|
|
376
|
+
Session.cache.delete(sessionKey(previous))
|
|
377
|
+
Session.cache.set(sessionKey(this.id), this)
|
|
378
|
+
this.modified = true
|
|
379
|
+
|
|
380
|
+
return this
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
public reset(full = false): void {
|
|
384
|
+
if (full) this.persistKeys.clear()
|
|
385
|
+
|
|
386
|
+
for (const key of Object.keys(this.rawData)) {
|
|
387
|
+
if (this.persistKeys.has(key)) continue
|
|
388
|
+
delete this.rawData[key as keyof T]
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (!this.hasPersistedKeys()) {
|
|
392
|
+
Session.cache.delete(sessionKey(this.id))
|
|
393
|
+
this.modified = false
|
|
394
|
+
// A pending half-life refresh would make `getCookie` re-store the
|
|
395
|
+
// session this branch just deleted.
|
|
396
|
+
this.cookieRefreshDue = false
|
|
397
|
+
} else {
|
|
398
|
+
Session.cache.set(sessionKey(this.id), this)
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
get<K extends keyof T>(key: K): T[K] | undefined
|
|
403
|
+
get<K extends keyof T>(key: K, defaultValue: T[K]): T[K]
|
|
404
|
+
get<R = string>(key: string & {}): R | undefined
|
|
405
|
+
get(key: string & {}, defaultValue: boolean): boolean
|
|
406
|
+
get(key: string & {}, defaultValue: number): number
|
|
407
|
+
get(key: string & {}, defaultValue: string): string
|
|
408
|
+
get<R = string>(key: string & {}, defaultValue: R): R
|
|
409
|
+
public get(key: any, defaultValue?: any): any {
|
|
410
|
+
return (this.rawData[key] ?? defaultValue) as any
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
set<K extends keyof T>(key: K, value: T[K], persist?: boolean): this
|
|
414
|
+
set<V = any>(key: string & {}, value: V, persist?: boolean): this
|
|
415
|
+
public set(key: any, value: any, persist = false): this {
|
|
416
|
+
;(this.data as any)[key] = value
|
|
417
|
+
if (persist) this.persist(key, true)
|
|
418
|
+
return this
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
public delete(key: TK, persist?: boolean): this {
|
|
422
|
+
if (!key) return this
|
|
423
|
+
|
|
424
|
+
if (persist) this.persist(key, false)
|
|
425
|
+
delete this.data[key]
|
|
426
|
+
return this
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
public bind(response?: Response) {
|
|
430
|
+
return Session.bind({ session: this } as any, response)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
public destroy(): void {
|
|
434
|
+
Session.delete(this.id)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
public toJSON() {
|
|
438
|
+
return {
|
|
439
|
+
id: this.id,
|
|
440
|
+
createdAt: this.createdAt,
|
|
441
|
+
accessedAt: this.accessedAt,
|
|
442
|
+
cookieIssuedAt: this.cookieIssuedAt,
|
|
443
|
+
persistKeys: Array.from(this.persistKeys),
|
|
444
|
+
data: { ...this.rawData },
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
static async *[Symbol.asyncIterator]() {
|
|
449
|
+
for (const [, sess] of Session.entries()) {
|
|
450
|
+
yield sess
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
static async get(sessid: string): Promise<Session<any> | undefined> {
|
|
455
|
+
return await Session.cache.get(sessionKey(sessid))
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Every enumeration below filters to the current host and yields the bare
|
|
460
|
+
* session id, not the cache key — so `Session.keys()` still returns ids the
|
|
461
|
+
* dashboard can hand back to `Session.get` / `Session.delete`.
|
|
462
|
+
*/
|
|
463
|
+
static *entries(): IterableIterator<[string, Session<any>]> {
|
|
464
|
+
const scope = sessionScope()
|
|
465
|
+
for (const [key, sess] of Session.cache.entries()) {
|
|
466
|
+
if (!inScope(key, scope)) continue
|
|
467
|
+
yield [key.slice(scope.length), sess]
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
static *values(): IterableIterator<Session<any>> {
|
|
472
|
+
for (const [, sess] of Session.entries()) yield sess
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
static *keys(): IterableIterator<string> {
|
|
476
|
+
for (const [id] of Session.entries()) yield id
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
static list(options: {
|
|
480
|
+
search?: string
|
|
481
|
+
page: number
|
|
482
|
+
pageSize: number
|
|
483
|
+
sortBy: string
|
|
484
|
+
sortOrder: 'ASC' | 'DESC'
|
|
485
|
+
}) {
|
|
486
|
+
// Fast path: with no `hosts` configured every key is already in the default
|
|
487
|
+
// bucket, so the cache's own SQL paging is correctly scoped and there is no
|
|
488
|
+
// reason to give it up.
|
|
489
|
+
//
|
|
490
|
+
// The check is on `hosts` and not on the scope, deliberately: `hostKey`
|
|
491
|
+
// collapses to '' in the unconfigured case too, so the scope alone cannot
|
|
492
|
+
// tell the two situations apart. This method used to compute one anyway and
|
|
493
|
+
// never read it — scoping lives in `Session.entries()`, which the slow path
|
|
494
|
+
// below goes through.
|
|
495
|
+
const hosts = Bakery.config.hosts
|
|
496
|
+
if (!hosts || Object.keys(hosts).length === 0) {
|
|
497
|
+
return Session.cache.search(options)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Scoped path: `TieredCache.search` pages in SQL across the whole table,
|
|
501
|
+
// which is every tenant's sessions, and it has no key-prefix predicate — so
|
|
502
|
+
// the filter has to happen before paging, in JS. Bounded by the session
|
|
503
|
+
// table, which the pruner holds down to live sessions.
|
|
504
|
+
const needle = options.search?.trim().toLowerCase() || ''
|
|
505
|
+
const matched: Session<any>[] = []
|
|
506
|
+
for (const [id, sess] of Session.entries()) {
|
|
507
|
+
if (
|
|
508
|
+
needle &&
|
|
509
|
+
!id.toLowerCase().includes(needle) &&
|
|
510
|
+
!JSON.stringify(sess).toLowerCase().includes(needle)
|
|
511
|
+
) {
|
|
512
|
+
continue
|
|
513
|
+
}
|
|
514
|
+
matched.push(sess)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const dir = options.sortOrder === 'ASC' ? 1 : -1
|
|
518
|
+
const rank = (s: Session<any>) =>
|
|
519
|
+
options.sortBy === 'id'
|
|
520
|
+
? s.id
|
|
521
|
+
: options.sortBy === 'keys'
|
|
522
|
+
? s.persistedKeys.length
|
|
523
|
+
: s.accessedAt
|
|
524
|
+
matched.sort((a, b) => {
|
|
525
|
+
const [ra, rb] = [rank(a), rank(b)]
|
|
526
|
+
if (ra < rb) return -dir
|
|
527
|
+
if (ra > rb) return dir
|
|
528
|
+
return a.id < b.id ? -dir : a.id > b.id ? dir : 0
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
const totalRows = matched.length
|
|
532
|
+
const totalPages = Math.max(1, Math.ceil(totalRows / options.pageSize))
|
|
533
|
+
const page = Math.min(options.page, totalPages)
|
|
534
|
+
const offset = Math.max(0, (page - 1) * options.pageSize)
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
rows: matched.slice(offset, offset + options.pageSize),
|
|
538
|
+
totalRows,
|
|
539
|
+
page,
|
|
540
|
+
pageSize: options.pageSize,
|
|
541
|
+
totalPages,
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const sessionPruneTimer = setInterval(
|
|
547
|
+
function cleanUpSessions() {
|
|
548
|
+
Session.cache.prune(DEFAULT_SESSION_TTL, '$.persistKeys')
|
|
549
|
+
Session.cache.prune(DEFAULT_SESSION_PERSIST)
|
|
550
|
+
},
|
|
551
|
+
1000 * 60 * 15, // prune every 15 minutes
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
Bakery.onShutdown(() => {
|
|
555
|
+
clearInterval(sessionPruneTimer)
|
|
556
|
+
})
|
package/src/shared.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { MapOf } from './types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ambient types shared by the server and the browser runtime.
|
|
5
|
+
*
|
|
6
|
+
* These used to be declared twice, verbatim, in `global.d.ts` and
|
|
7
|
+
* `client/globals.d.ts`. Nothing caught it: `skipLibCheck: true` suppresses
|
|
8
|
+
* errors *inside* .d.ts files — including this project's own — so four TS2300
|
|
9
|
+
* duplicate-identifier errors sat there invisibly, and every tsconfig excluded
|
|
10
|
+
* `src/client/**` on top of that.
|
|
11
|
+
*
|
|
12
|
+
* Convention 5 is about exactly this: write it once, at the lowest layer that
|
|
13
|
+
* can hold it. `isomorphic.test.ts` pins the same rule for shared *values*
|
|
14
|
+
* because a second copy reappeared there once before; this is the type-level
|
|
15
|
+
* equivalent, and `tests/conventions.test.ts` now fails if a global name is
|
|
16
|
+
* declared in two ambient files again.
|
|
17
|
+
*
|
|
18
|
+
* `MapOf` and `Wrapped` have since moved out of the global scope entirely,
|
|
19
|
+
* into `types.d.ts` — their names are generic enough to collide with a
|
|
20
|
+
* consuming app's own. What stays global here is framework-specific enough
|
|
21
|
+
* not to, and is needed by browser globals that are bound at runtime rather
|
|
22
|
+
* than imported.
|
|
23
|
+
*
|
|
24
|
+
* Anything here must make sense in a browser with no Bun and no server config.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
declare global {
|
|
28
|
+
/** The one JSON envelope — see convention 7. */
|
|
29
|
+
type JsonResponse<T = any> = {
|
|
30
|
+
time: number
|
|
31
|
+
status: number
|
|
32
|
+
message: string
|
|
33
|
+
data?: T
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type ISFunction = {
|
|
37
|
+
(value: any, type: 'string'): value is string
|
|
38
|
+
(value: any, type: 'number'): value is number
|
|
39
|
+
(value: any, type: 'boolean'): value is boolean
|
|
40
|
+
(value: any, type: 'bigint'): value is bigint
|
|
41
|
+
(value: any, type: 'symbol'): value is symbol
|
|
42
|
+
(value: any, type: 'object'): value is Record<string, any>
|
|
43
|
+
(value: any, type: 'array'): value is any[]
|
|
44
|
+
(value: any, type: 'null'): value is null
|
|
45
|
+
(value: any, type: 'undefined'): value is undefined
|
|
46
|
+
// `Function` is the type being narrowed to, and there is no narrower
|
|
47
|
+
// spelling of "callable" a predicate can promise. `noBannedTypes` is off
|
|
48
|
+
// repo-wide, so this needs no suppression — see MONOREPO.md for why.
|
|
49
|
+
(value: any, type: 'function'): value is Function
|
|
50
|
+
(value: any, type?: string): boolean
|
|
51
|
+
string(value: any): value is string
|
|
52
|
+
number(value: any): value is number
|
|
53
|
+
boolean(value: any): value is boolean
|
|
54
|
+
bigint(value: any): value is bigint
|
|
55
|
+
symbol(value: any): value is symbol
|
|
56
|
+
object(value: any): value is MapOf<any>
|
|
57
|
+
array(value: any): value is any[]
|
|
58
|
+
null(value: any): value is null
|
|
59
|
+
undefined(value: any): value is undefined
|
|
60
|
+
// `Function` again, same reason as the overload above.
|
|
61
|
+
function(value: any): value is Function
|
|
62
|
+
}
|
|
63
|
+
}
|