@davaux/session 0.8.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/CLAUDE.md +95 -0
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/package.json +34 -0
- package/src/index.ts +186 -0
- package/tsconfig.json +17 -0
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<!-- pka-generated -->
|
|
2
|
+
# @davaux/session
|
|
3
|
+
|
|
4
|
+
> Generated by Project Knowledge Analyzer on 2026-06-06T21:53:10.444Z
|
|
5
|
+
|
|
6
|
+
## Overview
|
|
7
|
+
|
|
8
|
+
HMAC-signed cookie sessions for Davaux
|
|
9
|
+
|
|
10
|
+
**Version**: 0.8.0
|
|
11
|
+
**Author**: David L Dyess II
|
|
12
|
+
**License**: MIT
|
|
13
|
+
**Repository**: https://codeberg.org/davaux/davaux#readme
|
|
14
|
+
|
|
15
|
+
## Tech Stack
|
|
16
|
+
|
|
17
|
+
- **Language**: TypeScript
|
|
18
|
+
- **Module System**: ESM (`type: module`)
|
|
19
|
+
|
|
20
|
+
## Commands
|
|
21
|
+
|
|
22
|
+
- `npm run build` — tsc
|
|
23
|
+
- `npm run typecheck` — tsc --noEmit
|
|
24
|
+
|
|
25
|
+
## Project Structure
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
├── CLAUDE.md
|
|
29
|
+
├── README.md
|
|
30
|
+
├── package.json
|
|
31
|
+
├── tsconfig.json
|
|
32
|
+
└── src/
|
|
33
|
+
└── index.ts
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Entry Points
|
|
38
|
+
|
|
39
|
+
- `src/index.ts`
|
|
40
|
+
|
|
41
|
+
## Files by Type
|
|
42
|
+
|
|
43
|
+
### Documentation (2)
|
|
44
|
+
- `CLAUDE.md`
|
|
45
|
+
- `README.md`
|
|
46
|
+
|
|
47
|
+
### Config (2)
|
|
48
|
+
- `package.json`
|
|
49
|
+
- `tsconfig.json`
|
|
50
|
+
|
|
51
|
+
### Module (1)
|
|
52
|
+
- `src/index.ts`
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
## Git
|
|
56
|
+
- **Branch**: main
|
|
57
|
+
- **Last Commit**: chore: Add alpha status note to README
|
|
58
|
+
- **Author**: David Dyess II
|
|
59
|
+
- **Date**: 2026-06-06 15:52:27 -0600
|
|
60
|
+
- **Remote**: https://codeberg.org/davaux/davaux.git
|
|
61
|
+
|
|
62
|
+
### Recent Commits
|
|
63
|
+
```
|
|
64
|
+
f527031 chore: Add alpha status note to README
|
|
65
|
+
90c819e chore: Add repo info to package.json files
|
|
66
|
+
b200d9d feat(davaux)!: Add OmlCacheConfig - opt-in with includes option or opt-out with excludes option; Fix OML implementation to follow OML spec - use output instead of return
|
|
67
|
+
3bce0c2 chore: Update and add READMEs to packages; update ROADMAP
|
|
68
|
+
fc27c20 fix(davaux): Remove old dist folder on new builds; fix server port per DavauxConfig
|
|
69
|
+
c760659 feat(davaux): Add minify CSS in production builds
|
|
70
|
+
c899ab8 fix(davaux): Added method JS and JSX extenstion to scanner
|
|
71
|
+
7d99f04 feat(davaux): Add support for declarative partial updates
|
|
72
|
+
3b47f37 chore: Bump package versions to 0.8.0
|
|
73
|
+
aa0460f chore: Add CHANGELOG.md
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Dependencies
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
### Development
|
|
80
|
+
@types/node, davaux, typescript
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
## Exported Symbols
|
|
91
|
+
|
|
92
|
+
**`src/index.ts`**
|
|
93
|
+
`Session`, `sessionMiddleware`, `SessionOptions`
|
|
94
|
+
|
|
95
|
+
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David L Dyess II
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# @davaux/session
|
|
2
|
+
|
|
3
|
+
HMAC-signed cookie sessions for Davaux.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @davaux/session
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
Add the middleware to your `davaux.config.ts`. The `secret` must be at least 32 characters — use an environment variable in production:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// davaux.config.ts
|
|
17
|
+
import { defineConfig } from 'davaux/config'
|
|
18
|
+
import { sessionMiddleware } from '@davaux/session'
|
|
19
|
+
|
|
20
|
+
export default defineConfig({
|
|
21
|
+
middleware: [
|
|
22
|
+
sessionMiddleware({ secret: process.env.SESSION_SECRET! }),
|
|
23
|
+
],
|
|
24
|
+
})
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Reading and writing session data
|
|
28
|
+
|
|
29
|
+
`ctx.state.session` is available in every handler, layout, and middleware after setup:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
// src/routes/login.page.tsx
|
|
33
|
+
import { definePage, redirect } from 'davaux'
|
|
34
|
+
|
|
35
|
+
export default definePage(async (ctx) => {
|
|
36
|
+
if (ctx.req.method === 'POST') {
|
|
37
|
+
const form = await ctx.formData()
|
|
38
|
+
ctx.state.session.set('userId', form.get('userId'))
|
|
39
|
+
redirect('/dashboard')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return <form method='post'>...</form>
|
|
43
|
+
})
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
// src/routes/dashboard.page.tsx
|
|
48
|
+
import { definePage, redirect } from 'davaux'
|
|
49
|
+
|
|
50
|
+
export default definePage((ctx) => {
|
|
51
|
+
const userId = ctx.state.session.get('userId')
|
|
52
|
+
if (!userId) redirect('/login')
|
|
53
|
+
|
|
54
|
+
return <h1>Welcome, {userId}</h1>
|
|
55
|
+
})
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Session API
|
|
59
|
+
|
|
60
|
+
`ctx.state.session` exposes:
|
|
61
|
+
|
|
62
|
+
| Method | Description |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `get(key)` | Return the value for `key`, or `undefined` |
|
|
65
|
+
| `set(key, value)` | Store a value (must be JSON-serializable) |
|
|
66
|
+
| `delete(key)` | Remove a key |
|
|
67
|
+
| `clear()` | Wipe the entire session |
|
|
68
|
+
| `destroy()` | Clear the session and expire the cookie |
|
|
69
|
+
|
|
70
|
+
## Options
|
|
71
|
+
|
|
72
|
+
| Option | Type | Default | Description |
|
|
73
|
+
|---|---|---|---|
|
|
74
|
+
| `secret` | `string` | — | HMAC signing secret (required, min 32 chars) |
|
|
75
|
+
| `cookieName` | `string` | `'session'` | Name of the session cookie |
|
|
76
|
+
| `maxAge` | `number` | `86400` | Cookie lifetime in seconds |
|
|
77
|
+
| `httpOnly` | `boolean` | `true` | Set the `HttpOnly` flag |
|
|
78
|
+
| `secure` | `boolean` | `false` | Set the `Secure` flag (enable in production) |
|
|
79
|
+
| `sameSite` | `'strict' \| 'lax' \| 'none'` | `'lax'` | `SameSite` attribute |
|
|
80
|
+
| `path` | `string` | `'/'` | Cookie path |
|
|
81
|
+
|
|
82
|
+
## TypeScript
|
|
83
|
+
|
|
84
|
+
`ctx.state.session` is typed automatically when you import `@davaux/session`. If you need the `Session` type directly:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import type { Session } from '@davaux/session'
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Notes
|
|
91
|
+
|
|
92
|
+
- Sessions are stored entirely in a signed cookie — no server-side store required
|
|
93
|
+
- The cookie payload is Base64-encoded JSON; it is signed but not encrypted — do not store secrets in the session
|
|
94
|
+
- Browsers enforce a ~4 KB per-cookie limit; keep session data minimal (user IDs, flags) and load larger data from your database in the handler
|
|
95
|
+
|
|
96
|
+
## Documentation
|
|
97
|
+
|
|
98
|
+
[davaux.codeberg.page/docs/session](https://davaux.codeberg.page/docs/session)
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@davaux/session",
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "HMAC-signed cookie sessions for Davaux",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"author": "David L Dyess II",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://codeberg.org/davaux/davaux"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://codeberg.org/davaux/davaux/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://codeberg.org/davaux/davaux#readme",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"import": "./dist/index.js",
|
|
19
|
+
"types": "./dist/index.d.ts"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc",
|
|
24
|
+
"typecheck": "tsc --noEmit"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"davaux": ">=0.8.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^25.0.0",
|
|
31
|
+
"davaux": "*",
|
|
32
|
+
"typescript": "^6.0.3"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import type { ServerResponse } from 'node:http'
|
|
3
|
+
import type { MiddlewareFn } from 'davaux'
|
|
4
|
+
|
|
5
|
+
// ─── State augmentation ───────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
declare module 'davaux' {
|
|
8
|
+
interface State {
|
|
9
|
+
session: Session
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// ─── Session ──────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Per-request session object. Stores arbitrary key-value data serialised into
|
|
17
|
+
* an HMAC-signed cookie. Mutations (`set`, `delete`, `destroy`) are persisted
|
|
18
|
+
* automatically before the response headers are sent.
|
|
19
|
+
*/
|
|
20
|
+
export class Session {
|
|
21
|
+
private readonly _data: Record<string, unknown>
|
|
22
|
+
private _dirty = false
|
|
23
|
+
private _destroyed = false
|
|
24
|
+
|
|
25
|
+
constructor(data: Record<string, unknown> = {}) {
|
|
26
|
+
this._data = data
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Retrieve a session value by key. Returns `undefined` when absent. */
|
|
30
|
+
get<T = unknown>(key: string): T | undefined {
|
|
31
|
+
return this._data[key] as T | undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Store a value under `key`. Marks the session dirty so it is persisted. */
|
|
35
|
+
set(key: string, value: unknown): void {
|
|
36
|
+
this._data[key] = value
|
|
37
|
+
this._dirty = true
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Remove `key` from the session. Marks the session dirty so it is persisted. */
|
|
41
|
+
delete(key: string): void {
|
|
42
|
+
delete this._data[key]
|
|
43
|
+
this._dirty = true
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Clear all session data and delete the cookie on response. */
|
|
47
|
+
destroy(): void {
|
|
48
|
+
this._destroyed = true
|
|
49
|
+
this._dirty = false
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Snapshot of current session data. */
|
|
53
|
+
get data(): Record<string, unknown> {
|
|
54
|
+
return { ...this._data }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @internal */
|
|
58
|
+
get dirty(): boolean {
|
|
59
|
+
return this._dirty
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @internal */
|
|
63
|
+
get destroyed(): boolean {
|
|
64
|
+
return this._destroyed
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Options ──────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
export interface SessionOptions {
|
|
71
|
+
/**
|
|
72
|
+
* Secret(s) used to sign the session cookie with HMAC-SHA256.
|
|
73
|
+
* Pass an array for rotation: the first secret signs new sessions,
|
|
74
|
+
* all are accepted for verification.
|
|
75
|
+
*/
|
|
76
|
+
secret: string | string[]
|
|
77
|
+
/** Cookie name. Default: `'session'` */
|
|
78
|
+
cookieName?: string
|
|
79
|
+
/** Max-Age in seconds. Omit for a session cookie (clears on browser close). */
|
|
80
|
+
maxAge?: number
|
|
81
|
+
/** Default: `true` */
|
|
82
|
+
httpOnly?: boolean
|
|
83
|
+
/** Default: `false`. Enable in production to require HTTPS. */
|
|
84
|
+
secure?: boolean
|
|
85
|
+
/** Default: `'Lax'` */
|
|
86
|
+
sameSite?: 'Strict' | 'Lax' | 'None'
|
|
87
|
+
/** Default: `'/'` */
|
|
88
|
+
path?: string
|
|
89
|
+
domain?: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Signing ──────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
function sign(payload: string, secret: string): string {
|
|
95
|
+
const sig = createHmac('sha256', secret).update(payload).digest('base64url')
|
|
96
|
+
return `${payload}.${sig}`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function verify(value: string, secrets: string[]): Record<string, unknown> | null {
|
|
100
|
+
const dot = value.lastIndexOf('.')
|
|
101
|
+
if (dot === -1) return null
|
|
102
|
+
|
|
103
|
+
const payload = value.slice(0, dot)
|
|
104
|
+
const sigBuf = Buffer.from(value.slice(dot + 1), 'base64url')
|
|
105
|
+
|
|
106
|
+
for (const secret of secrets) {
|
|
107
|
+
const expectedBuf = createHmac('sha256', secret).update(payload).digest()
|
|
108
|
+
if (expectedBuf.length === sigBuf.length && timingSafeEqual(expectedBuf, sigBuf)) {
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8')) as Record<
|
|
111
|
+
string,
|
|
112
|
+
unknown
|
|
113
|
+
>
|
|
114
|
+
} catch {
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─── Middleware ───────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* HMAC-signed cookie session middleware. Parses the incoming session cookie,
|
|
127
|
+
* exposes the data as `ctx.state.session`, and automatically writes an updated
|
|
128
|
+
* cookie when the session is mutated or destroyed.
|
|
129
|
+
*
|
|
130
|
+
* Pass an array of secrets for key rotation: the first secret signs new
|
|
131
|
+
* sessions, all secrets are accepted for verification.
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* import { sessionMiddleware } from '@davaux/session'
|
|
135
|
+
* export default sessionMiddleware({
|
|
136
|
+
* secret: process.env.SESSION_SECRET!,
|
|
137
|
+
* maxAge: 60 * 60 * 24 * 7, // 1 week
|
|
138
|
+
* })
|
|
139
|
+
*/
|
|
140
|
+
export function sessionMiddleware(options: SessionOptions): MiddlewareFn {
|
|
141
|
+
const secrets = Array.isArray(options.secret) ? options.secret : [options.secret]
|
|
142
|
+
if (secrets.length === 0 || secrets.some((s) => !s)) {
|
|
143
|
+
throw new Error('@davaux/session: at least one non-empty secret is required')
|
|
144
|
+
}
|
|
145
|
+
if (secrets.some((s) => s.length < 32)) {
|
|
146
|
+
console.warn(
|
|
147
|
+
'@davaux/session: secret should be at least 32 characters — short secrets are weak and easy to brute-force',
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const name = options.cookieName ?? 'session'
|
|
152
|
+
|
|
153
|
+
return async (ctx, next) => {
|
|
154
|
+
const raw = ctx.cookies.get(name)
|
|
155
|
+
const existing = raw ? verify(raw, secrets) : null
|
|
156
|
+
const session = new Session(existing ?? {})
|
|
157
|
+
ctx.state.session = session
|
|
158
|
+
|
|
159
|
+
// Inject the session cookie right before headers are committed.
|
|
160
|
+
// This approach handles the common case where the route handler calls
|
|
161
|
+
// res.writeHead() inside next(), after which res.setHeader() would fail.
|
|
162
|
+
const originalWriteHead = ctx.res.writeHead.bind(ctx.res) as typeof ctx.res.writeHead
|
|
163
|
+
// biome-ignore lint/suspicious/noExplicitAny: writeHead has multiple overloads
|
|
164
|
+
ctx.res.writeHead = ((...args: any[]) => {
|
|
165
|
+
ctx.res.writeHead = originalWriteHead
|
|
166
|
+
if (session.destroyed) {
|
|
167
|
+
ctx.cookies.delete(name, { path: options.path ?? '/' })
|
|
168
|
+
} else if (session.dirty) {
|
|
169
|
+
const payload = Buffer.from(JSON.stringify(session.data)).toString('base64url')
|
|
170
|
+
const signed = sign(payload, secrets[0])
|
|
171
|
+
ctx.cookies.set(name, signed, {
|
|
172
|
+
httpOnly: options.httpOnly ?? true,
|
|
173
|
+
secure: options.secure ?? false,
|
|
174
|
+
sameSite: options.sameSite ?? 'Lax',
|
|
175
|
+
maxAge: options.maxAge,
|
|
176
|
+
path: options.path ?? '/',
|
|
177
|
+
domain: options.domain,
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
// biome-ignore lint/suspicious/noExplicitAny: forwarding overloaded args
|
|
181
|
+
return (originalWriteHead as (...a: any[]) => ServerResponse)(...args)
|
|
182
|
+
}) as typeof ctx.res.writeHead
|
|
183
|
+
|
|
184
|
+
await next()
|
|
185
|
+
}
|
|
186
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"declarationMap": true,
|
|
9
|
+
"sourceMap": true,
|
|
10
|
+
"outDir": "./dist",
|
|
11
|
+
"rootDir": "./src",
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"lib": ["ESNext"],
|
|
14
|
+
"types": ["node"]
|
|
15
|
+
},
|
|
16
|
+
"include": ["src/**/*"]
|
|
17
|
+
}
|