@shaferllc/keel 0.66.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 (132) 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 +2 -2
  6. package/dist/core/cache.d.ts +82 -5
  7. package/dist/core/cache.js +181 -23
  8. package/dist/core/cli/stubs.d.ts +12 -0
  9. package/dist/core/cli/stubs.js +120 -0
  10. package/dist/core/events.d.ts +129 -5
  11. package/dist/core/events.js +165 -7
  12. package/dist/core/health.d.ts +141 -0
  13. package/dist/core/health.js +226 -0
  14. package/dist/core/helpers.d.ts +9 -3
  15. package/dist/core/helpers.js +11 -3
  16. package/dist/core/index.d.ts +15 -10
  17. package/dist/core/index.js +7 -4
  18. package/dist/core/lock.d.ts +139 -0
  19. package/dist/core/lock.js +215 -0
  20. package/dist/core/logger.d.ts +82 -4
  21. package/dist/core/logger.js +141 -23
  22. package/dist/core/mail.d.ts +128 -7
  23. package/dist/core/mail.js +264 -16
  24. package/dist/core/queue.d.ts +134 -9
  25. package/dist/core/queue.js +304 -14
  26. package/dist/core/storage.d.ts +159 -6
  27. package/dist/core/storage.js +287 -7
  28. package/dist/mcp/server.d.ts +19 -0
  29. package/dist/mcp/server.js +355 -0
  30. package/docs/ai-manifest.json +2472 -0
  31. package/docs/ai.md +128 -0
  32. package/docs/architecture.md +331 -0
  33. package/docs/authentication.md +453 -0
  34. package/docs/authorization.md +167 -0
  35. package/docs/broadcasting.md +137 -0
  36. package/docs/broker.md +500 -0
  37. package/docs/cache.md +558 -0
  38. package/docs/configuration.md +311 -0
  39. package/docs/console.md +356 -0
  40. package/docs/container.md +467 -0
  41. package/docs/controllers.md +265 -0
  42. package/docs/cors.md +51 -0
  43. package/docs/database.md +530 -0
  44. package/docs/debugging.md +129 -0
  45. package/docs/decorators.md +127 -0
  46. package/docs/errors.md +395 -0
  47. package/docs/events.md +496 -0
  48. package/docs/examples/architecture-app.ts +27 -0
  49. package/docs/examples/authentication.ts +61 -0
  50. package/docs/examples/authorization.ts +79 -0
  51. package/docs/examples/broadcasting.ts +60 -0
  52. package/docs/examples/broker-cache-validate.ts +34 -0
  53. package/docs/examples/broker-fault-tolerance.ts +29 -0
  54. package/docs/examples/broker-middleware.ts +27 -0
  55. package/docs/examples/broker.ts +203 -0
  56. package/docs/examples/cache.ts +222 -0
  57. package/docs/examples/configuration.ts +81 -0
  58. package/docs/examples/container.ts +134 -0
  59. package/docs/examples/controllers.ts +86 -0
  60. package/docs/examples/database.ts +118 -0
  61. package/docs/examples/debugging.ts +41 -0
  62. package/docs/examples/decorators.ts +40 -0
  63. package/docs/examples/errors.ts +121 -0
  64. package/docs/examples/events.ts +204 -0
  65. package/docs/examples/factories.ts +84 -0
  66. package/docs/examples/hashing.ts +71 -0
  67. package/docs/examples/health.ts +94 -0
  68. package/docs/examples/helpers.ts +171 -0
  69. package/docs/examples/hooks.ts +54 -0
  70. package/docs/examples/inertia.ts +81 -0
  71. package/docs/examples/locks.ts +120 -0
  72. package/docs/examples/logger.ts +92 -0
  73. package/docs/examples/mail.ts +160 -0
  74. package/docs/examples/middleware.ts +119 -0
  75. package/docs/examples/migrations.ts +126 -0
  76. package/docs/examples/models.ts +239 -0
  77. package/docs/examples/notification.ts +124 -0
  78. package/docs/examples/providers.ts +123 -0
  79. package/docs/examples/queues.ts +254 -0
  80. package/docs/examples/rate-limiting.ts +42 -0
  81. package/docs/examples/redis.ts +99 -0
  82. package/docs/examples/request-response.ts +197 -0
  83. package/docs/examples/routing.ts +186 -0
  84. package/docs/examples/scheduling.ts +62 -0
  85. package/docs/examples/sessions.ts +102 -0
  86. package/docs/examples/static-files.ts +63 -0
  87. package/docs/examples/storage.ts +132 -0
  88. package/docs/examples/templates.ts +58 -0
  89. package/docs/examples/testing.ts +66 -0
  90. package/docs/examples/transformer.ts +141 -0
  91. package/docs/examples/transformers.ts +49 -0
  92. package/docs/examples/url-builder.ts +86 -0
  93. package/docs/examples/validation.ts +102 -0
  94. package/docs/examples/views.tsx +62 -0
  95. package/docs/examples/vite.ts +106 -0
  96. package/docs/factories.md +166 -0
  97. package/docs/getting-started.md +290 -0
  98. package/docs/hashing.md +259 -0
  99. package/docs/health.md +225 -0
  100. package/docs/helpers.md +347 -0
  101. package/docs/hono.md +186 -0
  102. package/docs/hooks.md +118 -0
  103. package/docs/inertia.md +241 -0
  104. package/docs/locks.md +323 -0
  105. package/docs/logger.md +290 -0
  106. package/docs/mail.md +678 -0
  107. package/docs/middleware.md +425 -0
  108. package/docs/migrations.md +476 -0
  109. package/docs/models.md +810 -0
  110. package/docs/notifications.md +474 -0
  111. package/docs/providers.md +363 -0
  112. package/docs/queues.md +679 -0
  113. package/docs/rate-limiting.md +155 -0
  114. package/docs/redis.md +178 -0
  115. package/docs/request-response.md +953 -0
  116. package/docs/routing.md +804 -0
  117. package/docs/scheduling.md +110 -0
  118. package/docs/security.md +85 -0
  119. package/docs/sessions.md +354 -0
  120. package/docs/social-auth.md +174 -0
  121. package/docs/static-files.md +211 -0
  122. package/docs/storage.md +450 -0
  123. package/docs/templates.md +315 -0
  124. package/docs/testing.md +125 -0
  125. package/docs/transformers.md +381 -0
  126. package/docs/url-builder.md +295 -0
  127. package/docs/validation.md +288 -0
  128. package/docs/views.md +267 -0
  129. package/docs/vite.md +434 -0
  130. package/llms-full.txt +17694 -0
  131. package/llms.txt +116 -0
  132. package/package.json +26 -7
