@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.
Files changed (239) hide show
  1. package/AGENTS.md +169 -0
  2. package/README.md +44 -6
  3. package/bin/keel-mcp.mjs +9 -0
  4. package/dist/api/api.config.stub +9 -0
  5. package/dist/api/config.d.ts +13 -0
  6. package/dist/api/config.js +14 -0
  7. package/dist/api/index.d.ts +16 -0
  8. package/dist/api/index.js +13 -0
  9. package/dist/api/provider.d.ts +10 -0
  10. package/dist/api/provider.js +17 -0
  11. package/dist/api/query.d.ts +35 -0
  12. package/dist/api/query.js +42 -0
  13. package/dist/api/resource.d.ts +91 -0
  14. package/dist/api/resource.js +188 -0
  15. package/dist/core/application.d.ts +5 -5
  16. package/dist/core/application.js +8 -2
  17. package/dist/core/cache.d.ts +81 -5
  18. package/dist/core/cache.js +188 -23
  19. package/dist/core/cli/stubs.d.ts +26 -0
  20. package/dist/core/cli/stubs.js +225 -0
  21. package/dist/core/console-prompt.d.ts +79 -0
  22. package/dist/core/console-prompt.js +239 -0
  23. package/dist/core/console-ui.d.ts +96 -0
  24. package/dist/core/console-ui.js +187 -0
  25. package/dist/core/console.d.ts +188 -0
  26. package/dist/core/console.js +395 -0
  27. package/dist/core/database.d.ts +70 -1
  28. package/dist/core/database.js +174 -15
  29. package/dist/core/env.d.ts +96 -0
  30. package/dist/core/env.js +140 -0
  31. package/dist/core/events.d.ts +129 -5
  32. package/dist/core/events.js +165 -7
  33. package/dist/core/health.d.ts +141 -0
  34. package/dist/core/health.js +226 -0
  35. package/dist/core/helpers.d.ts +9 -3
  36. package/dist/core/helpers.js +11 -3
  37. package/dist/core/http/kernel.d.ts +2 -0
  38. package/dist/core/http/kernel.js +48 -0
  39. package/dist/core/http/router.d.ts +5 -5
  40. package/dist/core/http/router.js +5 -5
  41. package/dist/core/i18n.d.ts +162 -0
  42. package/dist/core/i18n.js +472 -0
  43. package/dist/core/index.d.ts +39 -13
  44. package/dist/core/index.js +19 -6
  45. package/dist/core/instrumentation.d.ts +113 -0
  46. package/dist/core/instrumentation.js +52 -0
  47. package/dist/core/lock.d.ts +139 -0
  48. package/dist/core/lock.js +215 -0
  49. package/dist/core/logger.d.ts +89 -4
  50. package/dist/core/logger.js +167 -22
  51. package/dist/core/mail.d.ts +128 -7
  52. package/dist/core/mail.js +264 -16
  53. package/dist/core/notification.js +10 -1
  54. package/dist/core/package.d.ts +120 -0
  55. package/dist/core/package.js +169 -0
  56. package/dist/core/pages.d.ts +108 -0
  57. package/dist/core/pages.js +199 -0
  58. package/dist/core/queue.d.ts +134 -9
  59. package/dist/core/queue.js +325 -14
  60. package/dist/core/repl.d.ts +33 -0
  61. package/dist/core/repl.js +88 -0
  62. package/dist/core/scheduler.js +6 -0
  63. package/dist/core/social.d.ts +4 -4
  64. package/dist/core/social.js +4 -4
  65. package/dist/core/storage.d.ts +159 -6
  66. package/dist/core/storage.js +299 -7
  67. package/dist/core/telemetry.d.ts +208 -0
  68. package/dist/core/telemetry.js +383 -0
  69. package/dist/core/template.d.ts +2 -3
  70. package/dist/core/template.js +2 -3
  71. package/dist/core/testing.d.ts +170 -1
  72. package/dist/core/testing.js +504 -2
  73. package/dist/db/d1.js +13 -0
  74. package/dist/db/pg.d.ts +13 -0
  75. package/dist/db/pg.js +46 -4
  76. package/dist/mcp/server.d.ts +19 -0
  77. package/dist/mcp/server.js +355 -0
  78. package/dist/openapi/config.d.ts +28 -0
  79. package/dist/openapi/config.js +25 -0
  80. package/dist/openapi/doc.d.ts +40 -0
  81. package/dist/openapi/doc.js +20 -0
  82. package/dist/openapi/export.d.ts +8 -0
  83. package/dist/openapi/export.js +19 -0
  84. package/dist/openapi/gate.d.ts +15 -0
  85. package/dist/openapi/gate.js +27 -0
  86. package/dist/openapi/index.d.ts +19 -0
  87. package/dist/openapi/index.js +15 -0
  88. package/dist/openapi/openapi.config.stub +29 -0
  89. package/dist/openapi/provider.d.ts +18 -0
  90. package/dist/openapi/provider.js +35 -0
  91. package/dist/openapi/routes.d.ts +9 -0
  92. package/dist/openapi/routes.js +23 -0
  93. package/dist/openapi/spec.d.ts +23 -0
  94. package/dist/openapi/spec.js +132 -0
  95. package/dist/openapi/ui.d.ts +8 -0
  96. package/dist/openapi/ui.js +31 -0
  97. package/dist/openapi/zod.d.ts +12 -0
  98. package/dist/openapi/zod.js +46 -0
  99. package/dist/watch/config.d.ts +33 -0
  100. package/dist/watch/config.js +38 -0
  101. package/dist/watch/entry.d.ts +53 -0
  102. package/dist/watch/entry.js +105 -0
  103. package/dist/watch/gate.d.ts +20 -0
  104. package/dist/watch/gate.js +32 -0
  105. package/dist/watch/index.d.ts +21 -0
  106. package/dist/watch/index.js +17 -0
  107. package/dist/watch/migration.d.ts +7 -0
  108. package/dist/watch/migration.js +20 -0
  109. package/dist/watch/provider.d.ts +22 -0
  110. package/dist/watch/provider.js +58 -0
  111. package/dist/watch/prune.d.ts +11 -0
  112. package/dist/watch/prune.js +20 -0
  113. package/dist/watch/recorder.d.ts +24 -0
  114. package/dist/watch/recorder.js +39 -0
  115. package/dist/watch/routes.d.ts +13 -0
  116. package/dist/watch/routes.js +55 -0
  117. package/dist/watch/store.d.ts +54 -0
  118. package/dist/watch/store.js +158 -0
  119. package/dist/watch/ui/dist/watch.css +1 -0
  120. package/dist/watch/ui/dist/watch.js +555 -0
  121. package/dist/watch/ui-shell.d.ts +12 -0
  122. package/dist/watch/ui-shell.js +24 -0
  123. package/dist/watch/watch.config.stub +47 -0
  124. package/dist/watch/watchers.d.ts +12 -0
  125. package/dist/watch/watchers.js +156 -0
  126. package/docs/ai-manifest.json +3206 -0
  127. package/docs/ai.md +128 -0
  128. package/docs/api-resources.md +118 -0
  129. package/docs/architecture.md +331 -0
  130. package/docs/authentication.md +453 -0
  131. package/docs/authorization.md +167 -0
  132. package/docs/broadcasting.md +137 -0
  133. package/docs/broker.md +500 -0
  134. package/docs/cache.md +558 -0
  135. package/docs/configuration.md +385 -0
  136. package/docs/console.md +536 -0
  137. package/docs/container.md +467 -0
  138. package/docs/controllers.md +265 -0
  139. package/docs/cors.md +51 -0
  140. package/docs/database.md +631 -0
  141. package/docs/debugging.md +129 -0
  142. package/docs/decorators.md +127 -0
  143. package/docs/errors.md +395 -0
  144. package/docs/events.md +496 -0
  145. package/docs/examples/architecture-app.ts +27 -0
  146. package/docs/examples/authentication.ts +61 -0
  147. package/docs/examples/authorization.ts +79 -0
  148. package/docs/examples/broadcasting.ts +60 -0
  149. package/docs/examples/broker-cache-validate.ts +34 -0
  150. package/docs/examples/broker-fault-tolerance.ts +29 -0
  151. package/docs/examples/broker-middleware.ts +27 -0
  152. package/docs/examples/broker.ts +203 -0
  153. package/docs/examples/cache.ts +222 -0
  154. package/docs/examples/configuration.ts +121 -0
  155. package/docs/examples/console.ts +134 -0
  156. package/docs/examples/container.ts +134 -0
  157. package/docs/examples/controllers.ts +86 -0
  158. package/docs/examples/database.ts +208 -0
  159. package/docs/examples/debugging.ts +41 -0
  160. package/docs/examples/decorators.ts +40 -0
  161. package/docs/examples/errors.ts +121 -0
  162. package/docs/examples/events.ts +204 -0
  163. package/docs/examples/factories.ts +84 -0
  164. package/docs/examples/hashing.ts +71 -0
  165. package/docs/examples/health.ts +94 -0
  166. package/docs/examples/helpers.ts +171 -0
  167. package/docs/examples/hooks.ts +54 -0
  168. package/docs/examples/i18n.ts +117 -0
  169. package/docs/examples/inertia.ts +81 -0
  170. package/docs/examples/locks.ts +120 -0
  171. package/docs/examples/logger.ts +166 -0
  172. package/docs/examples/mail.ts +263 -0
  173. package/docs/examples/middleware.ts +119 -0
  174. package/docs/examples/migrations.ts +126 -0
  175. package/docs/examples/models.ts +239 -0
  176. package/docs/examples/notification.ts +124 -0
  177. package/docs/examples/pages.ts +82 -0
  178. package/docs/examples/providers.ts +123 -0
  179. package/docs/examples/queues.ts +254 -0
  180. package/docs/examples/rate-limiting.ts +42 -0
  181. package/docs/examples/redis.ts +99 -0
  182. package/docs/examples/request-response.ts +197 -0
  183. package/docs/examples/routing.ts +186 -0
  184. package/docs/examples/scheduling.ts +62 -0
  185. package/docs/examples/sessions.ts +102 -0
  186. package/docs/examples/static-files.ts +63 -0
  187. package/docs/examples/storage.ts +132 -0
  188. package/docs/examples/telemetry.ts +127 -0
  189. package/docs/examples/templates.ts +58 -0
  190. package/docs/examples/testing.ts +215 -0
  191. package/docs/examples/transformer.ts +141 -0
  192. package/docs/examples/transformers.ts +49 -0
  193. package/docs/examples/url-builder.ts +86 -0
  194. package/docs/examples/validation.ts +102 -0
  195. package/docs/examples/views.tsx +62 -0
  196. package/docs/examples/vite.ts +106 -0
  197. package/docs/factories.md +166 -0
  198. package/docs/getting-started.md +290 -0
  199. package/docs/hashing.md +259 -0
  200. package/docs/health.md +225 -0
  201. package/docs/helpers.md +347 -0
  202. package/docs/hono.md +186 -0
  203. package/docs/hooks.md +118 -0
  204. package/docs/i18n.md +302 -0
  205. package/docs/inertia.md +241 -0
  206. package/docs/locks.md +323 -0
  207. package/docs/logger.md +436 -0
  208. package/docs/mail.md +751 -0
  209. package/docs/middleware.md +425 -0
  210. package/docs/migrations.md +476 -0
  211. package/docs/models.md +810 -0
  212. package/docs/notifications.md +474 -0
  213. package/docs/openapi.md +111 -0
  214. package/docs/packages.md +118 -0
  215. package/docs/pages.md +217 -0
  216. package/docs/providers.md +363 -0
  217. package/docs/queues.md +679 -0
  218. package/docs/rate-limiting.md +155 -0
  219. package/docs/redis.md +178 -0
  220. package/docs/request-response.md +953 -0
  221. package/docs/routing.md +804 -0
  222. package/docs/scheduling.md +110 -0
  223. package/docs/security.md +85 -0
  224. package/docs/sessions.md +354 -0
  225. package/docs/social-auth.md +174 -0
  226. package/docs/static-files.md +211 -0
  227. package/docs/storage.md +452 -0
  228. package/docs/telemetry.md +263 -0
  229. package/docs/templates.md +314 -0
  230. package/docs/testing.md +376 -0
  231. package/docs/transformers.md +381 -0
  232. package/docs/url-builder.md +295 -0
  233. package/docs/validation.md +288 -0
  234. package/docs/views.md +267 -0
  235. package/docs/vite.md +434 -0
  236. package/docs/watch.md +118 -0
  237. package/llms-full.txt +19816 -0
  238. package/llms.txt +127 -0
  239. package/package.json +42 -7
@@ -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"`.