@shaferllc/keel 0.66.0 → 0.74.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/AGENTS.md +169 -0
- package/README.md +44 -6
- package/bin/keel-mcp.mjs +9 -0
- package/dist/api/api.config.stub +9 -0
- package/dist/api/config.d.ts +13 -0
- package/dist/api/config.js +14 -0
- package/dist/api/index.d.ts +16 -0
- package/dist/api/index.js +13 -0
- package/dist/api/provider.d.ts +10 -0
- package/dist/api/provider.js +17 -0
- package/dist/api/query.d.ts +35 -0
- package/dist/api/query.js +42 -0
- package/dist/api/resource.d.ts +91 -0
- package/dist/api/resource.js +188 -0
- package/dist/core/application.d.ts +5 -5
- package/dist/core/application.js +8 -2
- package/dist/core/cache.d.ts +81 -5
- package/dist/core/cache.js +188 -23
- package/dist/core/cli/stubs.d.ts +26 -0
- package/dist/core/cli/stubs.js +225 -0
- package/dist/core/console-prompt.d.ts +79 -0
- package/dist/core/console-prompt.js +239 -0
- package/dist/core/console-ui.d.ts +96 -0
- package/dist/core/console-ui.js +187 -0
- package/dist/core/console.d.ts +188 -0
- package/dist/core/console.js +395 -0
- package/dist/core/database.d.ts +70 -1
- package/dist/core/database.js +174 -15
- package/dist/core/env.d.ts +96 -0
- package/dist/core/env.js +140 -0
- package/dist/core/events.d.ts +129 -5
- package/dist/core/events.js +165 -7
- package/dist/core/health.d.ts +141 -0
- package/dist/core/health.js +226 -0
- package/dist/core/helpers.d.ts +9 -3
- package/dist/core/helpers.js +11 -3
- package/dist/core/http/kernel.d.ts +2 -0
- package/dist/core/http/kernel.js +48 -0
- package/dist/core/http/router.d.ts +5 -5
- package/dist/core/http/router.js +5 -5
- package/dist/core/i18n.d.ts +162 -0
- package/dist/core/i18n.js +472 -0
- package/dist/core/index.d.ts +39 -13
- package/dist/core/index.js +19 -6
- package/dist/core/instrumentation.d.ts +113 -0
- package/dist/core/instrumentation.js +52 -0
- package/dist/core/lock.d.ts +139 -0
- package/dist/core/lock.js +215 -0
- package/dist/core/logger.d.ts +89 -4
- package/dist/core/logger.js +167 -22
- package/dist/core/mail.d.ts +128 -7
- package/dist/core/mail.js +264 -16
- package/dist/core/notification.js +10 -1
- package/dist/core/package.d.ts +120 -0
- package/dist/core/package.js +169 -0
- package/dist/core/pages.d.ts +108 -0
- package/dist/core/pages.js +199 -0
- package/dist/core/queue.d.ts +134 -9
- package/dist/core/queue.js +325 -14
- package/dist/core/repl.d.ts +33 -0
- package/dist/core/repl.js +88 -0
- package/dist/core/scheduler.js +6 -0
- package/dist/core/social.d.ts +4 -4
- package/dist/core/social.js +4 -4
- package/dist/core/storage.d.ts +159 -6
- package/dist/core/storage.js +299 -7
- package/dist/core/telemetry.d.ts +208 -0
- package/dist/core/telemetry.js +383 -0
- package/dist/core/template.d.ts +2 -3
- package/dist/core/template.js +2 -3
- package/dist/core/testing.d.ts +170 -1
- package/dist/core/testing.js +504 -2
- package/dist/db/d1.js +13 -0
- package/dist/db/pg.d.ts +13 -0
- package/dist/db/pg.js +46 -4
- package/dist/mcp/server.d.ts +19 -0
- package/dist/mcp/server.js +355 -0
- package/dist/openapi/config.d.ts +28 -0
- package/dist/openapi/config.js +25 -0
- package/dist/openapi/doc.d.ts +40 -0
- package/dist/openapi/doc.js +20 -0
- package/dist/openapi/export.d.ts +8 -0
- package/dist/openapi/export.js +19 -0
- package/dist/openapi/gate.d.ts +15 -0
- package/dist/openapi/gate.js +27 -0
- package/dist/openapi/index.d.ts +19 -0
- package/dist/openapi/index.js +15 -0
- package/dist/openapi/openapi.config.stub +29 -0
- package/dist/openapi/provider.d.ts +18 -0
- package/dist/openapi/provider.js +35 -0
- package/dist/openapi/routes.d.ts +9 -0
- package/dist/openapi/routes.js +23 -0
- package/dist/openapi/spec.d.ts +23 -0
- package/dist/openapi/spec.js +132 -0
- package/dist/openapi/ui.d.ts +8 -0
- package/dist/openapi/ui.js +31 -0
- package/dist/openapi/zod.d.ts +12 -0
- package/dist/openapi/zod.js +46 -0
- package/dist/watch/config.d.ts +33 -0
- package/dist/watch/config.js +38 -0
- package/dist/watch/entry.d.ts +53 -0
- package/dist/watch/entry.js +105 -0
- package/dist/watch/gate.d.ts +20 -0
- package/dist/watch/gate.js +32 -0
- package/dist/watch/index.d.ts +21 -0
- package/dist/watch/index.js +17 -0
- package/dist/watch/migration.d.ts +7 -0
- package/dist/watch/migration.js +20 -0
- package/dist/watch/provider.d.ts +22 -0
- package/dist/watch/provider.js +58 -0
- package/dist/watch/prune.d.ts +11 -0
- package/dist/watch/prune.js +20 -0
- package/dist/watch/recorder.d.ts +24 -0
- package/dist/watch/recorder.js +39 -0
- package/dist/watch/routes.d.ts +13 -0
- package/dist/watch/routes.js +55 -0
- package/dist/watch/store.d.ts +54 -0
- package/dist/watch/store.js +158 -0
- package/dist/watch/ui/dist/watch.css +1 -0
- package/dist/watch/ui/dist/watch.js +555 -0
- package/dist/watch/ui-shell.d.ts +12 -0
- package/dist/watch/ui-shell.js +24 -0
- package/dist/watch/watch.config.stub +47 -0
- package/dist/watch/watchers.d.ts +12 -0
- package/dist/watch/watchers.js +156 -0
- package/docs/ai-manifest.json +3206 -0
- package/docs/ai.md +128 -0
- package/docs/api-resources.md +118 -0
- package/docs/architecture.md +331 -0
- package/docs/authentication.md +453 -0
- package/docs/authorization.md +167 -0
- package/docs/broadcasting.md +137 -0
- package/docs/broker.md +500 -0
- package/docs/cache.md +558 -0
- package/docs/configuration.md +385 -0
- package/docs/console.md +536 -0
- package/docs/container.md +467 -0
- package/docs/controllers.md +265 -0
- package/docs/cors.md +51 -0
- package/docs/database.md +631 -0
- package/docs/debugging.md +129 -0
- package/docs/decorators.md +127 -0
- package/docs/errors.md +395 -0
- package/docs/events.md +496 -0
- package/docs/examples/architecture-app.ts +27 -0
- package/docs/examples/authentication.ts +61 -0
- package/docs/examples/authorization.ts +79 -0
- package/docs/examples/broadcasting.ts +60 -0
- package/docs/examples/broker-cache-validate.ts +34 -0
- package/docs/examples/broker-fault-tolerance.ts +29 -0
- package/docs/examples/broker-middleware.ts +27 -0
- package/docs/examples/broker.ts +203 -0
- package/docs/examples/cache.ts +222 -0
- package/docs/examples/configuration.ts +121 -0
- package/docs/examples/console.ts +134 -0
- package/docs/examples/container.ts +134 -0
- package/docs/examples/controllers.ts +86 -0
- package/docs/examples/database.ts +208 -0
- package/docs/examples/debugging.ts +41 -0
- package/docs/examples/decorators.ts +40 -0
- package/docs/examples/errors.ts +121 -0
- package/docs/examples/events.ts +204 -0
- package/docs/examples/factories.ts +84 -0
- package/docs/examples/hashing.ts +71 -0
- package/docs/examples/health.ts +94 -0
- package/docs/examples/helpers.ts +171 -0
- package/docs/examples/hooks.ts +54 -0
- package/docs/examples/i18n.ts +117 -0
- package/docs/examples/inertia.ts +81 -0
- package/docs/examples/locks.ts +120 -0
- package/docs/examples/logger.ts +166 -0
- package/docs/examples/mail.ts +263 -0
- package/docs/examples/middleware.ts +119 -0
- package/docs/examples/migrations.ts +126 -0
- package/docs/examples/models.ts +239 -0
- package/docs/examples/notification.ts +124 -0
- package/docs/examples/pages.ts +82 -0
- package/docs/examples/providers.ts +123 -0
- package/docs/examples/queues.ts +254 -0
- package/docs/examples/rate-limiting.ts +42 -0
- package/docs/examples/redis.ts +99 -0
- package/docs/examples/request-response.ts +197 -0
- package/docs/examples/routing.ts +186 -0
- package/docs/examples/scheduling.ts +62 -0
- package/docs/examples/sessions.ts +102 -0
- package/docs/examples/static-files.ts +63 -0
- package/docs/examples/storage.ts +132 -0
- package/docs/examples/telemetry.ts +127 -0
- package/docs/examples/templates.ts +58 -0
- package/docs/examples/testing.ts +215 -0
- package/docs/examples/transformer.ts +141 -0
- package/docs/examples/transformers.ts +49 -0
- package/docs/examples/url-builder.ts +86 -0
- package/docs/examples/validation.ts +102 -0
- package/docs/examples/views.tsx +62 -0
- package/docs/examples/vite.ts +106 -0
- package/docs/factories.md +166 -0
- package/docs/getting-started.md +290 -0
- package/docs/hashing.md +259 -0
- package/docs/health.md +225 -0
- package/docs/helpers.md +347 -0
- package/docs/hono.md +186 -0
- package/docs/hooks.md +118 -0
- package/docs/i18n.md +302 -0
- package/docs/inertia.md +241 -0
- package/docs/locks.md +323 -0
- package/docs/logger.md +436 -0
- package/docs/mail.md +751 -0
- package/docs/middleware.md +425 -0
- package/docs/migrations.md +476 -0
- package/docs/models.md +810 -0
- package/docs/notifications.md +474 -0
- package/docs/openapi.md +111 -0
- package/docs/packages.md +118 -0
- package/docs/pages.md +217 -0
- package/docs/providers.md +363 -0
- package/docs/queues.md +679 -0
- package/docs/rate-limiting.md +155 -0
- package/docs/redis.md +178 -0
- package/docs/request-response.md +953 -0
- package/docs/routing.md +804 -0
- package/docs/scheduling.md +110 -0
- package/docs/security.md +85 -0
- package/docs/sessions.md +354 -0
- package/docs/social-auth.md +174 -0
- package/docs/static-files.md +211 -0
- package/docs/storage.md +452 -0
- package/docs/telemetry.md +263 -0
- package/docs/templates.md +314 -0
- package/docs/testing.md +376 -0
- package/docs/transformers.md +381 -0
- package/docs/url-builder.md +295 -0
- package/docs/validation.md +288 -0
- package/docs/views.md +267 -0
- package/docs/vite.md +434 -0
- package/docs/watch.md +118 -0
- package/llms-full.txt +19816 -0
- package/llms.txt +127 -0
- package/package.json +42 -7
package/docs/hashing.md
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# Hashing & Encryption
|
|
2
|
+
|
|
3
|
+
Password hashing and value encryption, both built on the Web Crypto API — so
|
|
4
|
+
they run identically on Node and the edge, with no native bindings (no `bcrypt`
|
|
5
|
+
to compile). Every operation is `async` (Web Crypto is promise-based), and the
|
|
6
|
+
core never reaches for a Node-only module, so the same code ships to a Worker or
|
|
7
|
+
a server unchanged.
|
|
8
|
+
|
|
9
|
+
## Hashing passwords
|
|
10
|
+
|
|
11
|
+
`hash.make()` produces a self-describing PBKDF2-SHA256 hash (algorithm,
|
|
12
|
+
iterations, salt, and digest are all encoded in the string):
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { hash } from "@shaferllc/keel/core";
|
|
16
|
+
|
|
17
|
+
const hashed = await hash.make(password); // store this
|
|
18
|
+
await hash.verify(hashed, password); // → true / false
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The stored string is `pbkdf2_sha256$<iterations>$<base64 salt>$<base64 digest>` —
|
|
22
|
+
four `$`-joined fields. Because the salt and work factor travel with the digest,
|
|
23
|
+
`verify()` needs nothing but the stored string and the candidate password; there
|
|
24
|
+
is no separate salt column to manage.
|
|
25
|
+
|
|
26
|
+
Each call to `make()` draws a fresh 16-byte random salt, so hashing the same
|
|
27
|
+
password twice yields two different strings — both verify:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
const a = await hash.make("hunter2");
|
|
31
|
+
const b = await hash.make("hunter2");
|
|
32
|
+
a === b; // false — different salts
|
|
33
|
+
await hash.verify(a, "hunter2"); // true
|
|
34
|
+
await hash.verify(b, "hunter2"); // true
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Note the argument order: `verify(hashed, password)` — the **stored hash comes
|
|
38
|
+
first**, the plaintext second.
|
|
39
|
+
|
|
40
|
+
### Rotating the work factor
|
|
41
|
+
|
|
42
|
+
Rotate work factors over time — bump the iteration count and rehash on next
|
|
43
|
+
login, while the user's plaintext is in hand:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
if (await hash.verify(user.password, plain)) {
|
|
47
|
+
if (hash.needsRehash(user.password)) {
|
|
48
|
+
user.password = await hash.make(plain);
|
|
49
|
+
await user.save();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The default is 100,000 iterations. To raise the bar, pass a higher count to
|
|
55
|
+
`make()` and check against the same number with `needsRehash()`:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
const hashed = await hash.make(password, 200_000);
|
|
59
|
+
hash.needsRehash(hashed, 200_000); // false — already at target
|
|
60
|
+
hash.needsRehash(hashed, 300_000); // true — below the new default
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Verification is timing-safe (a constant-time compare of the derived digests),
|
|
64
|
+
and `verify()` returns `false` — never throws — for the common malformed cases:
|
|
65
|
+
wrong algorithm prefix or a missing field. A hash whose iteration field or salt
|
|
66
|
+
is unparsable is a corrupt store, not a wrong password (see the note on
|
|
67
|
+
`verify` in the reference).
|
|
68
|
+
|
|
69
|
+
### Faster tests
|
|
70
|
+
|
|
71
|
+
PBKDF2 is deliberately slow, which makes a test suite that creates lots of users
|
|
72
|
+
crawl. `hash.fake()` swaps in a trivial, **insecure** scheme (`make` returns
|
|
73
|
+
`fake$<password>`, `verify` just compares) so hashing is near-instant; `restore()`
|
|
74
|
+
brings back real PBKDF2. Call them in your test setup/teardown:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
beforeEach(() => hash.fake());
|
|
78
|
+
afterEach(() => hash.restore());
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Never call `fake()` outside tests.
|
|
82
|
+
|
|
83
|
+
## Encrypting values
|
|
84
|
+
|
|
85
|
+
`encryption` encrypts any JSON-serializable value with AES-GCM (a 256-bit key
|
|
86
|
+
derived by SHA-256 from `config('app.key')`, a fresh 12-byte IV per call). Use it
|
|
87
|
+
for tokens, opaque cookies, or anything you hand to a client and get back:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { encryption } from "@shaferllc/keel/core";
|
|
91
|
+
|
|
92
|
+
const token = await encryption.encrypt({ userId: 1, scope: "reset" });
|
|
93
|
+
const data = await encryption.decrypt<{ userId: number }>(token);
|
|
94
|
+
// data → { userId: 1, scope: "reset" } or null if tampered / invalid
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
AES-GCM is authenticated: the tag is verified on decrypt, so any tampering with
|
|
98
|
+
the ciphertext (or a payload produced under a different key) fails the
|
|
99
|
+
authentication check. `decrypt()` turns that failure into `null` rather than
|
|
100
|
+
throwing — so a bad token is just an unauthenticated request, not a crash:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
const value = await encryption.decrypt(untrustedInput);
|
|
104
|
+
if (value === null) return unauthorized(); // tampered, truncated, or wrong key
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`encrypt()` round-trips anything `JSON.stringify` accepts — objects, arrays,
|
|
108
|
+
strings, numbers, booleans. It is not deterministic: the random IV means the same
|
|
109
|
+
input encrypts to a different string every time, so you can't use the ciphertext
|
|
110
|
+
as a lookup key.
|
|
111
|
+
|
|
112
|
+
### Expiring and purpose-bound tokens
|
|
113
|
+
|
|
114
|
+
`encrypt()` takes options that make the ciphertext self-expire and bind it to a
|
|
115
|
+
context — ideal for one-shot links like password resets or email confirmations:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
const token = await encryption.encrypt(
|
|
119
|
+
{ userId: 1 },
|
|
120
|
+
{ expiresIn: "1h", purpose: "password-reset" },
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// later — decrypt with the SAME purpose, or you get null
|
|
124
|
+
const data = await encryption.decrypt(token, { purpose: "password-reset" });
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`expiresIn` is seconds or a duration string (`"30m"`, `"1h"`, `"7d"`); an expired
|
|
128
|
+
token decrypts to `null`. `purpose` binds the token to a use — decrypting with a
|
|
129
|
+
different purpose (or none) returns `null`, so a reset token can't be replayed as,
|
|
130
|
+
say, a login token. Both travel inside the ciphertext, so they can't be tampered
|
|
131
|
+
with. Tokens made without these options keep decrypting as before.
|
|
132
|
+
|
|
133
|
+
## The app key
|
|
134
|
+
|
|
135
|
+
Both encryption and [signed URLs](./url-builder.md) use `config('app.key')`. Set
|
|
136
|
+
a long, random `APP_KEY` and keep it secret:
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
APP_KEY=a-long-random-secret-value
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
If `app.key` is unset, `encrypt()` **throws** (`Encryption requires
|
|
143
|
+
config('app.key'). Set APP_KEY.`) — encryption can't proceed without a key. On
|
|
144
|
+
the read side `decrypt()` still returns `null` (the missing-key error is caught
|
|
145
|
+
alongside every other decrypt failure), so a misconfigured key surfaces as failed
|
|
146
|
+
decryption, not an exception.
|
|
147
|
+
|
|
148
|
+
Changing the key invalidates every previously encrypted value and signed URL —
|
|
149
|
+
old ciphertext no longer authenticates under the new key, so every prior token
|
|
150
|
+
decrypts to `null`.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## API reference
|
|
155
|
+
|
|
156
|
+
Two objects are exported: `hash` (password hashing) and `encryption` (reversible
|
|
157
|
+
value encryption). Both are plain objects — import and call their methods
|
|
158
|
+
directly; there's nothing to construct.
|
|
159
|
+
|
|
160
|
+
### `hash`
|
|
161
|
+
|
|
162
|
+
PBKDF2-SHA256 password hashing. All three methods work on the self-describing
|
|
163
|
+
string format `pbkdf2_sha256$<iterations>$<salt>$<digest>`.
|
|
164
|
+
|
|
165
|
+
#### `hash.make(password, iterations?)`
|
|
166
|
+
|
|
167
|
+
`make(password: string, iterations?: number): Promise<string>`
|
|
168
|
+
|
|
169
|
+
Hashes a password with PBKDF2-SHA256 and a fresh random 16-byte salt, returning
|
|
170
|
+
the self-describing hash string.
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
const hashed = await hash.make(password); // 100,000 iterations
|
|
174
|
+
const stronger = await hash.make(password, 200_000);
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
**Notes:** `iterations` defaults to `100_000`. The salt is drawn from
|
|
178
|
+
`crypto.getRandomValues`, so the output differs on every call; the digest is
|
|
179
|
+
256-bit. Async because Web Crypto's `deriveBits` is. Store the whole returned
|
|
180
|
+
string — it carries everything `verify` needs.
|
|
181
|
+
|
|
182
|
+
#### `hash.verify(hashed, password)`
|
|
183
|
+
|
|
184
|
+
`verify(hashed: string, password: string): Promise<boolean>`
|
|
185
|
+
|
|
186
|
+
Re-derives the digest from `password` using the salt and iteration count embedded
|
|
187
|
+
in `hashed`, and compares it constant-time. **Stored hash first, plaintext
|
|
188
|
+
second.**
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
if (await hash.verify(user.password, submitted)) {
|
|
192
|
+
// authenticated
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**Notes:** the compare is timing-safe. Returns `false` (never throws) when the
|
|
197
|
+
algorithm prefix isn't `pbkdf2_sha256` or any of the four fields is missing.
|
|
198
|
+
Caveat: a hash with the right prefix but a *non-numeric* iteration field or an
|
|
199
|
+
*invalid-base64* salt slips past those guards and makes the underlying Web Crypto
|
|
200
|
+
call throw — so "never throws" holds for genuine wrong-password and simple
|
|
201
|
+
malformed cases, but not for a corrupted store. Treat your hash column as
|
|
202
|
+
trusted.
|
|
203
|
+
|
|
204
|
+
#### `hash.needsRehash(hashed, iterations?)`
|
|
205
|
+
|
|
206
|
+
`needsRehash(hashed: string, iterations?: number): boolean`
|
|
207
|
+
|
|
208
|
+
Returns `true` when `hashed` was made with fewer iterations than the given target
|
|
209
|
+
— your cue to re-hash the plaintext at the current work factor.
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
if (hash.needsRehash(user.password)) {
|
|
213
|
+
user.password = await hash.make(plain);
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
**Notes:** synchronous (it just reads the iteration field). `iterations`
|
|
218
|
+
defaults to `100_000`. Returns `true` if the iteration field is absent or
|
|
219
|
+
unparsable (a `0`/`NaN` count reads as "below target"). Only meaningful right
|
|
220
|
+
after a successful `verify`, when you still hold the plaintext to re-hash.
|
|
221
|
+
|
|
222
|
+
### `encryption`
|
|
223
|
+
|
|
224
|
+
Authenticated (AES-GCM) encryption of JSON-serializable values, keyed by
|
|
225
|
+
`config('app.key')`.
|
|
226
|
+
|
|
227
|
+
#### `encryption.encrypt(value)`
|
|
228
|
+
|
|
229
|
+
`encrypt(value: unknown): Promise<string>`
|
|
230
|
+
|
|
231
|
+
JSON-serializes `value`, encrypts it with AES-GCM under a key derived from
|
|
232
|
+
`config('app.key')`, and returns a base64 string (IV prepended to the
|
|
233
|
+
ciphertext).
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
const token = await encryption.encrypt({ userId: 1, scope: "reset" });
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
**Notes:** each call uses a fresh random 12-byte IV, so the output is
|
|
240
|
+
non-deterministic — never use the ciphertext as a cache/lookup key. Throws
|
|
241
|
+
`Encryption requires config('app.key'). Set APP_KEY.` if the app key is unset.
|
|
242
|
+
`value` must survive `JSON.stringify` (no `undefined`, functions, or `BigInt`).
|
|
243
|
+
|
|
244
|
+
#### `encryption.decrypt(payload)`
|
|
245
|
+
|
|
246
|
+
`decrypt<T = unknown>(payload: string): Promise<T | null>`
|
|
247
|
+
|
|
248
|
+
Reverses `encrypt`: verifies the AES-GCM tag, decrypts, and `JSON.parse`s the
|
|
249
|
+
result. The type parameter types the resolved value.
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
const data = await encryption.decrypt<{ userId: number }>(token);
|
|
253
|
+
if (data === null) return unauthorized();
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
**Notes:** returns `null` — never throws — for any failure: tampered or truncated
|
|
257
|
+
ciphertext, a payload encrypted under a different key, malformed base64, or an
|
|
258
|
+
unset app key (all caught internally). `T` is a compile-time convenience; it does
|
|
259
|
+
not validate the decrypted shape at runtime.
|
package/docs/health.md
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# Health Checks
|
|
2
|
+
|
|
3
|
+
Two endpoints, answering the two questions an orchestrator — Kubernetes, Fly,
|
|
4
|
+
Railway, a load balancer — actually asks:
|
|
5
|
+
|
|
6
|
+
| Endpoint | Question | Checks |
|
|
7
|
+
|----------|----------|--------|
|
|
8
|
+
| `/health/live` | Is the process up? | **nothing** — answers instantly |
|
|
9
|
+
| `/health/ready` | Can it serve requests? | every registered check |
|
|
10
|
+
|
|
11
|
+
The split matters. A liveness probe that touched the database would get a
|
|
12
|
+
perfectly healthy app **restarted** during a database blip. So liveness checks
|
|
13
|
+
nothing: if it answered, the process is alive. Readiness is where dependencies are
|
|
14
|
+
checked — a 503 there pulls the instance out of the load-balancer pool without
|
|
15
|
+
killing it, and it rejoins when the dependency recovers.
|
|
16
|
+
|
|
17
|
+
## Using it
|
|
18
|
+
|
|
19
|
+
Register your checks once, then install the middleware:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { health, healthCheck, HttpKernel, DatabaseCheck, RedisCheck } from "@shaferllc/keel/core";
|
|
23
|
+
|
|
24
|
+
export class HealthServiceProvider extends ServiceProvider {
|
|
25
|
+
boot(): void {
|
|
26
|
+
health().register([
|
|
27
|
+
new DatabaseCheck(),
|
|
28
|
+
new RedisCheck().cacheFor(30),
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
this.app.make(HttpKernel).use(healthCheck());
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`GET /health/ready` now returns:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"isHealthy": true,
|
|
41
|
+
"status": "ok",
|
|
42
|
+
"finishedAt": "2026-07-11T18:04:22.014Z",
|
|
43
|
+
"checks": [
|
|
44
|
+
{
|
|
45
|
+
"name": "database",
|
|
46
|
+
"status": "ok",
|
|
47
|
+
"message": "Database is reachable",
|
|
48
|
+
"isCached": false,
|
|
49
|
+
"finishedAt": "2026-07-11T18:04:22.011Z",
|
|
50
|
+
"meta": { "durationMs": 3 }
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
with **200** while `isHealthy` is true and **503** the moment a check fails.
|
|
57
|
+
|
|
58
|
+
## Built-in checks
|
|
59
|
+
|
|
60
|
+
| Check | What it does |
|
|
61
|
+
|-------|--------------|
|
|
62
|
+
| `DatabaseCheck` | `SELECT 1` on the default connection, or `new DatabaseCheck("reporting")` for a named one |
|
|
63
|
+
| `RedisCheck` | Reads a key — a failed read means a broken connection |
|
|
64
|
+
| `CacheCheck` | Writes a key, reads it back, deletes it |
|
|
65
|
+
|
|
66
|
+
Notably **absent**: disk-space, heap, and RSS checks. Those measure a Node
|
|
67
|
+
process, and on Workers there isn't one — a memory threshold you can't observe is
|
|
68
|
+
worse than no check at all. If you're on Node and want them, `check()` below takes
|
|
69
|
+
ten lines.
|
|
70
|
+
|
|
71
|
+
## Your own checks
|
|
72
|
+
|
|
73
|
+
`check(name, fn)` builds one from a function. Return `Result.ok`, `Result.warning`,
|
|
74
|
+
or `Result.failed`:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { check, Result, health } from "@shaferllc/keel/core";
|
|
78
|
+
|
|
79
|
+
health().register([
|
|
80
|
+
check("stripe", async () => {
|
|
81
|
+
const res = await fetch("https://api.stripe.com/healthcheck");
|
|
82
|
+
return res.ok
|
|
83
|
+
? Result.ok("Stripe is reachable")
|
|
84
|
+
: Result.failed(`Stripe returned ${res.status}`);
|
|
85
|
+
}),
|
|
86
|
+
|
|
87
|
+
check("queue-depth", async () => {
|
|
88
|
+
const depth = await queue().size();
|
|
89
|
+
if (depth > 10_000) return Result.failed(`Queue is backed up (${depth})`);
|
|
90
|
+
if (depth > 1_000) return Result.warning(`Queue is deep (${depth})`);
|
|
91
|
+
return Result.ok("Queue is keeping up").withMeta({ depth });
|
|
92
|
+
}),
|
|
93
|
+
]);
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**A warning is still healthy.** It shows up in the report and moves the overall
|
|
97
|
+
`status` to `"warning"`, but readiness stays **200** — degraded is not the same as
|
|
98
|
+
unable to serve, and you don't want a slow-but-working queue to evict every
|
|
99
|
+
instance you have. Only `Result.failed` returns a 503.
|
|
100
|
+
|
|
101
|
+
`withMeta()` attaches arbitrary detail (latencies, counts, versions) to the check's
|
|
102
|
+
entry in the report.
|
|
103
|
+
|
|
104
|
+
For a check with dependencies or state, extend `BaseCheck` instead:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
class QueueDepthCheck extends BaseCheck {
|
|
108
|
+
readonly name = "queue-depth";
|
|
109
|
+
|
|
110
|
+
constructor(private limit = 10_000) {
|
|
111
|
+
super();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async run(): Promise<Result> {
|
|
115
|
+
const depth = await queue().size();
|
|
116
|
+
return depth > this.limit
|
|
117
|
+
? Result.failed(`Queue is backed up (${depth})`)
|
|
118
|
+
: Result.ok("Queue is keeping up").withMeta({ depth });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**A check that throws becomes a failure**, not an exception — one broken check
|
|
124
|
+
never takes down the whole report, and the other checks still run and report.
|
|
125
|
+
|
|
126
|
+
## Caching a check
|
|
127
|
+
|
|
128
|
+
A probe every few seconds shouldn't hammer the thing it's probing. `cacheFor(seconds)`
|
|
129
|
+
reuses the last result inside that window, and the report marks it `isCached: true`:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
health().register([
|
|
133
|
+
new DatabaseCheck(), // run every time — it's cheap
|
|
134
|
+
new RedisCheck().cacheFor(30), // at most once every 30s
|
|
135
|
+
]);
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Protecting the endpoint
|
|
139
|
+
|
|
140
|
+
The readiness report names your infrastructure, so don't publish it. Pass a
|
|
141
|
+
`secret` and readiness requires `Authorization: Bearer <secret>`:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
this.app.make(HttpKernel).use(healthCheck({ secret: env.HEALTH_SECRET }));
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Liveness stays open — the orchestrator's restart probe shouldn't need a key, and
|
|
148
|
+
it reveals nothing.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## API reference
|
|
153
|
+
|
|
154
|
+
### `health()`
|
|
155
|
+
|
|
156
|
+
`health(): HealthChecks`
|
|
157
|
+
|
|
158
|
+
The application's health-check registry — a singleton.
|
|
159
|
+
|
|
160
|
+
### `healthCheck(options?)`
|
|
161
|
+
|
|
162
|
+
`healthCheck(options?: HealthCheckOptions): MiddlewareHandler`
|
|
163
|
+
|
|
164
|
+
Serves `/health/live` and `/health/ready`. Anything else falls through to your
|
|
165
|
+
routes.
|
|
166
|
+
|
|
167
|
+
**Options:** `basePath` (default `"/health"`), `secret` (bearer token required on
|
|
168
|
+
readiness), `checks` (a `HealthChecks` to run instead of the global registry —
|
|
169
|
+
handy in tests).
|
|
170
|
+
|
|
171
|
+
### `HealthChecks`
|
|
172
|
+
|
|
173
|
+
| Method | Signature |
|
|
174
|
+
|--------|-----------|
|
|
175
|
+
| `register` | `(checks: BaseCheck[]) => this` |
|
|
176
|
+
| `run` | `() => Promise<HealthReport>` — runs every check concurrently |
|
|
177
|
+
| `all` | `() => BaseCheck[]` |
|
|
178
|
+
| `clear` | `() => this` |
|
|
179
|
+
|
|
180
|
+
### `check(name, fn)`
|
|
181
|
+
|
|
182
|
+
`check(name: string, run: () => Promise<Result> | Result): BaseCheck`
|
|
183
|
+
|
|
184
|
+
Builds a check from a function.
|
|
185
|
+
|
|
186
|
+
### `BaseCheck`
|
|
187
|
+
|
|
188
|
+
Abstract. Implement `name` and `run(): Promise<Result>`. `cacheFor(seconds)` reuses
|
|
189
|
+
the last result for a window.
|
|
190
|
+
|
|
191
|
+
### `Result`
|
|
192
|
+
|
|
193
|
+
`Result.ok(message)` / `Result.warning(message)` / `Result.failed(message, error?)`,
|
|
194
|
+
plus `withMeta(data)` to attach detail. A warning keeps readiness at 200; a failure
|
|
195
|
+
takes it to 503.
|
|
196
|
+
|
|
197
|
+
### Interfaces & types
|
|
198
|
+
|
|
199
|
+
#### `HealthReport`
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
{
|
|
203
|
+
isHealthy: boolean; // false only if a check failed
|
|
204
|
+
status: HealthStatus; // the worst status any check reported
|
|
205
|
+
finishedAt: string; // ISO 8601
|
|
206
|
+
checks: CheckReport[];
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
#### `CheckReport`
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
{
|
|
214
|
+
name: string;
|
|
215
|
+
status: HealthStatus;
|
|
216
|
+
message: string;
|
|
217
|
+
isCached: boolean; // reused from a cacheFor() window
|
|
218
|
+
finishedAt: string;
|
|
219
|
+
meta?: Record<string, unknown>;
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
#### `HealthStatus`
|
|
224
|
+
|
|
225
|
+
`type HealthStatus = "ok" | "warning" | "error"`.
|