@sourceregistry/sveltekit-enhance 1.1.2 → 1.3.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/README.md +353 -69
- package/dist/helpers/CSRF.d.ts +14 -0
- package/dist/helpers/CSRF.js +38 -0
- package/dist/helpers/index.d.ts +3 -0
- package/dist/helpers/index.js +3 -0
- package/dist/helpers/logger.d.ts +6 -0
- package/dist/helpers/logger.js +1 -0
- package/dist/helpers/request-monitor.d.ts +21 -0
- package/dist/helpers/request-monitor.js +72 -0
- package/dist/index.d.ts +1 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -1,112 +1,396 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
1
3
|
# @sourceregistry/sveltekit-enhance
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
[](https://www.npmjs.com/package/@sourceregistry/sveltekit-enhance)
|
|
8
|
+
[](https://www.npmjs.com/package/@sourceregistry/sveltekit-enhance)
|
|
9
|
+
[](./LICENSE)
|
|
10
|
+
[](https://kit.svelte.dev)
|
|
11
|
+
[](https://github.com/SourceRegistry/sveltekit-enhance/issues)
|
|
12
|
+
|
|
13
|
+
Wrap actions, loads, methods, and hooks with composable enhancers. Stack auth guards, feature flags, request tracing, and form parsing without touching SvelteKit's internals.
|
|
6
14
|
|
|
7
|
-
|
|
8
|
-
Use `@sourceregistry/sveltekit-enhance` to build cleaner actions, loads, methods, and hooks with reusable guards for authentication, feature flags, request correlation, and form processing.
|
|
15
|
+
[Docs](https://sourceregistry.github.io/sveltekit-enhance/) · [npm](https://www.npmjs.com/package/@sourceregistry/sveltekit-enhance) · [Issues](https://github.com/SourceRegistry/sveltekit-enhance/issues)
|
|
9
16
|
|
|
10
|
-
|
|
17
|
+
</div>
|
|
11
18
|
|
|
12
|
-
|
|
13
|
-
- Reduce repetitive request parsing and validation code.
|
|
14
|
-
- Keep middleware-like behavior explicit and composable.
|
|
15
|
-
- Improve observability with correlation IDs on every response.
|
|
19
|
+
---
|
|
16
20
|
|
|
17
21
|
## Installation
|
|
18
22
|
|
|
19
|
-
```
|
|
23
|
+
```sh
|
|
20
24
|
npm install @sourceregistry/sveltekit-enhance
|
|
21
25
|
```
|
|
22
26
|
|
|
23
|
-
|
|
27
|
+
**Peer dependency:** `@sveltejs/kit ^2.58.0`
|
|
24
28
|
|
|
25
|
-
|
|
29
|
+
---
|
|
26
30
|
|
|
27
|
-
|
|
28
|
-
- `enhance.load(...)`
|
|
29
|
-
- `enhance.method(...)`
|
|
30
|
-
- `enhance.handle(...)`
|
|
31
|
+
## Overview
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
```ts
|
|
34
|
+
import { enhance, Auth, RequestCorrelation, RequestMonitor, Form } from '@sourceregistry/sveltekit-enhance';
|
|
33
35
|
|
|
34
|
-
|
|
36
|
+
// hooks.server.ts
|
|
37
|
+
export const handle = enhance.handle(
|
|
38
|
+
async ({ event, resolve }) => resolve(event),
|
|
39
|
+
RequestCorrelation.attach,
|
|
40
|
+
RequestMonitor.trace({ logger: myLogger, record: metrics.record }),
|
|
41
|
+
);
|
|
35
42
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
43
|
+
// +server.ts
|
|
44
|
+
export const POST = enhance.method(
|
|
45
|
+
async (event) => new Response(JSON.stringify(event.context)),
|
|
46
|
+
Auth.Bearer,
|
|
47
|
+
FeatureFlag.all('PUBLIC_API_ENABLED'),
|
|
48
|
+
);
|
|
40
49
|
|
|
50
|
+
// +page.server.ts
|
|
41
51
|
export const actions = {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
default: enhance.action(
|
|
53
|
+
async (event) => {
|
|
54
|
+
const name = event.context.form.string$('name');
|
|
55
|
+
return success({ name });
|
|
56
|
+
},
|
|
57
|
+
Form.schema(myValidator),
|
|
58
|
+
),
|
|
59
|
+
};
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Core API
|
|
65
|
+
|
|
66
|
+
Import from `@sourceregistry/sveltekit-enhance`.
|
|
67
|
+
|
|
68
|
+
### `enhance.handle`
|
|
69
|
+
|
|
70
|
+
Wraps SvelteKit's `handle` hook. Enhancers run left-to-right before the handler; their return values are merged into `context`.
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { enhance } from '@sourceregistry/sveltekit-enhance';
|
|
74
|
+
|
|
75
|
+
// src/hooks.server.ts
|
|
76
|
+
export const handle = enhance.handle(
|
|
77
|
+
async ({ event, resolve, context }) => resolve(event),
|
|
78
|
+
enhancerA,
|
|
79
|
+
enhancerB,
|
|
80
|
+
);
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### `enhance.load`
|
|
84
|
+
|
|
85
|
+
Wraps server `load` functions.
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
// +page.server.ts
|
|
89
|
+
export const load = enhance.load(
|
|
90
|
+
async (event) => ({ user: event.context.user }),
|
|
53
91
|
Auth.Bearer,
|
|
54
|
-
|
|
55
|
-
|
|
92
|
+
);
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### `enhance.action`
|
|
96
|
+
|
|
97
|
+
Wraps form actions.
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
// +page.server.ts
|
|
101
|
+
export const actions = {
|
|
102
|
+
submit: enhance.action(
|
|
103
|
+
async (event) => success(event.context),
|
|
104
|
+
Auth.Bearer,
|
|
105
|
+
Form.schema(myValidator),
|
|
106
|
+
),
|
|
56
107
|
};
|
|
57
108
|
```
|
|
58
109
|
|
|
59
|
-
|
|
110
|
+
### `enhance.method`
|
|
60
111
|
|
|
61
|
-
|
|
62
|
-
Validates `Authorization: Bearer <token>` and returns `{ token }`.
|
|
112
|
+
Wraps `+server.ts` endpoint handlers.
|
|
63
113
|
|
|
64
|
-
|
|
65
|
-
|
|
114
|
+
```ts
|
|
115
|
+
// +server.ts
|
|
116
|
+
export const GET = enhance.method(
|
|
117
|
+
async (event) => new Response(JSON.stringify(event.context)),
|
|
118
|
+
Auth.Bearer,
|
|
119
|
+
);
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Utilities
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { fail, error, success, not_good } from '@sourceregistry/sveltekit-enhance';
|
|
126
|
+
|
|
127
|
+
fail(400, { message: 'bad input' }); // throws ActionFailure — use inside actions
|
|
128
|
+
error(404, { message: 'not found' }); // throws HttpError
|
|
129
|
+
success({ id: 1 }); // typed identity helper
|
|
130
|
+
not_good(input, 403); // delegates to fail or error based on callType
|
|
131
|
+
```
|
|
66
132
|
|
|
67
|
-
|
|
68
|
-
Reuses incoming `x-correlation-id` / `x-request-id` or generates one, stores it in `locals`, and appends it to response headers.
|
|
133
|
+
---
|
|
69
134
|
|
|
70
|
-
|
|
71
|
-
Ignores the Chrome DevTools app-specific probe route with a `204` response.
|
|
135
|
+
## Helpers
|
|
72
136
|
|
|
73
|
-
-
|
|
74
|
-
Typed helpers for strings, numbers, booleans, dates, files, arrays, JSON, selector helpers, and schema-style validation workflows.
|
|
137
|
+
All helpers are available from `@sourceregistry/sveltekit-enhance` or `@sourceregistry/sveltekit-enhance/helpers`.
|
|
75
138
|
|
|
76
|
-
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
### `CSRF`
|
|
142
|
+
|
|
143
|
+
Blocks cross-site form submissions on mutating methods (`POST`, `PUT`, `PATCH`, `DELETE`) with form content types. Checks the `Origin` header against the request origin. Absent `Origin` (server-side fetch, curl) is allowed through. Returns `403` — JSON body when `Accept: application/json`, SvelteKit `error()` otherwise.
|
|
77
144
|
|
|
78
145
|
```ts
|
|
79
|
-
|
|
80
|
-
import {enhance, RequestCorrelation} from '@sourceregistry/sveltekit-enhance';
|
|
146
|
+
import { CSRF, CSRFChecker } from '@sourceregistry/sveltekit-enhance';
|
|
81
147
|
|
|
82
148
|
export const handle = enhance.handle(
|
|
83
|
-
|
|
84
|
-
|
|
149
|
+
myHandler,
|
|
150
|
+
CSRF.inspect(
|
|
151
|
+
CSRFChecker.list('/api/webhooks/stripe'), // bypass paths
|
|
152
|
+
myLogger, // optional, defaults to console
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Built-in bypass checkers:
|
|
158
|
+
|
|
159
|
+
| Checker | Description |
|
|
160
|
+
|---------|-------------|
|
|
161
|
+
| `CSRFChecker.list(...paths)` | Exact pathname match |
|
|
162
|
+
| `CSRFChecker.regex(...patterns)` | RegExp match against pathname |
|
|
163
|
+
|
|
164
|
+
Custom checker — any `(input: EnhanceInput) => MaybePromise<boolean>`:
|
|
165
|
+
```ts
|
|
166
|
+
// true = bypass CSRF check
|
|
167
|
+
CSRF.inspect((input) => input.url.pathname.startsWith('/api/public'))
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Returns `{ csrf_valid: true }` on pass. Locals set: none.
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
### `Auth`
|
|
175
|
+
|
|
176
|
+
Extracts and validates `Authorization: Bearer <token>` headers.
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
import { Auth } from '@sourceregistry/sveltekit-enhance';
|
|
180
|
+
|
|
181
|
+
export const GET = enhance.method(
|
|
182
|
+
async (event) => new Response(event.context.token),
|
|
183
|
+
Auth.Bearer,
|
|
85
184
|
);
|
|
86
185
|
```
|
|
87
186
|
|
|
88
|
-
|
|
187
|
+
Returns `{ token: string }`. Throws `401` if the header is missing or malformed.
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
### `Devtools`
|
|
192
|
+
|
|
193
|
+
Silences Chrome DevTools probe requests (`/.well-known/appspecific/com.chrome.devtools.json`) with a `204 No Content`. Logs in `dev` mode.
|
|
89
194
|
|
|
90
195
|
```ts
|
|
91
|
-
import {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
load,
|
|
95
|
-
method,
|
|
96
|
-
handle,
|
|
97
|
-
Auth,
|
|
98
|
-
FeatureFlag,
|
|
99
|
-
RequestCorrelation,
|
|
100
|
-
Devtools,
|
|
101
|
-
Form
|
|
102
|
-
} from '@sourceregistry/sveltekit-enhance';
|
|
196
|
+
import { Devtools } from '@sourceregistry/sveltekit-enhance';
|
|
197
|
+
|
|
198
|
+
export const handle = enhance.handle(myHandler, Devtools.ignore);
|
|
103
199
|
```
|
|
104
200
|
|
|
105
|
-
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
### `FeatureFlag`
|
|
204
|
+
|
|
205
|
+
Guards routes behind SvelteKit public env vars (`$env/dynamic/public`). Always passes in `dev` mode.
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
import { FeatureFlag } from '@sourceregistry/sveltekit-enhance';
|
|
209
|
+
|
|
210
|
+
// All listed flags must be enabled
|
|
211
|
+
FeatureFlag.all('PUBLIC_FEATURE_A', 'PUBLIC_FEATURE_B')
|
|
212
|
+
|
|
213
|
+
// At least one flag must be enabled
|
|
214
|
+
FeatureFlag.oneOf('PUBLIC_FEATURE_A', 'PUBLIC_FEATURE_B')
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Truthy values: `true`, `TRUE`, `on`, `ON`, `1`. Returns `{ flags }` or throws `503 Feature not enabled`.
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
### `RequestCorrelation`
|
|
222
|
+
|
|
223
|
+
Propagates a correlation ID across the request/response cycle.
|
|
224
|
+
|
|
225
|
+
- Reads `x-correlation-id` or `x-request-id` from incoming headers
|
|
226
|
+
- Validates: max 128 chars, pattern `[A-Za-z0-9._:-]+`
|
|
227
|
+
- Generates a UUID v4 if absent
|
|
228
|
+
- Echoes the ID back via `x-correlation-id` response header
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
import { RequestCorrelation } from '@sourceregistry/sveltekit-enhance';
|
|
232
|
+
|
|
233
|
+
export const handle = enhance.handle(myHandler, RequestCorrelation.attach);
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
| Local | Type | Description |
|
|
237
|
+
|-------|------|-------------|
|
|
238
|
+
| `correlation_id` | `string` | Resolved correlation ID |
|
|
239
|
+
| `request_started_at` | `number` | `Date.now()` at attach time |
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
### `RequestMonitor`
|
|
244
|
+
|
|
245
|
+
Structured HTTP request logging and optional metrics collection. Instruments the full lifecycle: start, completion (log level by status), and unhandled errors — all with elapsed duration.
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
import { RequestMonitor } from '@sourceregistry/sveltekit-enhance';
|
|
249
|
+
|
|
250
|
+
export const handle = enhance.handle(
|
|
251
|
+
myHandler,
|
|
252
|
+
RequestCorrelation.attach,
|
|
253
|
+
RequestMonitor.trace({
|
|
254
|
+
logger: myLogger, // optional, defaults to console
|
|
255
|
+
record: metrics.record, // optional
|
|
256
|
+
}),
|
|
257
|
+
);
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
#### `TraceOptions`
|
|
261
|
+
|
|
262
|
+
| Option | Type | Default | Description |
|
|
263
|
+
|--------|------|---------|-------------|
|
|
264
|
+
| `logger` | `TraceLogger` | `console` | Must implement `debug`, `info`, `warn`, `error` |
|
|
265
|
+
| `record` | `(entry: RecordTraceMetricEntry) => any` | — | Called after every request |
|
|
266
|
+
|
|
267
|
+
#### Log events
|
|
268
|
+
|
|
269
|
+
| Event | Level | Condition |
|
|
270
|
+
|-------|-------|-----------|
|
|
271
|
+
| `http.request.started` | `debug` | Before resolve |
|
|
272
|
+
| `http.request.completed` | `info` | `status < 400` |
|
|
273
|
+
| `http.request.completed` | `warn` | `status 4xx` |
|
|
274
|
+
| `http.request.completed` | `error` | `status 5xx` |
|
|
275
|
+
| `http.request.failed` | `error` | Unhandled throw |
|
|
276
|
+
|
|
277
|
+
#### Types
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
type RecordTraceMetricEntry = { method: string; path: string; status: number; durationMs: number }
|
|
281
|
+
type TraceLogger = { debug(...args: any[]): any; info(...args: any[]): any; warn(...args: any[]): any; error(...args: any[]): any }
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Locals set: `trace: { id: string; started_at: bigint }`.
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
### `Form`
|
|
289
|
+
|
|
290
|
+
Typed, ergonomic FormData extraction. Works standalone or as an enhancer.
|
|
291
|
+
|
|
292
|
+
#### As an enhancer — `Form.schema`
|
|
293
|
+
|
|
294
|
+
Validates and deserializes form data via a `Validator<T>` before the handler runs.
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
import { Form, enhance, success } from '@sourceregistry/sveltekit-enhance';
|
|
298
|
+
|
|
299
|
+
export const actions = {
|
|
300
|
+
default: enhance.action(
|
|
301
|
+
async (event) => success(event.context.form.result),
|
|
302
|
+
Form.schema(myValidator),
|
|
303
|
+
),
|
|
304
|
+
};
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
#### Standalone — `Form.handle`
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
import { Form } from '@sourceregistry/sveltekit-enhance';
|
|
311
|
+
|
|
312
|
+
await Form.handle(request, ({ form }) => {
|
|
313
|
+
const name = form.string$('name');
|
|
314
|
+
const age = form.number('age');
|
|
315
|
+
return { name, age };
|
|
316
|
+
});
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
#### Field extractors
|
|
320
|
+
|
|
321
|
+
Optional variants return `undefined` when the field is absent. Required variants (`$` suffix) throw `fail(400)`.
|
|
322
|
+
|
|
323
|
+
| Method | Returns | Notes |
|
|
324
|
+
|--------|---------|-------|
|
|
325
|
+
| `string(name)` / `string$(name)` | `string \| null \| undefined` | |
|
|
326
|
+
| `pattern$(name, pattern)` | `string` | `RegExp` or pattern string |
|
|
327
|
+
| `number(name)` / `number$(name)` | `number \| undefined` | |
|
|
328
|
+
| `boolean(name)` / `boolean$(name)` | `boolean \| undefined` | Accepts `true/false`, `1/0`, `on/off` |
|
|
329
|
+
| `date(name, parser?)` / `date$(name, parser)` | `Date \| undefined` | Custom parser supported |
|
|
330
|
+
| `json<T>(name, transformer?)` / `json$(name)` | `T \| undefined` | Optional transform fn |
|
|
331
|
+
| `jsond(options)` | `any` | All FormData → nested object via dot-notation keys |
|
|
332
|
+
| `file(name)` / `file$(name)` | `File \| null \| undefined` | |
|
|
333
|
+
| `files(name)` | `File[]` | Non-empty, named files only |
|
|
334
|
+
| `fileRecord(prefix, removePrefix?)` | `Record<string, File[]>` | Groups files by key prefix |
|
|
335
|
+
| `array<T>(name, mapper?)` / `array$(name)` | `T[] \| undefined` | |
|
|
336
|
+
| `enum(name, Enum)` / `enum$(name, Enum)` | `keyof E \| undefined` | |
|
|
337
|
+
| `record(options?)` | `Record<string, any>` | All entries; optional `filter` / `transformer` |
|
|
338
|
+
| `validate(schema, options?)` | `T` | Runs `Validator<T>`, throws `fail(400)` on failure |
|
|
339
|
+
|
|
340
|
+
#### Conditional helpers
|
|
341
|
+
|
|
342
|
+
```ts
|
|
343
|
+
form.onlyIf(condition, trueVal, falseVal?)
|
|
344
|
+
form.onlyIfPresent(key, (entry) => ..., fallback?)
|
|
345
|
+
form.onlyIfArrayPresent(key, (entries) => ..., fallback)
|
|
346
|
+
form.selector({ fieldName: (entry, key) => ..., $default?, $error? })
|
|
347
|
+
form.selector$({ ... }) // throws fail(400) if no case matches
|
|
348
|
+
form.basedOn(val, processor)
|
|
349
|
+
form.process(name, parser, processor)
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
#### Standalone functions
|
|
353
|
+
|
|
354
|
+
All `FormContext` methods are also exported as standalone functions taking `FormData` as the first argument, plus:
|
|
355
|
+
|
|
356
|
+
```ts
|
|
357
|
+
arrayString(formdata, name, delimiter, mapper?)
|
|
358
|
+
hasOneOf(formdata, names)
|
|
359
|
+
reviver(key, value) // JSON.parse reviver — coerces strings to typed primitives
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
---
|
|
363
|
+
|
|
364
|
+
## Type Reference
|
|
365
|
+
|
|
366
|
+
```ts
|
|
367
|
+
// Core
|
|
368
|
+
EnhanceInput<CallType> // event passed to all enhancers
|
|
369
|
+
EnhanceFunction<CallType> // (event: EnhanceInput) => MaybePromise<object | Response>
|
|
370
|
+
EnhanceHandle // final handle fn — ({ event, resolve, context }) => MaybePromise<Response>
|
|
371
|
+
EnhanceLoad // final load fn
|
|
372
|
+
EnhanceAction // final action fn
|
|
373
|
+
EnhanceMethod // final method fn
|
|
374
|
+
MaybePromise<T> // T | Promise<T>
|
|
375
|
+
|
|
376
|
+
// Form
|
|
377
|
+
Validator<T> // (value: unknown, path?: ValidationPath) => ValidationResult<T>
|
|
378
|
+
ValidationResult<T> // { success: true; data: T } | { success: false; errors: ValidationIssue[] }
|
|
379
|
+
ValidationIssue // { path: string; message: string; code?: string }
|
|
380
|
+
FormContext // fluent API returned by Form.enhance / Form.handle
|
|
381
|
+
InferValidator<V> // infers T from Validator<T>
|
|
382
|
+
|
|
383
|
+
// Helpers
|
|
384
|
+
TraceLogger // { debug, info, warn, error }
|
|
385
|
+
TraceOptions // { logger?: TraceLogger; record?: (entry) => any }
|
|
386
|
+
RecordTraceMetricEntry // { method: string; path: string; status: number; durationMs: number }
|
|
387
|
+
RequestTraceLocals // { trace?: { id: string; started_at: bigint } }
|
|
388
|
+
RequestCorrelationLocals // { correlation_id?: string; request_started_at?: number }
|
|
389
|
+
CSRFChecker // { regex(...patterns): checker; list(...paths): checker }
|
|
390
|
+
```
|
|
106
391
|
|
|
107
|
-
|
|
108
|
-
- Node.js runtime (matching your SvelteKit adapter/runtime support)
|
|
392
|
+
---
|
|
109
393
|
|
|
110
394
|
## License
|
|
111
395
|
|
|
112
|
-
Apache-2.0
|
|
396
|
+
[Apache-2.0](./LICENSE) © [A.P.A. Slaa](https://github.com/SourceRegistry)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type EnhanceFunction, type EnhanceInput } from "../index.js";
|
|
2
|
+
import type { MaybePromise } from "../index.js";
|
|
3
|
+
import type { Logger } from "./logger.js";
|
|
4
|
+
export declare const CSRF: {
|
|
5
|
+
inspect: (checker: (input: EnhanceInput) => MaybePromise<boolean>, logger?: Logger) => EnhanceFunction<"handle">;
|
|
6
|
+
};
|
|
7
|
+
export declare const CSRFChecker: {
|
|
8
|
+
regex: (...oneOf: RegExp[]) => (input: {
|
|
9
|
+
url: URL;
|
|
10
|
+
}) => boolean;
|
|
11
|
+
list: (...oneOf: string[]) => (input: {
|
|
12
|
+
url: URL;
|
|
13
|
+
}) => boolean;
|
|
14
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { json } from '@sveltejs/kit';
|
|
2
|
+
import { error } from "../index.js";
|
|
3
|
+
function isContentType(request, ...types) {
|
|
4
|
+
const type = request.headers.get('content-type')?.split(';', 1)[0].trim() ?? '';
|
|
5
|
+
return types.includes(type.toLowerCase());
|
|
6
|
+
}
|
|
7
|
+
function isFormContentType(request) {
|
|
8
|
+
return isContentType(request, 'application/x-www-form-urlencoded', 'multipart/form-data', 'text/plain');
|
|
9
|
+
}
|
|
10
|
+
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
11
|
+
const SAFE_LOG_HEADERS = new Set(['content-type', 'origin', 'referer', 'user-agent', 'accept', 'host']);
|
|
12
|
+
export const CSRF = {
|
|
13
|
+
inspect: (checker, logger = console) => async (input) => {
|
|
14
|
+
const { request, url } = input;
|
|
15
|
+
const origin = request.headers.get('origin');
|
|
16
|
+
const forbidden = isFormContentType(request) &&
|
|
17
|
+
MUTATING_METHODS.has(request.method) &&
|
|
18
|
+
origin !== null &&
|
|
19
|
+
origin !== url.origin &&
|
|
20
|
+
!(await checker(input));
|
|
21
|
+
if (forbidden) {
|
|
22
|
+
const message = `Cross-site ${request.method} form submissions are forbidden`;
|
|
23
|
+
logger.warn('CSRF Violation detected', {
|
|
24
|
+
url: url.toString(),
|
|
25
|
+
headers: Object.fromEntries([...request.headers.entries()].filter(([k]) => SAFE_LOG_HEADERS.has(k.toLowerCase())))
|
|
26
|
+
});
|
|
27
|
+
if (request.headers.get('accept') === 'application/json') {
|
|
28
|
+
return json({ message }, { status: 403 });
|
|
29
|
+
}
|
|
30
|
+
return error(403, { message });
|
|
31
|
+
}
|
|
32
|
+
return { csrf_valid: true };
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
export const CSRFChecker = {
|
|
36
|
+
regex: (...oneOf) => (input) => oneOf.some((exp) => exp[Symbol.match](input.url.pathname)),
|
|
37
|
+
list: (...oneOf) => (input) => oneOf.includes(input.url.pathname)
|
|
38
|
+
};
|
package/dist/helpers/index.d.ts
CHANGED
package/dist/helpers/index.js
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { EnhanceFunction } from "../index.js";
|
|
2
|
+
import type { Logger } from "./logger.js";
|
|
3
|
+
export type RequestTraceLocals = {
|
|
4
|
+
trace?: {
|
|
5
|
+
id: string;
|
|
6
|
+
started_at: bigint;
|
|
7
|
+
};
|
|
8
|
+
};
|
|
9
|
+
export type RecordTraceMetricEntry = {
|
|
10
|
+
method: string;
|
|
11
|
+
path: string;
|
|
12
|
+
status: number;
|
|
13
|
+
durationMs: number;
|
|
14
|
+
};
|
|
15
|
+
export type TraceOptions = {
|
|
16
|
+
logger?: Logger;
|
|
17
|
+
record?: (entry: RecordTraceMetricEntry) => any;
|
|
18
|
+
};
|
|
19
|
+
export declare const RequestMonitor: {
|
|
20
|
+
trace: (options?: TraceOptions) => EnhanceFunction<"handle">;
|
|
21
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const durationMs = (startedAt) => Number(process.hrtime.bigint() - startedAt) / 1_000_000;
|
|
2
|
+
const resolveRoute = (event) => event.route.id ?? event.url.pathname;
|
|
3
|
+
export const RequestMonitor = {
|
|
4
|
+
trace: (options = {}) => {
|
|
5
|
+
const { logger = console, record, } = options;
|
|
6
|
+
return async (event) => {
|
|
7
|
+
const locals = event.locals;
|
|
8
|
+
const requestId = locals.requestId ?? "unknown";
|
|
9
|
+
const route = resolveRoute(event);
|
|
10
|
+
locals.trace = {
|
|
11
|
+
id: requestId,
|
|
12
|
+
started_at: process.hrtime.bigint()
|
|
13
|
+
};
|
|
14
|
+
logger.debug("http.request.started", {
|
|
15
|
+
request_id: requestId,
|
|
16
|
+
method: event.request.method,
|
|
17
|
+
route,
|
|
18
|
+
client_ip: event?.getClientAddress?.()
|
|
19
|
+
});
|
|
20
|
+
try {
|
|
21
|
+
const response = await event.resolve(event.event);
|
|
22
|
+
if (!response)
|
|
23
|
+
return response;
|
|
24
|
+
const elapsedMs = Number(durationMs(locals.trace.started_at).toFixed(2));
|
|
25
|
+
record?.({
|
|
26
|
+
method: event.request.method,
|
|
27
|
+
path: route,
|
|
28
|
+
status: response.status,
|
|
29
|
+
durationMs: elapsedMs,
|
|
30
|
+
});
|
|
31
|
+
const context = {
|
|
32
|
+
request_id: requestId,
|
|
33
|
+
method: event.request.method,
|
|
34
|
+
route,
|
|
35
|
+
status: response.status,
|
|
36
|
+
duration_ms: elapsedMs,
|
|
37
|
+
client_ip: event?.getClientAddress?.()
|
|
38
|
+
};
|
|
39
|
+
if (response.status >= 500) {
|
|
40
|
+
logger.error("http.request.completed", context);
|
|
41
|
+
return response;
|
|
42
|
+
}
|
|
43
|
+
if (response.status >= 400) {
|
|
44
|
+
logger.warn("http.request.completed", context);
|
|
45
|
+
return response;
|
|
46
|
+
}
|
|
47
|
+
logger.info("http.request.completed", context);
|
|
48
|
+
return response;
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
const elapsedMs = Number(durationMs(locals.trace.started_at).toFixed(2));
|
|
52
|
+
record?.({
|
|
53
|
+
method: event.request.method,
|
|
54
|
+
path: route,
|
|
55
|
+
status: 500,
|
|
56
|
+
durationMs: elapsedMs,
|
|
57
|
+
});
|
|
58
|
+
logger.error("http.request.failed", {
|
|
59
|
+
request_id: requestId,
|
|
60
|
+
method: event.request.method,
|
|
61
|
+
route,
|
|
62
|
+
duration_ms: elapsedMs,
|
|
63
|
+
client_ip: event?.getClientAddress?.(),
|
|
64
|
+
error: error instanceof Error
|
|
65
|
+
? { name: error.name, message: error.message }
|
|
66
|
+
: { value: String(error) }
|
|
67
|
+
});
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type EnhanceInput<CallType extends EnhanceCallType = EnhanceCallType, Par
|
|
|
18
18
|
request: Request;
|
|
19
19
|
callType: CallType;
|
|
20
20
|
fetch: typeof fetch;
|
|
21
|
+
getClientAddress?: () => string;
|
|
21
22
|
get errorHandlers(): EnhanceErrorHandler[];
|
|
22
23
|
} & (CallType extends 'handle' ? {
|
|
23
24
|
get responseHandlers(): EnhanceResponseHandler[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sourceregistry/sveltekit-enhance",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Composable enhance and form utilities for SvelteKit actions, loads, methods, and hooks.",
|
|
5
5
|
"author": "A.P.A. Slaa (a.p.a.slaa@projectsource.nl)",
|
|
6
6
|
"scripts": {
|
|
@@ -31,6 +31,10 @@
|
|
|
31
31
|
"types": "./dist/index.d.ts",
|
|
32
32
|
"svelte": "./dist/index.js",
|
|
33
33
|
"default": "./dist/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./helpers": {
|
|
36
|
+
"types": "./dist/helpers/index.d.ts",
|
|
37
|
+
"default": "./dist/helpers/index.js"
|
|
34
38
|
}
|
|
35
39
|
},
|
|
36
40
|
"repository": {
|