@shaferllc/keel 0.59.0 → 0.68.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 (166) hide show
  1. package/AGENTS.md +167 -0
  2. package/README.md +30 -1
  3. package/bin/keel-mcp.mjs +9 -0
  4. package/dist/core/application.d.ts +5 -5
  5. package/dist/core/application.js +14 -2
  6. package/dist/core/auth.d.ts +47 -0
  7. package/dist/core/auth.js +77 -0
  8. package/dist/core/authorization.d.ts +9 -1
  9. package/dist/core/authorization.js +22 -2
  10. package/dist/core/cache.d.ts +82 -5
  11. package/dist/core/cache.js +181 -23
  12. package/dist/core/cli/stubs.d.ts +12 -0
  13. package/dist/core/cli/stubs.js +120 -0
  14. package/dist/core/container.d.ts +20 -0
  15. package/dist/core/container.js +52 -0
  16. package/dist/core/cors.d.ts +29 -0
  17. package/dist/core/cors.js +72 -0
  18. package/dist/core/crypto.d.ts +40 -4
  19. package/dist/core/crypto.js +66 -6
  20. package/dist/core/csrf.d.ts +25 -0
  21. package/dist/core/csrf.js +78 -0
  22. package/dist/core/database.d.ts +49 -4
  23. package/dist/core/database.js +89 -21
  24. package/dist/core/events.d.ts +129 -5
  25. package/dist/core/events.js +165 -7
  26. package/dist/core/health.d.ts +141 -0
  27. package/dist/core/health.js +226 -0
  28. package/dist/core/helpers.d.ts +15 -3
  29. package/dist/core/helpers.js +23 -3
  30. package/dist/core/index.d.ts +33 -18
  31. package/dist/core/index.js +16 -8
  32. package/dist/core/lock.d.ts +139 -0
  33. package/dist/core/lock.js +215 -0
  34. package/dist/core/logger.d.ts +82 -4
  35. package/dist/core/logger.js +141 -23
  36. package/dist/core/mail.d.ts +128 -7
  37. package/dist/core/mail.js +264 -16
  38. package/dist/core/model.d.ts +2 -0
  39. package/dist/core/model.js +16 -14
  40. package/dist/core/provider.d.ts +7 -0
  41. package/dist/core/provider.js +7 -0
  42. package/dist/core/queue.d.ts +134 -9
  43. package/dist/core/queue.js +304 -14
  44. package/dist/core/rate-limit.js +3 -0
  45. package/dist/core/relations.js +13 -13
  46. package/dist/core/request.d.ts +26 -0
  47. package/dist/core/request.js +77 -0
  48. package/dist/core/shield.d.ts +39 -0
  49. package/dist/core/shield.js +60 -0
  50. package/dist/core/social.d.ts +173 -0
  51. package/dist/core/social.js +337 -0
  52. package/dist/core/storage.d.ts +159 -6
  53. package/dist/core/storage.js +287 -7
  54. package/dist/core/tokens.d.ts +74 -0
  55. package/dist/core/tokens.js +155 -0
  56. package/dist/db/d1.d.ts +32 -0
  57. package/dist/db/d1.js +26 -0
  58. package/dist/db/libsql.d.ts +29 -0
  59. package/dist/db/libsql.js +32 -0
  60. package/dist/db/pg.d.ts +29 -0
  61. package/dist/db/pg.js +33 -0
  62. package/dist/mcp/server.d.ts +19 -0
  63. package/dist/mcp/server.js +355 -0
  64. package/docs/ai-manifest.json +2472 -0
  65. package/docs/ai.md +128 -0
  66. package/docs/architecture.md +331 -0
  67. package/docs/authentication.md +453 -0
  68. package/docs/authorization.md +167 -0
  69. package/docs/broadcasting.md +137 -0
  70. package/docs/broker.md +500 -0
  71. package/docs/cache.md +558 -0
  72. package/docs/configuration.md +311 -0
  73. package/docs/console.md +356 -0
  74. package/docs/container.md +467 -0
  75. package/docs/controllers.md +265 -0
  76. package/docs/cors.md +51 -0
  77. package/docs/database.md +530 -0
  78. package/docs/debugging.md +129 -0
  79. package/docs/decorators.md +127 -0
  80. package/docs/errors.md +395 -0
  81. package/docs/events.md +496 -0
  82. package/docs/examples/architecture-app.ts +27 -0
  83. package/docs/examples/authentication.ts +61 -0
  84. package/docs/examples/authorization.ts +79 -0
  85. package/docs/examples/broadcasting.ts +60 -0
  86. package/docs/examples/broker-cache-validate.ts +34 -0
  87. package/docs/examples/broker-fault-tolerance.ts +29 -0
  88. package/docs/examples/broker-middleware.ts +27 -0
  89. package/docs/examples/broker.ts +203 -0
  90. package/docs/examples/cache.ts +222 -0
  91. package/docs/examples/configuration.ts +81 -0
  92. package/docs/examples/container.ts +134 -0
  93. package/docs/examples/controllers.ts +86 -0
  94. package/docs/examples/database.ts +118 -0
  95. package/docs/examples/debugging.ts +41 -0
  96. package/docs/examples/decorators.ts +40 -0
  97. package/docs/examples/errors.ts +121 -0
  98. package/docs/examples/events.ts +204 -0
  99. package/docs/examples/factories.ts +84 -0
  100. package/docs/examples/hashing.ts +71 -0
  101. package/docs/examples/health.ts +94 -0
  102. package/docs/examples/helpers.ts +171 -0
  103. package/docs/examples/hooks.ts +54 -0
  104. package/docs/examples/inertia.ts +81 -0
  105. package/docs/examples/locks.ts +120 -0
  106. package/docs/examples/logger.ts +92 -0
  107. package/docs/examples/mail.ts +160 -0
  108. package/docs/examples/middleware.ts +119 -0
  109. package/docs/examples/migrations.ts +126 -0
  110. package/docs/examples/models.ts +239 -0
  111. package/docs/examples/notification.ts +124 -0
  112. package/docs/examples/providers.ts +123 -0
  113. package/docs/examples/queues.ts +254 -0
  114. package/docs/examples/rate-limiting.ts +42 -0
  115. package/docs/examples/redis.ts +99 -0
  116. package/docs/examples/request-response.ts +197 -0
  117. package/docs/examples/routing.ts +186 -0
  118. package/docs/examples/scheduling.ts +62 -0
  119. package/docs/examples/sessions.ts +102 -0
  120. package/docs/examples/static-files.ts +63 -0
  121. package/docs/examples/storage.ts +132 -0
  122. package/docs/examples/templates.ts +58 -0
  123. package/docs/examples/testing.ts +66 -0
  124. package/docs/examples/transformer.ts +141 -0
  125. package/docs/examples/transformers.ts +49 -0
  126. package/docs/examples/url-builder.ts +86 -0
  127. package/docs/examples/validation.ts +102 -0
  128. package/docs/examples/views.tsx +62 -0
  129. package/docs/examples/vite.ts +106 -0
  130. package/docs/factories.md +166 -0
  131. package/docs/getting-started.md +290 -0
  132. package/docs/hashing.md +259 -0
  133. package/docs/health.md +225 -0
  134. package/docs/helpers.md +347 -0
  135. package/docs/hono.md +186 -0
  136. package/docs/hooks.md +118 -0
  137. package/docs/inertia.md +241 -0
  138. package/docs/locks.md +323 -0
  139. package/docs/logger.md +290 -0
  140. package/docs/mail.md +678 -0
  141. package/docs/middleware.md +425 -0
  142. package/docs/migrations.md +476 -0
  143. package/docs/models.md +810 -0
  144. package/docs/notifications.md +474 -0
  145. package/docs/providers.md +363 -0
  146. package/docs/queues.md +679 -0
  147. package/docs/rate-limiting.md +155 -0
  148. package/docs/redis.md +178 -0
  149. package/docs/request-response.md +953 -0
  150. package/docs/routing.md +804 -0
  151. package/docs/scheduling.md +110 -0
  152. package/docs/security.md +85 -0
  153. package/docs/sessions.md +354 -0
  154. package/docs/social-auth.md +174 -0
  155. package/docs/static-files.md +211 -0
  156. package/docs/storage.md +450 -0
  157. package/docs/templates.md +315 -0
  158. package/docs/testing.md +125 -0
  159. package/docs/transformers.md +381 -0
  160. package/docs/url-builder.md +295 -0
  161. package/docs/validation.md +288 -0
  162. package/docs/views.md +267 -0
  163. package/docs/vite.md +434 -0
  164. package/llms-full.txt +17694 -0
  165. package/llms.txt +116 -0
  166. package/package.json +38 -7
