expediate 1.0.4 → 1.0.6

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 (69) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/CONTRIBUTING.md +150 -0
  3. package/LICENSE +16 -16
  4. package/README.md +330 -444
  5. package/dist/apis.d.ts +504 -27
  6. package/dist/apis.d.ts.map +1 -1
  7. package/dist/apis.js +618 -107
  8. package/dist/apis.js.map +1 -1
  9. package/dist/cjs/index.js +4066 -0
  10. package/dist/cjs/package.json +1 -0
  11. package/dist/git.d.ts +72 -9
  12. package/dist/git.d.ts.map +1 -1
  13. package/dist/git.js +129 -74
  14. package/dist/git.js.map +1 -1
  15. package/dist/http-objects.d.ts +26 -0
  16. package/dist/http-objects.d.ts.map +1 -0
  17. package/dist/http-objects.js +588 -0
  18. package/dist/http-objects.js.map +1 -0
  19. package/dist/index.d.ts +18 -13
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +15 -24
  22. package/dist/index.js.map +1 -1
  23. package/dist/jwt-auth.d.ts +158 -57
  24. package/dist/jwt-auth.d.ts.map +1 -1
  25. package/dist/jwt-auth.js +447 -207
  26. package/dist/jwt-auth.js.map +1 -1
  27. package/dist/middleware.d.ts +476 -0
  28. package/dist/middleware.d.ts.map +1 -0
  29. package/dist/middleware.js +647 -0
  30. package/dist/middleware.js.map +1 -0
  31. package/dist/mimetypes.json +882 -1
  32. package/dist/misc.d.ts +268 -25
  33. package/dist/misc.d.ts.map +1 -1
  34. package/dist/misc.js +449 -168
  35. package/dist/misc.js.map +1 -1
  36. package/dist/openapi.d.ts +433 -0
  37. package/dist/openapi.d.ts.map +1 -0
  38. package/dist/openapi.js +624 -0
  39. package/dist/openapi.js.map +1 -0
  40. package/dist/router-types.d.ts +760 -0
  41. package/dist/router-types.d.ts.map +1 -0
  42. package/dist/router-types.js +23 -0
  43. package/dist/router-types.js.map +1 -0
  44. package/dist/router.d.ts +37 -201
  45. package/dist/router.d.ts.map +1 -1
  46. package/dist/router.js +502 -244
  47. package/dist/router.js.map +1 -1
  48. package/dist/static.d.ts +3 -3
  49. package/dist/static.d.ts.map +1 -1
  50. package/dist/static.js +164 -105
  51. package/dist/static.js.map +1 -1
  52. package/docs/THREAT_MODEL.md +52 -0
  53. package/docs/api-builder-v2-design.md +644 -0
  54. package/docs/api-builder-v3-design.md +397 -0
  55. package/docs/api-builder.md +454 -0
  56. package/docs/benchmark.md +27 -0
  57. package/docs/body-parsing.md +223 -0
  58. package/docs/errors.md +359 -0
  59. package/docs/expediate.png +0 -0
  60. package/docs/git.md +139 -0
  61. package/docs/jwt-auth.md +251 -0
  62. package/docs/logo.svg +12 -0
  63. package/docs/middleware.md +264 -0
  64. package/docs/openapi.md +180 -0
  65. package/docs/router.md +356 -0
  66. package/docs/static.md +128 -0
  67. package/docs/wiki.json +123 -0
  68. package/package.json +47 -8
  69. package/.npmignore +0 -16