@@ -9,37 +9,119 @@
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
  */
14
- /** The bridge to a storage backend — implement it once per backend. */
23
+ import type { MiddlewareHandler } from "hono";
24
+ /** Whether an object is world-readable or needs a signed URL. */
25
+ export type FileVisibility = "public" | "private";
26
+ /** Per-write options. Disks map these onto their backend's own metadata. */
27
+ export interface WriteOptions {
28
+ /**
29
+ * The object's MIME type. Inferred from the path's extension when omitted, so
30
+ * a `.png` is stored as `image/png` rather than `application/octet-stream`.
31
+ */
32
+ contentType?: string;
33
+ /** A `Cache-Control` value to store alongside the object. */
34
+ cacheControl?: string;
35
+ /** `"public"` or `"private"`. Disks that can't express this ignore it. */
36
+ visibility?: FileVisibility;
37
+ /** Arbitrary user metadata stored with the object. */
38
+ metadata?: Record<string, string>;
39
+ }
40
+ /** What a disk knows about a stored object. */
41
+ export interface FileMetadata {
42
+ size: number;
43
+ contentType?: string;
44
+ cacheControl?: string;
45
+ visibility?: FileVisibility;
46
+ lastModified?: Date;
47
+ metadata?: Record<string, string>;
48
+ }
49
+ export interface SignedFileOptions {
50
+ /** Seconds the URL stays valid. Default: 3600 (one hour). */
51
+ expiresIn?: number;
52
+ }
53
+ export interface SignedUploadOptions extends SignedFileOptions {
54
+ /** The `Content-Type` the uploader must send. */
55
+ contentType?: string;
56
+ }
57
+ /**
58
+ * The bridge to a storage backend — implement it once per backend.
59
+ *
60
+ * The first six methods are required. The rest are optional capabilities: a disk
61
+ * that can do better than the generic implementation (native `HEAD`, a
62
+ * server-side copy, backend presigning) implements them, and `Storage` uses them
63
+ * when present.
64
+ */
15
65
  export interface Disk {
16
- put(path: string, bytes: Uint8Array): Promise<void>;
66
+ put(path: string, bytes: Uint8Array, options?: WriteOptions): Promise<void>;
17
67
  get(path: string): Promise<Uint8Array | null>;
18
68
  exists(path: string): Promise<boolean>;
19
69
  delete(path: string): Promise<void>;
20
70
  /** Paths currently stored, optionally filtered to those under `prefix`. */
21
71
  list(prefix?: string): Promise<string[]>;
22
- /** A URL for the stored object (public or signed — the disk decides). */
72
+ /** A public URL for the stored object. */
23
73
  url(path: string): string;
74
+ /** Size, content type, and friends. `Storage.metadata()` falls back to a read. */
75
+ metadata?(path: string): Promise<FileMetadata | null>;
76
+ /** A server-side copy. `Storage.copy()` falls back to read-then-write. */
77
+ copy?(from: string, to: string): Promise<void>;
78
+ /** A server-side move. `Storage.move()` falls back to copy-then-delete. */
79
+ move?(from: string, to: string): Promise<void>;
80
+ /**
81
+ * A backend-presigned read URL (S3/R2 SigV4, GCS, …). When a disk doesn't
82
+ * implement this, `Storage.signedUrl()` signs the disk's own `url()` with
83
+ * `config('app.key')` — serve it with `serveStorage({ signed: true })`.
84
+ */
85
+ signedUrl?(path: string, options?: SignedFileOptions): Promise<string>;
86
+ /**
87
+ * A backend-presigned upload URL, so a browser can `PUT` straight to the
88
+ * bucket. There is no generic fallback — only the backend can accept the write.
89
+ */
90
+ signedUploadUrl?(path: string, options?: SignedUploadOptions): Promise<string>;
24
91
  }