@@ -9,8 +9,19 @@
9
9
  * const bytes = await storage().get("avatars/1.png");
10
10
  * const url = storage().url("avatars/1.png");
11
11
  *
12
+ * Private files get a temporary URL instead of a public one:
13
+ *
14
+ * await storage().signedUrl("invoices/42.pdf", { expiresIn: 300 });
15
+ *
16
+ * and browsers can upload straight to the bucket — the bytes never transit the
17
+ * app — with a signed upload URL:
18
+ *
19
+ * await storage().signedUploadUrl("uploads/raw.mov", { contentType: "video/quicktime" });
20
+ *
12
21
  * Register several disks by name and select one with `storage("s3")`.
13
22
  */
23
+ import { getMimeType } from "hono/utils/mime";
24
+ import { config } from "./helpers.js";
14
25
  function toBytes(contents) {
15
26
  if (typeof contents === "string")
16
27
  return new TextEncoder().encode(contents);
@@ -18,7 +29,71 @@ function toBytes(contents) {
18
29
  return contents;
19
30
  return new Uint8Array(contents);
20
31
  }
21
- /* ------------------------------ memory disk ------------------------------- */
32
+ /** The MIME type for a path's extension, or `application/octet-stream`. */
33
+ export function contentTypeFor(path) {
34
+ return getMimeType(path) ?? "application/octet-stream";
35
+ }
36
+ /* -------------------------------- signing --------------------------------- */
37
+ /** HMAC-SHA256 hex — Web Crypto, so it works on Node and the edge. */
38
+ async function hmac(key, data) {
39
+ const enc = new TextEncoder();
40
+ const cryptoKey = await crypto.subtle.importKey("raw", enc.encode(key), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
41
+ const sig = await crypto.subtle.sign("HMAC", cryptoKey, enc.encode(data));
42
+ return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
43
+ }
44
+ function appKey() {
45
+ const key = config("app.key", "");
46
+ if (!key)
47
+ throw new Error("Signed storage URLs require config('app.key'). Set APP_KEY.");
48
+ return key;
49
+ }
50
+ function timingSafeEqual(a, b) {
51
+ if (a.length !== b.length)
52
+ return false;
53
+ let diff = 0;
54
+ for (let i = 0; i < a.length; i++)
55
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
56
+ return diff === 0;
57
+ }
58
+ /**
59
+ * The string we actually sign: path + query, never the host. A disk's `url()`
60
+ * may be absolute (a CDN) or relative (`/storage/x`), while the request that
61
+ * comes back in is always absolute — signing only the path makes both sides
62
+ * agree. It also means the same signature is valid across your own hostnames,
63
+ * which is what you want behind a CDN.
64
+ */
65
+ function canonical(url, params) {
66
+ const { pathname } = new URL(url, "http://keel.local");
67
+ const query = params.toString();
68
+ return query ? `${pathname}?${query}` : pathname;
69
+ }
70
+ /**
71
+ * Sign a URL with `config('app.key')`, adding `expires` and `signature` query
72
+ * params. Verify it with `verifyStorageUrl()`.
73
+ */
74
+ export async function signStorageUrl(url, expiresIn = 3600) {
75
+ const mark = url.indexOf("?");
76
+ const base = mark === -1 ? url : url.slice(0, mark);
77
+ // Keep any query the disk's own url() already carries — a CDN token, say.
78
+ const params = new URLSearchParams(mark === -1 ? "" : url.slice(mark + 1));
79
+ params.set("expires", String(Math.floor(Date.now() / 1000) + expiresIn));
80
+ const signature = await hmac(appKey(), canonical(base, params));
81
+ params.set("signature", signature);
82
+ return `${base}?${params}`;
83
+ }
84
+ /** Whether a URL carries a valid, unexpired signature from `signStorageUrl()`. */
85
+ export async function verifyStorageUrl(url) {
86
+ const parsed = new URL(url, "http://keel.local");
87
+ const signature = parsed.searchParams.get("signature");
88
+ if (!signature)
89
+ return false;
90
+ parsed.searchParams.delete("signature");
91
+ const expires = Number(parsed.searchParams.get("expires"));
92
+ if (!expires || expires < Math.floor(Date.now() / 1000))
93
+ return false;
94
+ const expected = await hmac(appKey(), canonical(parsed.pathname, parsed.searchParams));
95
+ return timingSafeEqual(signature, expected);
96
+ }
22
97
  /** An in-memory `Disk` — the default; ideal for tests. Not shared across processes. */
23
98
  export class MemoryDisk {
24
99
  baseUrl;
@@ -26,11 +101,21 @@ export class MemoryDisk {
26
101
  constructor(baseUrl = "/storage") {
27
102
  this.baseUrl = baseUrl;
28
103
  }
29
- async put(path, bytes) {
30
- this.files.set(path, bytes);
104
+ async put(path, bytes, options = {}) {
105
+ this.files.set(path, {
106
+ bytes,
107
+ meta: {
108
+ size: bytes.byteLength,
109
+ contentType: options.contentType ?? contentTypeFor(path),
110
+ cacheControl: options.cacheControl,
111
+ visibility: options.visibility ?? "public",
112
+ lastModified: new Date(),
113
+ metadata: options.metadata,
114
+ },
115
+ });
31
116
  }
32
117
  async get(path) {
33
- return this.files.get(path) ?? null;
118
+ return this.files.get(path)?.bytes ?? null;
34
119
  }
35
120
  async exists(path) {
36
121
  return this.files.has(path);
@@ -44,6 +129,18 @@ export class MemoryDisk {
44
129
  url(path) {
45
130
  return `${this.baseUrl}/${path}`;
46
131
  }
132
+ async metadata(path) {
133
+ return this.files.get(path)?.meta ?? null;
134
+ }
135
+ async copy(from, to) {
136
+ const entry = this.files.get(from);
137
+ if (entry)
138
+ this.files.set(to, { bytes: entry.bytes, meta: { ...entry.meta } });
139
+ }
140
+ async move(from, to) {
141
+ await this.copy(from, to);
142
+ this.files.delete(from);
143
+ }
47
144
  }
48
145
  /* -------------------------------- storage --------------------------------- */
49
146
  export class Storage {
@@ -51,9 +148,15 @@ export class Storage {
51
148
  constructor(disk) {
52
149
  this.disk = disk;
53
150
  }
54
- /** Write a file (string, bytes, or ArrayBuffer — strings are UTF-8 encoded). */
55
- put(path, contents) {
56
- return this.disk.put(path, toBytes(contents));
151
+ /**
152
+ * Write a file (string, bytes, or ArrayBuffer — strings are UTF-8 encoded).
153
+ * The content type is inferred from the extension unless you pass one.
154
+ */
155
+ put(path, contents, options = {}) {
156
+ return this.disk.put(path, toBytes(contents), {
157
+ ...options,
158
+ contentType: options.contentType ?? contentTypeFor(path),
159
+ });
57
160
  }
58
161
  /** Read a file's raw bytes, or null if it doesn't exist. */
59
162
  get(path) {
@@ -73,16 +176,118 @@ export class Storage {
73
176
  list(prefix) {
74
177
  return this.disk.list(prefix);
75
178
  }
179
+ /** Size, content type, and friends — or null if the file doesn't exist. */
180
+ async metadata(path) {
181
+ if (this.disk.metadata)
182
+ return this.disk.metadata(path);
183
+ const bytes = await this.disk.get(path);
184
+ if (bytes == null)
185
+ return null;
186
+ return { size: bytes.byteLength, contentType: contentTypeFor(path) };
187
+ }
188
+ /** A file's size in bytes, or null if it doesn't exist. */
189
+ async size(path) {
190
+ return (await this.metadata(path))?.size ?? null;
191
+ }
192
+ /** Copy a file. Server-side when the disk supports it, else read-then-write. */
193
+ async copy(from, to) {
194
+ if (this.disk.copy)
195
+ return this.disk.copy(from, to);
196
+ const bytes = await this.disk.get(from);
197
+ if (bytes == null)
198
+ throw new Error(`Cannot copy "${from}": no such file.`);
199
+ const meta = await this.metadata(from);
200
+ await this.disk.put(to, bytes, {
201
+ contentType: meta?.contentType ?? contentTypeFor(to),
202
+ cacheControl: meta?.cacheControl,
203
+ visibility: meta?.visibility,
204
+ metadata: meta?.metadata,
205
+ });
206
+ }
207
+ /** Move a file. Server-side when the disk supports it, else copy-then-delete. */
208
+ async move(from, to) {
209
+ if (this.disk.move)
210
+ return this.disk.move(from, to);
211
+ await this.copy(from, to);
212
+ await this.disk.delete(from);
213
+ }
214
+ /** A public URL for the object. Use `signedUrl()` for private ones. */
76
215
  url(path) {
77
216
  return this.disk.url(path);
78
217
  }
218
+ /**
219
+ * A temporary URL for a private object. Uses the backend's own presigning when
220
+ * the disk implements `signedUrl` (S3, R2, GCS); otherwise signs the disk's
221
+ * `url()` with `config('app.key')` — serve those with `serveStorage({ signed: true })`.
222
+ */
223
+ async signedUrl(path, options = {}) {
224
+ if (this.disk.signedUrl)
225
+ return this.disk.signedUrl(path, options);
226
+ return signStorageUrl(this.disk.url(path), options.expiresIn ?? 3600);
227
+ }
228
+ /**
229
+ * A URL the browser can `PUT` the file to directly, so the bytes never transit
230
+ * your app — the point of this on the edge, where proxying a large upload
231
+ * through a Worker is exactly what you don't want.
232
+ *
233
+ * Only the backend can accept such a write, so this requires a disk that
234
+ * implements `signedUploadUrl` (see the S3/R2 recipe in the storage guide).
235
+ */
236
+ async signedUploadUrl(path, options = {}) {
237
+ if (!this.disk.signedUploadUrl) {
238
+ throw new Error("This disk does not support signed upload URLs. Implement `signedUploadUrl` on it " +
239
+ "(the storage guide has an S3/R2 recipe) — only the storage backend can accept a direct upload.");
240
+ }
241
+ return this.disk.signedUploadUrl(path, {
242
+ ...options,
243
+ contentType: options.contentType ?? contentTypeFor(path),
244
+ });
245
+ }
79
246
  /** The underlying driver, for backend-specific operations. */
80
247
  get driver() {
81
248
  return this.disk;
82
249
  }
83
250
  }
251
+ /* --------------------------------- faking --------------------------------- */
252
+ /**
253
+ * A `Storage` backed by a `MemoryDisk`, with assertions — what `fakeDisk()`
254
+ * installs so tests never touch a real bucket.
255
+ */
256
+ export class FakeStorage extends Storage {
257
+ constructor() {
258
+ super(new MemoryDisk());
259
+ }
260
+ async assertExists(path) {
261
+ if (!(await this.exists(path))) {
262
+ throw new Error(`Expected "${path}" to exist on the fake disk, but it does not.`);
263
+ }
264
+ }
265
+ async assertMissing(path) {
266
+ if (await this.exists(path)) {
267
+ throw new Error(`Expected "${path}" to be missing from the fake disk, but it exists.`);
268
+ }
269
+ }
270
+ /** Assert a file's contents, as UTF-8 text. */
271
+ async assertContents(path, expected) {
272
+ await this.assertExists(path);
273
+ const actual = await this.getText(path);
274
+ if (actual !== expected) {
275
+ throw new Error(`Expected "${path}" to contain ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}.`);
276
+ }
277
+ }
278
+ /** Assert how many files are stored, optionally under a prefix. */
279
+ async assertCount(expected, prefix) {
280
+ const actual = (await this.list(prefix)).length;
281
+ if (actual !== expected) {
282
+ const where = prefix ? ` under "${prefix}"` : "";
283
+ throw new Error(`Expected ${expected} file(s)${where} on the fake disk, found ${actual}.`);
284
+ }
285
+ }
286
+ }
84
287
  /* -------------------------------- global ---------------------------------- */
85
288
  const disks = new Map([["default", new Storage(new MemoryDisk())]]);
289
+ /** Disks displaced by `fakeDisk()`, so `restoreDisk()` can put them back. */
290
+ const realDisks = new Map();
86
291
  /** Register a disk, optionally under a name (default: `"default"`). */
87
292
  export function setDisk(disk, name = "default") {
88
293
  const store = new Storage(disk);
@@ -96,3 +301,78 @@ export function storage(name = "default") {
96
301
  throw new Error(`No storage disk named "${name}". Register it with setDisk().`);
97
302
  return store;
98
303
  }
304
+ /**
305
+ * Swap a disk for an in-memory `FakeStorage` so tests never touch a real bucket,
306
+ * and assert against what was written. Undo with `restoreDisk()`.
307
+ *
308
+ * const disk = fakeDisk();
309
+ * await request.post("/avatars", form);
310
+ * await disk.assertExists("avatars/1.png");
311
+ */
312
+ export function fakeDisk(name = "default") {
313
+ const existing = disks.get(name);
314
+ // Only remember the *real* disk — faking twice must not stash a fake.
315
+ if (existing && !realDisks.has(name))
316
+ realDisks.set(name, existing);
317
+ const fake = new FakeStorage();
318
+ disks.set(name, fake);
319
+ return fake;
320
+ }
321
+ /** Restore the real disk after `fakeDisk()`. With no name, restores them all. */
322
+ export function restoreDisk(name) {
323
+ const names = name ? [name] : [...realDisks.keys()];
324
+ for (const key of names) {
325
+ const real = realDisks.get(key);
326
+ if (real)
327
+ disks.set(key, real);
328
+ realDisks.delete(key);
329
+ }
330
+ }
331
+ /**
332
+ * Serve files from a disk over HTTP — what makes the fallback `signedUrl()` real
333
+ * for disks without backend presigning (the memory disk, a local-filesystem
334
+ * disk). Requests that don't match `basePath`, or that name a file the disk
335
+ * doesn't have, fall through to your routes.
336
+ *
337
+ * this.use(serveStorage()); // public files
338
+ * this.use(serveStorage({ basePath: "/private", signed: true }));
339
+ */
340
+ export function serveStorage(options = {}) {
341
+ const basePath = (options.basePath ?? "/storage").replace(/\/+$/, "");
342
+ return async (c, next) => {
343
+ if (c.req.method !== "GET" && c.req.method !== "HEAD")
344
+ return next();
345
+ const url = new URL(c.req.url);
346
+ if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`))
347
+ return next();
348
+ const path = decodeURIComponent(url.pathname.slice(basePath.length + 1));
349
+ if (!path || path.includes(".."))
350
+ return next(); // path traversal guard
351
+ if (options.signed && !(await verifyStorageUrl(c.req.url))) {
352
+ return c.text("Forbidden", 403);
353
+ }
354
+ const store = storage(options.disk);
355
+ const bytes = await store.get(path);
356
+ if (bytes == null)
357
+ return next();
358
+ const meta = await store.metadata(path);
359
+ const etag = `W/"${meta?.size ?? bytes.byteLength}-${meta?.lastModified?.getTime() ?? 0}"`;
360
+ c.header("Content-Type", meta?.contentType ?? contentTypeFor(path));
361
+ c.header("ETag", etag);
362
+ if (meta?.lastModified)
363
+ c.header("Last-Modified", meta.lastModified.toUTCString());
364
+ const cacheControl = meta?.cacheControl ??
365
+ (options.maxAge != null
366
+ ? `${options.signed ? "private" : "public"}, max-age=${options.maxAge}`
367
+ : undefined);
368
+ if (cacheControl)
369
+ c.header("Cache-Control", cacheControl);
370
+ if (c.req.header("If-None-Match") === etag)
371
+ return c.body(null, 304);
372
+ if (c.req.method === "HEAD")
373
+ return c.body(null, 200);
374
+ // Copy out of the view's backing buffer — `bytes.buffer` alone may be larger.
375
+ const body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
376
+ return c.body(body, 200);
377
+ };
378
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Personal access tokens — opaque, database-backed bearer tokens for API and
3
+ * mobile clients, an alternative to the stateless [`jwt`](./crypto.ts). Unlike a
4
+ * JWT, an opaque token can be *revoked* instantly (it's a row you delete), carries
5
+ * *abilities* (scopes), and tracks *last used* — at the cost of a lookup per
6
+ * request. Built on the `db()` layer, so it runs anywhere a `Connection` does.
7
+ *
8
+ * const { token } = await createToken(user.id, { abilities: ["posts:write"] });
9
+ * // → "keel_<selector>.<verifier>" — show once, never recoverable
10
+ *
11
+ * const record = await verifyToken(token); // { tokenableId, abilities, … } | null
12
+ * tokenAllows(record, "posts:write"); // true
13
+ *
14
+ * Pair it with `tokenAuth()` in ./auth.ts to protect routes. The token splits
15
+ * into a public *selector* (indexed, for lookup) and a secret *verifier* (stored
16
+ * only as a SHA-256 hash) — so a leaked database can't mint working tokens, and
17
+ * verification needs no `RETURNING`/auto-increment (portable across every driver).
18
+ *
19
+ * Expected table (`personal_access_tokens` by default), all timestamps epoch-ms:
20
+ * selector TEXT UNIQUE, hash TEXT, tokenable_id TEXT, name TEXT,
21
+ * abilities TEXT (JSON), last_used_at INTEGER, expires_at INTEGER, created_at INTEGER
22
+ */
23
+ /** A verified token, as returned by `verifyToken`. */
24
+ export interface AccessToken {
25
+ /** Public lookup key (the part before the `.`). Pass to `revokeToken`. */
26
+ selector: string;
27
+ /** The id of the entity the token belongs to (usually a user id). */
28
+ tokenableId: string;
29
+ /** Optional human label, for a "your tokens" management screen. */
30
+ name: string | null;
31
+ /** Granted abilities/scopes; `["*"]` means all. */
32
+ abilities: string[];
33
+ /** Epoch-ms of last successful use, or null if never (updated on verify). */
34
+ lastUsedAt: number | null;
35
+ /** Epoch-ms expiry, or null for a token that never expires. */
36
+ expiresAt: number | null;
37
+ }
38
+ export interface CreateTokenOptions {
39
+ /** Abilities/scopes to grant. Defaults to `["*"]` (everything). */
40
+ abilities?: string[];
41
+ /** Lifetime — seconds (number) or a duration string (`"30d"`, `"12h"`). No expiry if omitted. */
42
+ expiresIn?: number | string;
43
+ /** A human label for the token. */
44
+ name?: string;
45
+ /** Which registered connection to store the token on. */
46
+ connection?: string;
47
+ }
48
+ export interface IssuedToken {
49
+ /** The plaintext token — `keel_<selector>.<verifier>`. Shown once; store the hash only. */
50
+ token: string;
51
+ selector: string;
52
+ abilities: string[];
53
+ expiresAt: number | null;
54
+ }
55
+ /** Change the table personal access tokens are stored in (default `personal_access_tokens`). */
56
+ export declare function setTokensTable(name: string): void;
57
+ /** Mint a new access token for an entity. Returns the plaintext once — persist nothing but the hash. */
58
+ export declare function createToken(tokenableId: string | number, options?: CreateTokenOptions): Promise<IssuedToken>;
59
+ /**
60
+ * Verify a plaintext token and return its record, or `null` if it's malformed,
61
+ * unknown, tampered, or expired. On success it stamps `last_used_at`. An expired
62
+ * token is deleted in passing, so the table self-prunes as stale tokens are tried.
63
+ */
64
+ export declare function verifyToken(token: string, connection?: string): Promise<AccessToken | null>;
65
+ /** Whether a verified token grants an ability (`["*"]` grants everything). */
66
+ export declare function tokenAllows(token: AccessToken | null | undefined, ability: string): boolean;
67
+ /** The negation of `tokenAllows`. */
68
+ export declare function tokenDenies(token: AccessToken | null | undefined, ability: string): boolean;
69
+ /** Revoke a single token by its selector (the part before the `.`). */
70
+ export declare function revokeToken(selector: string, connection?: string): Promise<void>;
71
+ /** Revoke every token belonging to an entity — a "log out everywhere" switch. */
72
+ export declare function revokeTokens(tokenableId: string | number, connection?: string): Promise<void>;
73
+ /** List an entity's tokens (metadata only — the secret is never stored). */
74
+ export declare function listTokens(tokenableId: string | number, connection?: string): Promise<AccessToken[]>;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Personal access tokens — opaque, database-backed bearer tokens for API and
3
+ * mobile clients, an alternative to the stateless [`jwt`](./crypto.ts). Unlike a
4
+ * JWT, an opaque token can be *revoked* instantly (it's a row you delete), carries
5
+ * *abilities* (scopes), and tracks *last used* — at the cost of a lookup per
6
+ * request. Built on the `db()` layer, so it runs anywhere a `Connection` does.
7
+ *
8
+ * const { token } = await createToken(user.id, { abilities: ["posts:write"] });
9
+ * // → "keel_<selector>.<verifier>" — show once, never recoverable
10
+ *
11
+ * const record = await verifyToken(token); // { tokenableId, abilities, … } | null
12
+ * tokenAllows(record, "posts:write"); // true
13
+ *
14
+ * Pair it with `tokenAuth()` in ./auth.ts to protect routes. The token splits
15
+ * into a public *selector* (indexed, for lookup) and a secret *verifier* (stored
16
+ * only as a SHA-256 hash) — so a leaked database can't mint working tokens, and
17
+ * verification needs no `RETURNING`/auto-increment (portable across every driver).
18
+ *
19
+ * Expected table (`personal_access_tokens` by default), all timestamps epoch-ms:
20
+ * selector TEXT UNIQUE, hash TEXT, tokenable_id TEXT, name TEXT,
21
+ * abilities TEXT (JSON), last_used_at INTEGER, expires_at INTEGER, created_at INTEGER
22
+ */
23
+ import { db } from "./database.js";
24
+ /** The table tokens are stored in; override with `setTokensTable`. */
25
+ let table = "personal_access_tokens";
26
+ /** Change the table personal access tokens are stored in (default `personal_access_tokens`). */
27
+ export function setTokensTable(name) {
28
+ table = name;
29
+ }
30
+ /* -------------------------------- crypto -------------------------------- */
31
+ const DURATION = /^(\d+)\s*(s|m|h|d)$/;
32
+ const UNIT = { s: 1, m: 60, h: 3600, d: 86400 };
33
+ function seconds(value) {
34
+ if (typeof value === "number")
35
+ return value;
36
+ const match = DURATION.exec(value.trim());
37
+ if (!match)
38
+ throw new Error(`Invalid duration "${value}" (use e.g. 3600, "30m", "12h", "30d").`);
39
+ return Number(match[1]) * UNIT[match[2]];
40
+ }
41
+ function base64url(bytes) {
42
+ let s = "";
43
+ for (const b of bytes)
44
+ s += String.fromCharCode(b);
45
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
46
+ }
47
+ function randomToken(size) {
48
+ return base64url(crypto.getRandomValues(new Uint8Array(size)));
49
+ }
50
+ /** SHA-256 of the verifier, base64url — what we actually persist. */
51
+ async function sha256(value) {
52
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
53
+ return base64url(new Uint8Array(digest));
54
+ }
55
+ /** Constant-time string compare, so a bad hash can't be timed byte-by-byte. */
56
+ function safeEqual(a, b) {
57
+ if (a.length !== b.length)
58
+ return false;
59
+ let result = 0;
60
+ for (let i = 0; i < a.length; i++)
61
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
62
+ return result === 0;
63
+ }
64
+ /* ------------------------------- lifecycle ------------------------------ */
65
+ /** Mint a new access token for an entity. Returns the plaintext once — persist nothing but the hash. */
66
+ export async function createToken(tokenableId, options = {}) {
67
+ const selector = randomToken(12);
68
+ const verifier = randomToken(24);
69
+ const abilities = options.abilities ?? ["*"];
70
+ const expiresAt = options.expiresIn != null ? Date.now() + seconds(options.expiresIn) * 1000 : null;
71
+ await db(table, options.connection).insert({
72
+ selector,
73
+ hash: await sha256(verifier),
74
+ tokenable_id: String(tokenableId),
75
+ name: options.name ?? null,
76
+ abilities: JSON.stringify(abilities),
77
+ last_used_at: null,
78
+ expires_at: expiresAt,
79
+ created_at: Date.now(),
80
+ });
81
+ return { token: `keel_${selector}.${verifier}`, selector, abilities, expiresAt };
82
+ }
83
+ /**
84
+ * Verify a plaintext token and return its record, or `null` if it's malformed,
85
+ * unknown, tampered, or expired. On success it stamps `last_used_at`. An expired
86
+ * token is deleted in passing, so the table self-prunes as stale tokens are tried.
87
+ */
88
+ export async function verifyToken(token, connection) {
89
+ const match = /^keel_([^.]+)\.(.+)$/.exec(token);
90
+ if (!match)
91
+ return null;
92
+ const selector = match[1];
93
+ const verifier = match[2];
94
+ const row = await db(table, connection).where("selector", selector).first();
95
+ if (!row)
96
+ return null;
97
+ if (!safeEqual(await sha256(verifier), String(row.hash)))
98
+ return null;
99
+ const expiresAt = row.expires_at != null ? Number(row.expires_at) : null;
100
+ if (expiresAt != null && Date.now() >= expiresAt) {
101
+ await db(table, connection).where("selector", selector).delete();
102
+ return null;
103
+ }
104
+ const now = Date.now();
105
+ await db(table, connection).where("selector", selector).update({ last_used_at: now });
106
+ return {
107
+ selector,
108
+ tokenableId: String(row.tokenable_id),
109
+ name: row.name ?? null,
110
+ abilities: parseAbilities(row.abilities),
111
+ lastUsedAt: now,
112
+ expiresAt,
113
+ };
114
+ }
115
+ /** Whether a verified token grants an ability (`["*"]` grants everything). */
116
+ export function tokenAllows(token, ability) {
117
+ if (!token)
118
+ return false;
119
+ return token.abilities.includes("*") || token.abilities.includes(ability);
120
+ }
121
+ /** The negation of `tokenAllows`. */
122
+ export function tokenDenies(token, ability) {
123
+ return !tokenAllows(token, ability);
124
+ }
125
+ /** Revoke a single token by its selector (the part before the `.`). */
126
+ export async function revokeToken(selector, connection) {
127
+ await db(table, connection).where("selector", selector).delete();
128
+ }
129
+ /** Revoke every token belonging to an entity — a "log out everywhere" switch. */
130
+ export async function revokeTokens(tokenableId, connection) {
131
+ await db(table, connection).where("tokenable_id", String(tokenableId)).delete();
132
+ }
133
+ /** List an entity's tokens (metadata only — the secret is never stored). */
134
+ export async function listTokens(tokenableId, connection) {
135
+ const rows = await db(table, connection).where("tokenable_id", String(tokenableId)).get();
136
+ return rows.map((row) => ({
137
+ selector: String(row.selector),
138
+ tokenableId: String(row.tokenable_id),
139
+ name: row.name ?? null,
140
+ abilities: parseAbilities(row.abilities),
141
+ lastUsedAt: row.last_used_at != null ? Number(row.last_used_at) : null,
142
+ expiresAt: row.expires_at != null ? Number(row.expires_at) : null,
143
+ }));
144
+ }
145
+ function parseAbilities(value) {
146
+ if (typeof value !== "string")
147
+ return Array.isArray(value) ? value : ["*"];
148
+ try {
149
+ const parsed = JSON.parse(value);
150
+ return Array.isArray(parsed) ? parsed : ["*"];
151
+ }
152
+ catch {
153
+ return ["*"];
154
+ }
155
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * A Keel `Connection` for Cloudflare D1. Pass your D1 binding (from `env.DB`)
3
+ * and register it — dialect `"sqlite"`:
4
+ *
5
+ * import { d1Connection } from "@shaferllc/keel/db/d1";
6
+ * import { setConnection } from "@shaferllc/keel/core";
7
+ *
8
+ * setConnection(d1Connection(env.DB), "sqlite");
9
+ *
10
+ * The binding is duck-typed — this module imports no driver and no
11
+ * `@cloudflare/workers-types`, so it stays edge-native and dependency-free. Any
12
+ * object shaped like a D1 database works.
13
+ */
14
+ import type { Connection, Row } from "../core/database.js";
15
+ /** The slice of the D1 `Database` API this adapter uses. */
16
+ export interface D1Like {
17
+ prepare(sql: string): {
18
+ bind(...values: unknown[]): {
19
+ all<T = Row>(): Promise<{
20
+ results?: T[];
21
+ }>;
22
+ run(): Promise<{
23
+ meta: {
24
+ changes?: number;
25
+ last_row_id?: number;
26
+ };
27
+ }>;
28
+ };
29
+ };
30
+ }
31
+ /** Build a `Connection` backed by a Cloudflare D1 binding. */
32
+ export declare function d1Connection(database: D1Like): Connection;
package/dist/db/d1.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * A Keel `Connection` for Cloudflare D1. Pass your D1 binding (from `env.DB`)
3
+ * and register it — dialect `"sqlite"`:
4
+ *
5
+ * import { d1Connection } from "@shaferllc/keel/db/d1";
6
+ * import { setConnection } from "@shaferllc/keel/core";
7
+ *
8
+ * setConnection(d1Connection(env.DB), "sqlite");
9
+ *
10
+ * The binding is duck-typed — this module imports no driver and no
11
+ * `@cloudflare/workers-types`, so it stays edge-native and dependency-free. Any
12
+ * object shaped like a D1 database works.
13
+ */
14
+ /** Build a `Connection` backed by a Cloudflare D1 binding. */
15
+ export function d1Connection(database) {
16
+ return {
17
+ async select(sql, bindings) {
18
+ const { results } = await database.prepare(sql).bind(...bindings).all();
19
+ return results ?? [];
20
+ },
21
+ async write(sql, bindings) {
22
+ const { meta } = await database.prepare(sql).bind(...bindings).run();
23
+ return { rowsAffected: meta.changes ?? 0, insertId: meta.last_row_id };
24
+ },
25
+ };
26
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * A Keel `Connection` for libSQL / Turso. Pass an `@libsql/client` client (it
3
+ * runs on Node and the edge — Turso speaks HTTP) and register it with dialect
4
+ * `"sqlite"`:
5
+ *
6
+ * import { libsqlConnection } from "@shaferllc/keel/db/libsql";
7
+ * import { setConnection } from "@shaferllc/keel/core";
8
+ * import { createClient } from "@libsql/client";
9
+ *
10
+ * const client = createClient({ url: env.TURSO_URL, authToken: env.TURSO_TOKEN });
11
+ * setConnection(libsqlConnection(client), "sqlite");
12
+ *
13
+ * The client is duck-typed — this module imports no driver, so it never bundles
14
+ * `@libsql/client`.
15
+ */
16
+ import type { Connection, Row } from "../core/database.js";
17
+ /** The slice of the `@libsql/client` API this adapter uses. */
18
+ export interface LibSqlLike {
19
+ execute(stmt: {
20
+ sql: string;
21
+ args: unknown[];
22
+ }): Promise<{
23
+ rows: Row[];
24
+ rowsAffected: number;
25
+ lastInsertRowid?: bigint | number;
26
+ }>;
27
+ }
28
+ /** Build a `Connection` backed by an `@libsql/client` client. */
29
+ export declare function libsqlConnection(client: LibSqlLike): Connection;