package/docs/git.md ADDED
@@ -0,0 +1,139 @@
1
+ # Git Smart HTTP Gateway
2
+
3
+ `gitHandler` implements the Git Smart HTTP protocol, enabling clients to clone, fetch, and push to Git repositories over plain HTTP. No additional dependencies beyond a working `git` installation are required.
4
+
5
+ ---
6
+
7
+ ## Quick start
8
+
9
+ ```ts
10
+ import { createRouter, gitHandler } from 'expediate';
11
+ import path from 'path';
12
+
13
+ const app = createRouter();
14
+
15
+ app.use('/repos/:repo', gitHandler({
16
+ repository: (req) => {
17
+ const name = req.params.repo;
18
+ // Validate to prevent path confusion
19
+ if (!/^[\w.-]+$/.test(name)) return null;
20
+ return path.join('/srv/git', name + '.git');
21
+ },
22
+ }));
23
+
24
+ app.listen(3000);
25
+ ```
26
+
27
+ Clients can now clone, fetch, and push:
28
+
29
+ ```bash
30
+ git clone http://localhost:3000/repos/myproject
31
+ git push http://localhost:3000/repos/myproject HEAD:main
32
+ ```
33
+
34
+ ---
35
+
36
+ ## Supported endpoints
37
+
38
+ | Method | Path pattern | Git service | Purpose |
39
+ |---|---|---|---|
40
+ | `GET` | `/info/refs?service=git-upload-pack` | `git-upload-pack` | Capability advertisement (fetch/clone) |
41
+ | `POST` | `/git-upload-pack` | `git-upload-pack` | Pack negotiation and transfer (fetch/clone) |
42
+ | `GET` | `/info/refs?service=git-receive-pack` | `git-receive-pack` | Capability advertisement (push) |
43
+ | `POST` | `/git-receive-pack` | `git-receive-pack` | Pack transfer (push) |
44
+
45
+ `gitHandler` registers all four endpoints under the mounted prefix.
46
+
47
+ ---
48
+
49
+ ## `gitHandler(options)`
50
+
51
+ ### Options
52
+
53
+ | Option | Type | Default | Description |
54
+ |---|---|---|---|
55
+ | `repository` | `(req) => string \| null \| false` | **required** | Resolve the absolute path to the bare Git repository. Return a falsy value to send `404` |
56
+ | `gitPath` | `string` | `''` | Filesystem path prefix to the `git` binary directory (include trailing `/`) |
57
+ | `strict` | `boolean` | `false` | When `true`, passes `--strict` to `git-upload-pack`; rejects non-bare repositories |
58
+ | `timeout` | `number \| string` | — | Kill `git-upload-pack` after this many **seconds** if it does not finish |
59
+
60
+ The `repository` callback receives the augmented `RouterRequest`, so `req.params`, `req.cookies`, and any upstream middleware (e.g. auth) are available:
61
+
62
+ ```ts
63
+ gitHandler({
64
+ repository: async (req) => {
65
+ // Auth check using upstream middleware result
66
+ if (!(req as any).user) return null;
67
+ const name = (req as any).user.repoName;
68
+ return path.join('/srv/git', name + '.git');
69
+ },
70
+ })
71
+ ```
72
+
73
+ ### Error handling
74
+
75
+ | Situation | Response |
76
+ |---|---|
77
+ | `repository()` returns falsy | `404 Not Found` |
78
+ | `git` spawn fails (e.g. not installed) | `500 Internal Server Error` |
79
+ | `git` exits with non-zero code | `500 Internal Server Error` |
80
+ | Unrecognised path (not a git endpoint) | Calls `next()` (falls through) |
81
+
82
+ Client disconnections during streaming (`EPIPE` on stdin) are silently ignored.
83
+
84
+ ### Gzip bodies
85
+
86
+ `POST` bodies with `Content-Encoding: gzip` are decompressed transparently before being piped to the git process.
87
+
88
+ ---
89
+
90
+ ## `gitCreate(gitDirectory, options?)`
91
+
92
+ Programmatically initialise a new Git repository:
93
+
94
+ ```ts
95
+ import { gitCreate } from 'expediate';
96
+
97
+ await gitCreate('/srv/git/myproject.git', {
98
+ description: 'My project repository',
99
+ });
100
+ ```
101
+
102
+ Runs `git init` (`--bare` by default) and optionally writes a `description` file.
103
+
104
+ | Option | Type | Default | Description |
105
+ |---|---|---|---|
106
+ | `description` | `string` | — | Contents of the repository's `description` file |
107
+ | `gitPath` | `string` | — | Same as `gitHandler` — directory prefix for the `git` binary |
108
+ | `bare` | `boolean` | `true` | Initialise as a bare repository (no working tree). Pass `false` for a normal repository with a checked-out working tree. |
109
+
110
+ ---
111
+
112
+ ## Access control
113
+
114
+ `gitHandler` itself does not enforce authentication or authorisation. Wire in upstream middleware before the handler:
115
+
116
+ ```ts
117
+ // Require Bearer token for all git operations
118
+ const requireToken: Middleware = (req, res, next) => {
119
+ const auth = req.headers.authorization;
120
+ if (!auth?.startsWith('Bearer ')) return res.status(401).end('Unauthorized');
121
+ next();
122
+ };
123
+
124
+ app.use('/repos/:repo', requireToken, gitHandler({ repository: resolveRepo }));
125
+ ```
126
+
127
+ For repository-level read/write separation, inspect `req.path` and `req.method` inside the `repository` callback:
128
+
129
+ ```ts
130
+ gitHandler({
131
+ repository: (req) => {
132
+ const name = req.params.repo;
133
+ const isPush = req.path.endsWith('/git-receive-pack') ||
134
+ (req.path.endsWith('/info/refs') && req.query['service'] === 'git-receive-pack');
135
+ if (isPush && !(req as any).user?.canPush) return null;
136
+ return path.join('/srv/git', name + '.git');
137
+ },
138
+ })
139
+ ```
@@ -0,0 +1,251 @@
1
+ # JWT Authentication
2
+
3
+ `createJwtPlugin()` returns a self-contained authentication plugin built on Node.js `crypto` — no external JWT library is required.
4
+
5
+ Features: HMAC / RSA / ECDSA algorithms, refresh token rotation, role and permission guards, pluggable user database, pluggable token store.
6
+
7
+ ---
8
+
9
+ ## Quick start
10
+
11
+ ```ts
12
+ import { createRouter, json, createJwtPlugin, createMapTokenStore } from 'expediate';
13
+
14
+ const app = createRouter();
15
+ const auth = createJwtPlugin({
16
+ accessTokenSecret: process.env.JWT_SECRET!,
17
+ refreshTokenStore: createMapTokenStore(), // omit to disable refresh tokens entirely
18
+ });
19
+
20
+ // Auth endpoints (all require the json() body parser)
21
+ app.post('/auth/login', json(), auth.login);
22
+ app.post('/auth/refresh', json(), auth.refresh);
23
+ app.post('/auth/logout', json(), auth.logout);
24
+
25
+ // Protected route
26
+ app.get('/me', auth.authenticate, auth.authorize, (req, res) => {
27
+ res.json((req as any).user);
28
+ });
29
+ ```
30
+
31
+ Refresh tokens are opt-in: `auth.login` only includes a `refreshToken` in its
32
+ response when `refreshTokenStore` is configured, and `auth.refresh` responds
33
+ `501 Not Implemented` otherwise.
34
+
35
+ ---
36
+
37
+ ## Auth endpoints
38
+
39
+ ### `POST /auth/login`
40
+
41
+ ```json
42
+ // Request
43
+ { "username": "alice", "password": "password123" }
44
+
45
+ // Response 200 (refreshToken present only when refreshTokenStore is configured)
46
+ {
47
+ "accessToken": "eyJ...",
48
+ "refreshToken": "eyJ...",
49
+ "expiresIn": 900,
50
+ "tokenType": "Bearer"
51
+ }
52
+ ```
53
+
54
+ ### `POST /auth/refresh`
55
+
56
+ ```json
57
+ // Request
58
+ { "refreshToken": "eyJ..." }
59
+
60
+ // Response 200 — new token pair (old refresh token is invalidated immediately)
61
+ { "accessToken": "eyJ...", "refreshToken": "eyJ...", "expiresIn": 900, "tokenType": "Bearer" }
62
+ ```
63
+
64
+ Refresh tokens are **rotated** on every use — the old token is invalidated before the new pair is issued, so a stolen refresh token can only be used once. Responds `501 Not Implemented` if no `refreshTokenStore` was configured.
65
+
66
+ ### `POST /auth/logout`
67
+
68
+ ```json
69
+ // Request
70
+ { "refreshToken": "b9c2..." }
71
+
72
+ // Response 200 — refresh token revoked
73
+ { "message": "Logged out successfully" }
74
+ ```
75
+
76
+ ---
77
+
78
+ ## Protecting routes
79
+
80
+ ```ts
81
+ // authenticate — populates req.user; calls next() silently on failure
82
+ // authorize — rejects with 401 Unauthorized if req.user is not set
83
+ app.get('/profile', auth.authenticate, auth.authorize, handler);
84
+ ```
85
+
86
+ `authenticate` always clears `req.user` at the start of the middleware call to prevent stale data leaking across requests. This split design means you can optionally inspect `req.user` in middleware before requiring it.
87
+
88
+ `req.user` shape when authenticated:
89
+
90
+ ```ts
91
+ interface TokenPayload {
92
+ sub: string; // user ID
93
+ username: string;
94
+ iss: string; // issuer
95
+ iat: number; // issued at (Unix seconds)
96
+ exp: number; // expires at (Unix seconds)
97
+ roles?: string[];
98
+ permissions?: string[];
99
+ }
100
+ ```
101
+
102
+ ---
103
+
104
+ ## Role and permission guards
105
+
106
+ Both helpers return `[authenticate, guard]` — spread them into route registration:
107
+
108
+ ```ts
109
+ // Require at least ONE of the listed roles
110
+ app.delete('/admin/users/:id', ...auth.requireRole('admin'), deleteUser);
111
+ app.get('/reports', ...auth.requireRole('admin', 'editor'), getReports);
112
+
113
+ // Require ALL of the listed permissions
114
+ app.put('/posts/:id', ...auth.requirePermission('write', 'publish'), updatePost);
115
+ ```
116
+
117
+ ---
118
+
119
+ ## Configuration reference
120
+
121
+ ```ts
122
+ const auth = createJwtPlugin({
123
+ // ── Secrets (always set in production) ──────────────────────────────────
124
+ accessTokenSecret: 'change-me', // shared HMAC secret (HS*)
125
+ refreshTokenSecret: 'change-me-too', // shared HMAC secret for refresh JWTs (HS*)
126
+
127
+ // For asymmetric algorithms (RS*, ES*), supply PEM keys instead:
128
+ // accessTokenPrivateKey: readFileSync('private.pem', 'utf8'),
129
+ // accessTokenPublicKey: readFileSync('public.pem', 'utf8'),
130
+ // Optional separate key pair for refresh tokens — falls back to the
131
+ // access token keys above when omitted:
132
+ // refreshTokenPrivateKey: readFileSync('refresh-private.pem', 'utf8'),
133
+ // refreshTokenPublicKey: readFileSync('refresh-public.pem', 'utf8'),
134
+
135
+ // ── Algorithm ───────────────────────────────────────────────────────────
136
+ alg: 'HS256', // 'HS256' | 'HS384' | 'HS512'
137
+ // 'RS256' | 'RS384' | 'RS512'
138
+ // 'ES256' | 'ES384' | 'ES512'
139
+
140
+ // ── Expiry ──────────────────────────────────────────────────────────────
141
+ accessTokenExpiry: 15 * 60, // 15 minutes (seconds)
142
+ refreshTokenExpiry: 7 * 24 * 3600, // 7 days (seconds)
143
+
144
+ // ── Claims ──────────────────────────────────────────────────────────────
145
+ issuer: 'my-app',
146
+ checkIssuer: true, // reject tokens with a different iss claim (default: false)
147
+
148
+ // ── User database ───────────────────────────────────────────────────────
149
+ // Replace with a real database query; return null to reject login
150
+ fetchUser: async (username) => {
151
+ return await db.users.findOne({ username });
152
+ },
153
+
154
+ // Extracts the subject identifier from a user record (default: user => user.username)
155
+ username: (user) => user.id,
156
+
157
+ // ── Password validation ─────────────────────────────────────────────────
158
+ // Default uses SHA-256 — replace with bcrypt/argon2 for production
159
+ isPasswordValid: async (user, password) => {
160
+ return await bcrypt.compare(password, user.passwordHash);
161
+ },
162
+
163
+ // ── Custom JWT payload ──────────────────────────────────────────────────
164
+ payload: (user) => ({
165
+ sub: user.id,
166
+ username: user.username,
167
+ roles: user.roles,
168
+ permissions: user.permissions,
169
+ }),
170
+
171
+ // ── Token store ─────────────────────────────────────────────────────────
172
+ // Required to enable refresh tokens at all — omit to disable them entirely
173
+ // (auth.login then omits refreshToken, auth.refresh responds 501).
174
+ // createMapTokenStore() is a simple in-memory default; replace with a
175
+ // Redis adapter for multi-instance deployments.
176
+ refreshTokenStore: redisAdapter,
177
+ });
178
+ ```
179
+
180
+ ---
181
+
182
+ ## Token store interface
183
+
184
+ The store is keyed by JWT ID (`jti`), not by the token string itself — every
185
+ issued refresh token carries a unique `jti` claim, and the store maps that ID
186
+ to a record. Methods may return their result directly or as a `Promise`:
187
+
188
+ ```ts
189
+ interface TokenStore {
190
+ set(jti: string, record: RefreshTokenRecord): void | Promise<void>;
191
+ get(jti: string): RefreshTokenRecord | undefined | Promise<RefreshTokenRecord | undefined>;
192
+ delete(jti: string): void | Promise<void>;
193
+ deleteBySubject?(sub: string): void | Promise<void>; // optional — "log out all sessions"
194
+ }
195
+
196
+ interface RefreshTokenRecord {
197
+ sub: string; // subject the token was issued to
198
+ issuedAt: number; // Unix ms timestamp
199
+ expiresAt: number; // Unix ms timestamp
200
+ }
201
+ ```
202
+
203
+ A built-in in-memory store is available for testing:
204
+
205
+ ```ts
206
+ import { createMapTokenStore } from 'expediate';
207
+
208
+ const auth = createJwtPlugin({
209
+ accessTokenSecret: 'dev-secret',
210
+ refreshTokenStore: createMapTokenStore(),
211
+ });
212
+ ```
213
+
214
+ ### Refresh token internals
215
+
216
+ Refresh tokens are signed JWTs, not opaque strings. Their payload carries:
217
+
218
+ ```json
219
+ { "sub": "alice", "jti": "uuid-v4", "type": "refresh", "iss": "my-app", "iat": 0, "exp": 0 }
220
+ ```
221
+
222
+ The `jti` (a `crypto.randomUUID()` value) is the token store key. Refresh
223
+ tokens are signed with `refreshTokenSecret` (HS*) or `refreshTokenPrivateKey`
224
+ / `refreshTokenPublicKey` (RS*/ES*, falling back to the access-token PEM keys
225
+ when omitted) — a distinct key from the access token where configured, so an
226
+ access token can never be replayed as a refresh token. On every
227
+ `POST /auth/refresh`, the old `jti` is looked up, deleted, then a fresh pair
228
+ is issued (rotation).
229
+
230
+ ---
231
+
232
+ ## Algorithms
233
+
234
+ All algorithms are implemented using Node.js `crypto` — no third-party JWT library:
235
+
236
+ | Algorithm | Key type | Notes |
237
+ |---|---|---|
238
+ | `HS256` / `HS384` / `HS512` | Shared HMAC secret string | Default; `accessTokenSecret` |
239
+ | `RS256` / `RS384` / `RS512` | RSA PEM key pair | `accessTokenPrivateKey` + `accessTokenPublicKey` |
240
+ | `ES256` / `ES384` / `ES512` | ECDSA PEM key pair | DER↔P1363 conversion for standard JWT wire format |
241
+
242
+ Verification uses `crypto.timingSafeEqual` to prevent timing attacks.
243
+
244
+ ---
245
+
246
+ ## Security notes
247
+
248
+ - The default password hashing uses SHA-256, which is **not suitable for production**. Always supply an `isPasswordValid` function that uses bcrypt, argon2, or scrypt.
249
+ - Calling `createJwtPlugin()` with no `accessTokenSecret` uses a placeholder secret and should only be done in tests or demos.
250
+ - Refresh tokens are signed JWTs carrying a `jti` claim, not opaque strings — see [Refresh token internals](#refresh-token-internals). Only the `jti` is stored server-side (in the token store), and it's invalidated on each use.
251
+ - Refresh tokens are entirely opt-in: omit `refreshTokenStore` to disable them — `auth.login` then omits `refreshToken` from its response and `auth.refresh` responds `501 Not Implemented`.
package/docs/logo.svg ADDED
@@ -0,0 +1,12 @@
1
+ <svg viewBox="15.109859154929579 17.64 214.05633802816902 215.88" xmlns="http://www.w3.org/2000/svg" version="1.2" style="max-height: 500px" width="214.05633802816902" height="215.88">
2
+ <style>
3
+ .s0 { fill: #cc5535 }
4
+ .s1 { fill: #000000 }
5
+ </style>
6
+ <g id="Layer 1">
7
+ <path d="m101.62 96.81c4.32 0.2 4.48 9.14 0.08 9.08-3.68-0.05-33.34 0-39.17-0.51-6.21-0.53-6.39-8.5 1.01-8.57 3.98-0.04 35.33-0.12 38.08 0z" class="s0" fill-rule="evenodd" id="Forme 2"/>
8
+ <path d="m108.62 155.81c4.32 0.2 4.48 9.14 0.08 9.08-3.68-0.05-33.34 0-39.17-0.51-6.21-0.53-6.39-8.5 1.01-8.57 3.98-0.04 35.33-0.12 38.08 0z" class="s0" fill-rule="evenodd" id="Forme 2 copy"/>
9
+ <path d="m95.51 125.97c7.09 0.01 5.67 9.39-1.02 9.49-3.68 0.06-61.13-0.57-66.96-1.08-6.21-0.53-6.39-8.5 1.01-8.57 3.98-0.04 59.36 0.15 66.97 0.16z" class="s0" fill-rule="evenodd" id="Forme 2 copy 2"/>
10
+ <path d="m93.66 115.38c4.43 0.06 108.79 0.29 108.79 0.29 0 0 3.82-18.38-22.58-37.88-20.91-15.45-47.18-5.46-53.92-1.06-6.54 4.28-12.49 10.58-15.92 13.03-4.33 3.09-12.62-0.42-10.4-7.19 1.81-5.55 16.67-17.87 27.53-22.38 10.87-4.5 42.59-10.28 68.32 11.9 16.47 14.2 20.24 33.45 21.14 47.51 0.18 2.86-0.38 8.87-6.1 8.92-5.72 0.05-109.37 0.01-109.37 0.01 0 0 0.9 13.13 6.8 21.54 3.9 5.56 15.34 25.02 43.58 24.82 20.03-0.15 30.18-8.42 35.87-13.71 8.28-7.69 15.47 2.14 10.85 7.15-11.11 12.02-29.37 24.43-55.25 19.73-22.41-4.07-34.63-13.89-43.79-27.13-7.5-10.84-10.53-24.82-11.56-35.98-0.65-7.03 1.57-9.63 6.01-9.57z" class="s1" fill-rule="evenodd" id="Forme 1"/>
11
+ </g>
12
+ </svg>
@@ -0,0 +1,264 @@
1
+ # Middleware
2
+
3
+ Expediate ships a suite of production-ready middleware. All factories return standard `(req, res, next)` functions and can be mounted globally or scoped to specific paths.
4
+
5
+ ---
6
+
7
+ ## `compress()`
8
+
9
+ Transparent response compression. Must be mounted **before** any middleware that writes response bodies.
10
+
11
+ Algorithm priority (based on `Accept-Encoding`): Brotli > gzip > deflate.
12
+
13
+ ```ts
14
+ import { compress } from 'expediate';
15
+
16
+ app.use(compress());
17
+ app.use(compress({ threshold: 512, brotliQuality: 6 }));
18
+ ```
19
+
20
+ | Option | Type | Default | Description |
21
+ |---|---|---|---|
22
+ | `threshold` | `number` | `1024` | Minimum response size in bytes before compression is applied |
23
+ | `br` | `boolean` | `true` | Enable Brotli compression |
24
+ | `brotliQuality` | `number` | `4` | Brotli quality level (0–11) |
25
+ | `gzipLevel` | `number` | Node default | gzip/deflate compression level (1–9) |
26
+ | `filter` | `(req, res) => boolean` | — | Custom predicate; return `false` to skip compression for a response |
27
+
28
+ The middleware removes `Content-Length` (size changes after compression) and sets `Content-Encoding` and `Vary: Accept-Encoding`.
29
+
30
+ ---
31
+
32
+ ## `conditionalGet()`
33
+
34
+ Handles `If-None-Match` and `If-Modified-Since` headers (RFC 7232). When the client's cached response is still fresh, sends `304 Not Modified` with no body instead of the full response.
35
+
36
+ ```ts
37
+ import { conditionalGet } from 'expediate';
38
+
39
+ app.get('/users/:id', conditionalGet(), (req, res) => {
40
+ const user = getUser(req.params.id);
41
+ res.etag(user.updatedAt.toISOString());
42
+ res.json(user); // → 304 when the client is up to date
43
+ });
44
+ ```
45
+
46
+ Freshness checks follow RFC 7232 priority: `If-None-Match` (weak comparison, `*` wildcard supported) first, then `If-Modified-Since`. Only GET and HEAD are eligible — other methods pass through unchanged.
47
+
48
+ A `304` response strips `Content-Type`, `Content-Length`, and `Content-Encoding` but retains `ETag`, `Cache-Control`, `Vary`, and `Last-Modified`.
49
+
50
+ ---
51
+
52
+ ## `cacheControl()`
53
+
54
+ Sets `Cache-Control`, `Expires`, and `Vary` response headers.
55
+
56
+ ```ts
57
+ import { cacheControl } from 'expediate';
58
+
59
+ app.use('/api', cacheControl({ noStore: true }));
60
+ app.use('/assets', cacheControl({ maxAge: 31_536_000, immutable: true }));
61
+ ```
62
+
63
+ | Option | Type | Description |
64
+ |---|---|---|
65
+ | `maxAge` | `number` | `max-age=<seconds>`. Also sets `Expires` |
66
+ | `sMaxAge` | `number` | `s-maxage=<seconds>` for CDN/shared caches |
67
+ | `public` | `boolean` | `public` directive |
68
+ | `private` | `boolean` | `private` directive |
69
+ | `noStore` | `boolean` | `no-store` — disables all caching |
70
+ | `noCache` | `boolean` | `no-cache` — requires revalidation |
71
+ | `mustRevalidate` | `boolean` | `must-revalidate` |
72
+ | `immutable` | `boolean` | `immutable` — response body will never change within `max-age` |
73
+ | `vary` | `string \| string[]` | Sets the `Vary` header |
74
+
75
+ ---
76
+
77
+ ## `requestId()`
78
+
79
+ Attaches a unique ID to every request and echoes it in the response header.
80
+
81
+ ```ts
82
+ import { requestId } from 'expediate';
83
+
84
+ app.use(requestId());
85
+
86
+ app.get('/health', (req, res) => {
87
+ res.json({ id: req.id, status: 'ok' });
88
+ });
89
+ ```
90
+
91
+ | Option | Type | Default | Description |
92
+ |---|---|---|---|
93
+ | `header` | `string` | `'x-request-id'` | Header name to read and echo |
94
+ | `allowFromHeader` | `boolean` | `true` | Reuse client-supplied ID. Set `false` to always generate a new one |
95
+ | `generator` | `() => string` | `crypto.randomUUID` | Custom ID generator |
96
+
97
+ The ID is exposed as `req.id` on the augmented request object.
98
+
99
+ ---
100
+
101
+ ## `rateLimit()`
102
+
103
+ In-memory sliding-window rate limiting. State is per-process and lost on restart.
104
+
105
+ ```ts
106
+ import { rateLimit } from 'expediate';
107
+
108
+ // 100 requests per minute per IP (global)
109
+ app.use(rateLimit({ windowMs: 60_000, max: 100 }));
110
+
111
+ // Tighter limit on authentication endpoints
112
+ app.post('/auth/login', rateLimit({ windowMs: 60_000, max: 5 }), loginHandler);
113
+ ```
114
+
115
+ | Option | Type | Default | Description |
116
+ |---|---|---|---|
117
+ | `windowMs` | `number` | **required** | Sliding window duration in milliseconds |
118
+ | `max` | `number` | **required** | Max requests per key within the window |
119
+ | `keyBy` | `(req) => string` | `req.ip` | Function to extract the rate-limit key |
120
+ | `message` | `string` | `'Too Many Requests'` | Response body when limit is exceeded |
121
+ | `statusCode` | `number` | `429` | HTTP status when limit is exceeded |
122
+ | `headers` | `boolean` | `true` | Set `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` |
123
+
124
+ > Not suitable for multi-process deployments without a shared external store.
125
+
126
+ ---
127
+
128
+ ## `csrf()`
129
+
130
+ CSRF protection via the double-submit cookie pattern. Safe methods (GET, HEAD, OPTIONS, TRACE) are exempted. State-mutating methods must supply the token in the `X-CSRF-Token` header or `_csrf` body field.
131
+
132
+ ```ts
133
+ import { csrf } from 'expediate';
134
+
135
+ app.use(csrf());
136
+
137
+ // Expose the token to client-side JavaScript
138
+ app.get('/form', (req, res) =>
139
+ res.send(`<input type="hidden" name="_csrf" value="${req.csrfToken!()}">`));
140
+ // POST /form — token validated automatically
141
+ ```
142
+
143
+ | Option | Type | Default | Description |
144
+ |---|---|---|---|
145
+ | `cookieName` | `string` | `'_csrf'` | Cookie storing the token |
146
+ | `headerName` | `string` | `'x-csrf-token'` | Request header carrying the token |
147
+ | `fieldName` | `string` | `'_csrf'` | Parsed body field (fallback when header is absent) |
148
+ | `secure` | `boolean` | `false` | Mark the CSRF cookie as `Secure` |
149
+ | `sameSite` | `'Strict'\|'Lax'\|'None'` | `'Strict'` | `SameSite` attribute of the cookie |
150
+
151
+ The cookie is **not** `HttpOnly` — client JavaScript must be able to read it to include it in requests.
152
+
153
+ ---
154
+
155
+ ## `securityHeaders()`
156
+
157
+ Sets a hardened baseline of HTTP security response headers.
158
+
159
+ ```ts
160
+ import { securityHeaders } from 'expediate';
161
+
162
+ app.use(securityHeaders());
163
+
164
+ // Disable HSTS for plain-HTTP development servers
165
+ app.use(securityHeaders({ hsts: false }));
166
+ ```
167
+
168
+ | Header | Default value |
169
+ |---|---|
170
+ | `Strict-Transport-Security` | `max-age=15552000; includeSubDomains` |
171
+ | `X-Frame-Options` | `SAMEORIGIN` |
172
+ | `X-Content-Type-Options` | `nosniff` |
173
+ | `Referrer-Policy` | `strict-origin-when-cross-origin` |
174
+ | `Permissions-Policy` | `geolocation=(), microphone=(), camera=()` |
175
+ | `X-XSS-Protection` | `0` |
176
+
177
+ Pass `false` to disable a header, or a string to override it:
178
+
179
+ ```ts
180
+ app.use(securityHeaders({
181
+ hsts: false,
182
+ frameOptions: 'DENY',
183
+ permissionsPolicy: "geolocation=(self 'https://maps.example.com')",
184
+ }));
185
+ ```
186
+
187
+ ---
188
+
189
+ ## `cors()`
190
+
191
+ Adds Cross-Origin Resource Sharing headers. Headers are only set when the request includes an `Origin` header (browser-only). `OPTIONS` preflight requests are handled and terminated — `next()` is not called for preflights.
192
+
193
+ ```ts
194
+ import { cors } from 'expediate';
195
+
196
+ app.use(cors({ origin: 'https://example.com' }));
197
+
198
+ app.use(cors({
199
+ origin: ['https://app.example.com', 'https://admin.example.com'],
200
+ allowCredentials: true,
201
+ maxAge: 86400,
202
+ }));
203
+ ```
204
+
205
+ When `origin` is an array, the middleware compares the request `Origin` against the list and echoes back only the matching origin (or omits the header entirely if no match) — a plain array can't be passed straight to `Access-Control-Allow-Origin` since the CORS spec only allows a single value. `Vary` is **not** added automatically; if a cache sits in front of an array-origin response, set `vary: 'Origin'` yourself so the cache keys on it.
206
+
207
+ | Option | Type | Default | Description |
208
+ |---|---|---|---|
209
+ | `origin` | `string \| string[]` | `'*'` | Allowed origin(s). Array triggers request-origin matching |
210
+ | `allowHeaders` | `string \| string[]` | `'Accept, Content-Type, Authorization'` | `Access-Control-Allow-Headers` value |
211
+ | `allowMethods` | `string \| string[]` | `'GET,HEAD,PUT,PATCH,POST,DELETE'` | `Access-Control-Allow-Methods` value |
212
+ | `allowCredentials` | `boolean` | — | Set `Access-Control-Allow-Credentials: true` |
213
+ | `maxAge` | `number` | — | Preflight cache lifetime in seconds |
214
+ | `vary` | `string \| string[]` | — | Additional `Vary` header value |
215
+ | `optionsStatus` | `number` | `204` | Status code for OPTIONS preflight responses |
216
+ | `preflight` | `(req) => boolean` | — | Custom guard; return `false` to reject (OPTIONS → 403, others → 400) |
217
+
218
+ ---
219
+
220
+ ## `logger()`
221
+
222
+ Logs one line per completed request. Integrated with the request lifecycle — the timer starts when the request arrives and fires when the response finishes.
223
+
224
+ ```ts
225
+ import { logger } from 'expediate';
226
+
227
+ app.use(logger({
228
+ user: (req) => (req as any).user?.username ?? '-',
229
+ locale: 'en-US',
230
+ logger: (msg) => process.stderr.write(msg + '\n'),
231
+ }));
232
+ ```
233
+
234
+ Default output format (ANSI-coloured by status class):
235
+
236
+ ```
237
+ 21 Mar, 14:32 200 GET /api/users 127.0.0.1 alice 4 ms (1234)
238
+ ```
239
+
240
+ With `json: true`, the logger calls the `logger` function with a structured object instead of a string:
241
+
242
+ ```ts
243
+ {
244
+ timestamp: '2024-03-21T14:32:00.000Z',
245
+ status: 200,
246
+ method: 'GET',
247
+ path: '/api/users',
248
+ ip: '127.0.0.1',
249
+ user: 'alice',
250
+ elapsed: 4,
251
+ host: 'localhost:3000',
252
+ length: 256,
253
+ }
254
+ ```
255
+
256
+ | Option | Type | Default | Description |
257
+ |---|---|---|---|
258
+ | `track` | `boolean` | `false` | Emit a `LOST` warning for requests that never complete |
259
+ | `trackTimeout` | `number` | `30000` | Milliseconds before emitting the LOST warning |
260
+ | `user` | `(req) => string` | `() => '-'` | Extract a user identity from the request |
261
+ | `locale` | `string` | `'en-GB'` | BCP 47 locale for the timestamp |
262
+ | `dateFormat` | `Intl.DateTimeFormatOptions` | short date+time | Timestamp format options |
263
+ | `json` | `boolean` | `false` | Log structured objects instead of formatted strings |
264
+ | `logger` | `(msg: string \| object) => void` | `console.log` | Custom logging sink |