25
92
  export type Contents = string | Uint8Array | ArrayBuffer;
93
+ /** The MIME type for a path's extension, or `application/octet-stream`. */
94
+ export declare function contentTypeFor(path: string): string;
95
+ /**
96
+ * Sign a URL with `config('app.key')`, adding `expires` and `signature` query
97
+ * params. Verify it with `verifyStorageUrl()`.
98
+ */
99
+ export declare function signStorageUrl(url: string, expiresIn?: number): Promise<string>;
100
+ /** Whether a URL carries a valid, unexpired signature from `signStorageUrl()`. */
101
+ export declare function verifyStorageUrl(url: string): Promise<boolean>;
26
102
  /** An in-memory `Disk` — the default; ideal for tests. Not shared across processes. */
27
103
  export declare class MemoryDisk implements Disk {
28
104
  private baseUrl;
29
105
  private files;
30
106
  constructor(baseUrl?: string);
31
- put(path: string, bytes: Uint8Array): Promise<void>;
107
+ put(path: string, bytes: Uint8Array, options?: WriteOptions): Promise<void>;
32
108
  get(path: string): Promise<Uint8Array | null>;
33
109
  exists(path: string): Promise<boolean>;
34
110
  delete(path: string): Promise<void>;
35
111
  list(prefix?: string): Promise<string[]>;
36
112
  url(path: string): string;
113
+ metadata(path: string): Promise<FileMetadata | null>;
114
+ copy(from: string, to: string): Promise<void>;
115
+ move(from: string, to: string): Promise<void>;
37
116
  }
