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.
- package/CHANGELOG.md +138 -0
- package/CONTRIBUTING.md +150 -0
- package/LICENSE +16 -16
- package/README.md +330 -444
- package/dist/apis.d.ts +504 -27
- package/dist/apis.d.ts.map +1 -1
- package/dist/apis.js +618 -107
- package/dist/apis.js.map +1 -1
- package/dist/cjs/index.js +4066 -0
- package/dist/cjs/package.json +1 -0
- package/dist/git.d.ts +72 -9
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +129 -74
- package/dist/git.js.map +1 -1
- package/dist/http-objects.d.ts +26 -0
- package/dist/http-objects.d.ts.map +1 -0
- package/dist/http-objects.js +588 -0
- package/dist/http-objects.js.map +1 -0
- package/dist/index.d.ts +18 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -24
- package/dist/index.js.map +1 -1
- package/dist/jwt-auth.d.ts +158 -57
- package/dist/jwt-auth.d.ts.map +1 -1
- package/dist/jwt-auth.js +447 -207
- package/dist/jwt-auth.js.map +1 -1
- package/dist/middleware.d.ts +476 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +647 -0
- package/dist/middleware.js.map +1 -0
- package/dist/mimetypes.json +882 -1
- package/dist/misc.d.ts +268 -25
- package/dist/misc.d.ts.map +1 -1
- package/dist/misc.js +449 -168
- package/dist/misc.js.map +1 -1
- package/dist/openapi.d.ts +433 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +624 -0
- package/dist/openapi.js.map +1 -0
- package/dist/router-types.d.ts +760 -0
- package/dist/router-types.d.ts.map +1 -0
- package/dist/router-types.js +23 -0
- package/dist/router-types.js.map +1 -0
- package/dist/router.d.ts +37 -201
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +502 -244
- package/dist/router.js.map +1 -1
- package/dist/static.d.ts +3 -3
- package/dist/static.d.ts.map +1 -1
- package/dist/static.js +164 -105
- package/dist/static.js.map +1 -1
- package/docs/THREAT_MODEL.md +52 -0
- package/docs/api-builder-v2-design.md +644 -0
- package/docs/api-builder-v3-design.md +397 -0
- package/docs/api-builder.md +454 -0
- package/docs/benchmark.md +27 -0
- package/docs/body-parsing.md +223 -0
- package/docs/errors.md +359 -0
- package/docs/expediate.png +0 -0
- package/docs/git.md +139 -0
- package/docs/jwt-auth.md +251 -0
- package/docs/logo.svg +12 -0
- package/docs/middleware.md +264 -0
- package/docs/openapi.md +180 -0
- package/docs/router.md +356 -0
- package/docs/static.md +128 -0
- package/docs/wiki.json +123 -0
- package/package.json +47 -8
- 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
|
+
```
|
package/docs/jwt-auth.md
ADDED
|
@@ -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 |
|