38
117
  export declare class Storage {
39
118
  private disk;
40
119
  constructor(disk: Disk);
41
- /** Write a file (string, bytes, or ArrayBuffer — strings are UTF-8 encoded). */
42
- put(path: string, contents: Contents): Promise<void>;
120
+ /**
121
+ * Write a file (string, bytes, or ArrayBuffer — strings are UTF-8 encoded).
122
+ * The content type is inferred from the extension unless you pass one.
123
+ */
124
+ put(path: string, contents: Contents, options?: WriteOptions): Promise<void>;
43
125
  /** Read a file's raw bytes, or null if it doesn't exist. */
44
126
  get(path: string): Promise<Uint8Array | null>;
45
127
  /** Read a file as UTF-8 text, or null if it doesn't exist. */
@@ -47,11 +129,82 @@ export declare class Storage {
47
129
  exists(path: string): Promise<boolean>;
48
130
  delete(path: string): Promise<void>;
49
131
  list(prefix?: string): Promise<string[]>;
132
+ /** Size, content type, and friends — or null if the file doesn't exist. */
133
+ metadata(path: string): Promise<FileMetadata | null>;
134
+ /** A file's size in bytes, or null if it doesn't exist. */
135
+ size(path: string): Promise<number | null>;
136
+ /** Copy a file. Server-side when the disk supports it, else read-then-write. */
137
+ copy(from: string, to: string): Promise<void>;
138
+ /** Move a file. Server-side when the disk supports it, else copy-then-delete. */
139
+ move(from: string, to: string): Promise<void>;
140
+ /** A public URL for the object. Use `signedUrl()` for private ones. */
50
141
  url(path: string): string;
142
+ /**
143
+ * A temporary URL for a private object. Uses the backend's own presigning when
144
+ * the disk implements `signedUrl` (S3, R2, GCS); otherwise signs the disk's
145
+ * `url()` with `config('app.key')` — serve those with `serveStorage({ signed: true })`.
146
+ */
147
+ signedUrl(path: string, options?: SignedFileOptions): Promise<string>;
148
+ /**
149
+ * A URL the browser can `PUT` the file to directly, so the bytes never transit
150
+ * your app — the point of this on the edge, where proxying a large upload
151
+ * through a Worker is exactly what you don't want.
152
+ *
153
+ * Only the backend can accept such a write, so this requires a disk that
154
+ * implements `signedUploadUrl` (see the S3/R2 recipe in the storage guide).
155
+ */
156
+ signedUploadUrl(path: string, options?: SignedUploadOptions): Promise<string>;
51
157
  /** The underlying driver, for backend-specific operations. */
52
158
  get driver(): Disk;
53
159
  }
160
+ /**
161
+ * A `Storage` backed by a `MemoryDisk`, with assertions — what `fakeDisk()`
162
+ * installs so tests never touch a real bucket.
163
+ */
164
+ export declare class FakeStorage extends Storage {
165
+ constructor();
166
+ assertExists(path: string): Promise<void>;
167
+ assertMissing(path: string): Promise<void>;
168
+ /** Assert a file's contents, as UTF-8 text. */
169
+ assertContents(path: string, expected: string): Promise<void>;
170
+ /** Assert how many files are stored, optionally under a prefix. */
171
+ assertCount(expected: number, prefix?: string): Promise<void>;
172
+ }
54
173
  /** Register a disk, optionally under a name (default: `"default"`). */
55
174
  export declare function setDisk(disk: Disk, name?: string): Storage;
56
175
  /** The default disk, or a named one registered with `setDisk(disk, name)`. */
57
176
  export declare function storage(name?: string): Storage;
177
+ /**
178
+ * Swap a disk for an in-memory `FakeStorage` so tests never touch a real bucket,
179
+ * and assert against what was written. Undo with `restoreDisk()`.
180
+ *
181
+ * const disk = fakeDisk();
182
+ * await request.post("/avatars", form);
183
+ * await disk.assertExists("avatars/1.png");
184
+ */
185
+ export declare function fakeDisk(name?: string): FakeStorage;
186
+ /** Restore the real disk after `fakeDisk()`. With no name, restores them all. */
187
+ export declare function restoreDisk(name?: string): void;
188
+ export interface ServeStorageOptions {
189
+ /** Which registered disk to serve from. Default: `"default"`. */
190
+ disk?: string;
191
+ /** URL prefix the files live under. Default: `"/storage"`. */
192
+ basePath?: string;
193
+ /**
194
+ * Require a valid signature from `signedUrl()` — the private-file mode.
195
+ * Unsigned or expired requests get a 403. Default: false.
196
+ */
197
+ signed?: boolean;
198
+ /** `Cache-Control` max-age in seconds. Omit for no header. */
199
+ maxAge?: number;
200
+ }
201
+ /**
202
+ * Serve files from a disk over HTTP — what makes the fallback `signedUrl()` real
203
+ * for disks without backend presigning (the memory disk, a local-filesystem
204
+ * disk). Requests that don't match `basePath`, or that name a file the disk
205
+ * doesn't have, fall through to your routes.
206
+ *
207
+ * this.use(serveStorage()); // public files
208
+ * this.use(serveStorage({ basePath: "/private", signed: true }));
209
+ */
210
+ export declare function serveStorage(options?: ServeStorageOptions): MiddlewareHandler;
@@ -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,19 @@
1
+ /**
2
+ * The Keel MCP server. Exposes Keel's documentation, public API surface, and
3
+ * code generators to AI agents (Claude Code, Cursor, …) over stdio, so an agent
4
+ * editing a Keel app can look things up and scaffold code without guessing.
5
+ *
6
+ * Run it with `keel mcp` (dev) or the shipped `keel-mcp` bin (consumers).
7
+ * Tools:
8
+ * keel_overview framework facts, conventions, folder layout
9
+ * keel_search_docs full-text search across the guides
10
+ * keel_read_doc a full guide (optionally with its runnable example)
11
+ * keel_search_api search the public export surface
12
+ * keel_list_generators the `keel make:*` generators
13
+ * keel_scaffold generate a controller/provider/job/… stub (no write)
14
+ * Resources: keel://overview, keel://llms-full, and keel://docs/<slug> per guide.
15
+ */
16
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
+ export declare function createServer(): Promise<McpServer>;
18
+ /** Boot the server on stdio. Called by the `keel-mcp` bin and `keel mcp`. */
19
+ export declare function runMcpServer(): Promise<void>;