@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.
- package/AGENTS.md +167 -0
- package/README.md +30 -1
- package/bin/keel-mcp.mjs +9 -0
- package/dist/core/application.d.ts +5 -5
- package/dist/core/application.js +14 -2
- package/dist/core/auth.d.ts +47 -0
- package/dist/core/auth.js +77 -0
- package/dist/core/authorization.d.ts +9 -1
- package/dist/core/authorization.js +22 -2
- package/dist/core/cache.d.ts +82 -5
- package/dist/core/cache.js +181 -23
- package/dist/core/cli/stubs.d.ts +12 -0
- package/dist/core/cli/stubs.js +120 -0
- package/dist/core/container.d.ts +20 -0
- package/dist/core/container.js +52 -0
- package/dist/core/cors.d.ts +29 -0
- package/dist/core/cors.js +72 -0
- package/dist/core/crypto.d.ts +40 -4
- package/dist/core/crypto.js +66 -6
- package/dist/core/csrf.d.ts +25 -0
- package/dist/core/csrf.js +78 -0
- package/dist/core/database.d.ts +49 -4
- package/dist/core/database.js +89 -21
- package/dist/core/events.d.ts +129 -5
- package/dist/core/events.js +165 -7
- package/dist/core/health.d.ts +141 -0
- package/dist/core/health.js +226 -0
- package/dist/core/helpers.d.ts +15 -3
- package/dist/core/helpers.js +23 -3
- package/dist/core/index.d.ts +33 -18
- package/dist/core/index.js +16 -8
- package/dist/core/lock.d.ts +139 -0
- package/dist/core/lock.js +215 -0
- package/dist/core/logger.d.ts +82 -4
- package/dist/core/logger.js +141 -23
- package/dist/core/mail.d.ts +128 -7
- package/dist/core/mail.js +264 -16
- package/dist/core/model.d.ts +2 -0
- package/dist/core/model.js +16 -14
- package/dist/core/provider.d.ts +7 -0
- package/dist/core/provider.js +7 -0
- package/dist/core/queue.d.ts +134 -9
- package/dist/core/queue.js +304 -14
- package/dist/core/rate-limit.js +3 -0
- package/dist/core/relations.js +13 -13
- package/dist/core/request.d.ts +26 -0
- package/dist/core/request.js +77 -0
- package/dist/core/shield.d.ts +39 -0
- package/dist/core/shield.js +60 -0
- package/dist/core/social.d.ts +173 -0
- package/dist/core/social.js +337 -0
- package/dist/core/storage.d.ts +159 -6
- package/dist/core/storage.js +287 -7
- package/dist/core/tokens.d.ts +74 -0
- package/dist/core/tokens.js +155 -0
- package/dist/db/d1.d.ts +32 -0
- package/dist/db/d1.js +26 -0
- package/dist/db/libsql.d.ts +29 -0
- package/dist/db/libsql.js +32 -0
- package/dist/db/pg.d.ts +29 -0
- package/dist/db/pg.js +33 -0
- package/dist/mcp/server.d.ts +19 -0
- package/dist/mcp/server.js +355 -0
- package/docs/ai-manifest.json +2472 -0
- package/docs/ai.md +128 -0
- package/docs/architecture.md +331 -0
- package/docs/authentication.md +453 -0
- package/docs/authorization.md +167 -0
- package/docs/broadcasting.md +137 -0
- package/docs/broker.md +500 -0
- package/docs/cache.md +558 -0
- package/docs/configuration.md +311 -0
- package/docs/console.md +356 -0
- package/docs/container.md +467 -0
- package/docs/controllers.md +265 -0
- package/docs/cors.md +51 -0
- package/docs/database.md +530 -0
- package/docs/debugging.md +129 -0
- package/docs/decorators.md +127 -0
- package/docs/errors.md +395 -0
- package/docs/events.md +496 -0
- package/docs/examples/architecture-app.ts +27 -0
- package/docs/examples/authentication.ts +61 -0
- package/docs/examples/authorization.ts +79 -0
- package/docs/examples/broadcasting.ts +60 -0
- package/docs/examples/broker-cache-validate.ts +34 -0
- package/docs/examples/broker-fault-tolerance.ts +29 -0
- package/docs/examples/broker-middleware.ts +27 -0
- package/docs/examples/broker.ts +203 -0
- package/docs/examples/cache.ts +222 -0
- package/docs/examples/configuration.ts +81 -0
- package/docs/examples/container.ts +134 -0
- package/docs/examples/controllers.ts +86 -0
- package/docs/examples/database.ts +118 -0
- package/docs/examples/debugging.ts +41 -0
- package/docs/examples/decorators.ts +40 -0
- package/docs/examples/errors.ts +121 -0
- package/docs/examples/events.ts +204 -0
- package/docs/examples/factories.ts +84 -0
- package/docs/examples/hashing.ts +71 -0
- package/docs/examples/health.ts +94 -0
- package/docs/examples/helpers.ts +171 -0
- package/docs/examples/hooks.ts +54 -0
- package/docs/examples/inertia.ts +81 -0
- package/docs/examples/locks.ts +120 -0
- package/docs/examples/logger.ts +92 -0
- package/docs/examples/mail.ts +160 -0
- package/docs/examples/middleware.ts +119 -0
- package/docs/examples/migrations.ts +126 -0
- package/docs/examples/models.ts +239 -0
- package/docs/examples/notification.ts +124 -0
- package/docs/examples/providers.ts +123 -0
- package/docs/examples/queues.ts +254 -0
- package/docs/examples/rate-limiting.ts +42 -0
- package/docs/examples/redis.ts +99 -0
- package/docs/examples/request-response.ts +197 -0
- package/docs/examples/routing.ts +186 -0
- package/docs/examples/scheduling.ts +62 -0
- package/docs/examples/sessions.ts +102 -0
- package/docs/examples/static-files.ts +63 -0
- package/docs/examples/storage.ts +132 -0
- package/docs/examples/templates.ts +58 -0
- package/docs/examples/testing.ts +66 -0
- package/docs/examples/transformer.ts +141 -0
- package/docs/examples/transformers.ts +49 -0
- package/docs/examples/url-builder.ts +86 -0
- package/docs/examples/validation.ts +102 -0
- package/docs/examples/views.tsx +62 -0
- package/docs/examples/vite.ts +106 -0
- package/docs/factories.md +166 -0
- package/docs/getting-started.md +290 -0
- package/docs/hashing.md +259 -0
- package/docs/health.md +225 -0
- package/docs/helpers.md +347 -0
- package/docs/hono.md +186 -0
- package/docs/hooks.md +118 -0
- package/docs/inertia.md +241 -0
- package/docs/locks.md +323 -0
- package/docs/logger.md +290 -0
- package/docs/mail.md +678 -0
- package/docs/middleware.md +425 -0
- package/docs/migrations.md +476 -0
- package/docs/models.md +810 -0
- package/docs/notifications.md +474 -0
- package/docs/providers.md +363 -0
- package/docs/queues.md +679 -0
- package/docs/rate-limiting.md +155 -0
- package/docs/redis.md +178 -0
- package/docs/request-response.md +953 -0
- package/docs/routing.md +804 -0
- package/docs/scheduling.md +110 -0
- package/docs/security.md +85 -0
- package/docs/sessions.md +354 -0
- package/docs/social-auth.md +174 -0
- package/docs/static-files.md +211 -0
- package/docs/storage.md +450 -0
- package/docs/templates.md +315 -0
- package/docs/testing.md +125 -0
- package/docs/transformers.md +381 -0
- package/docs/url-builder.md +295 -0
- package/docs/validation.md +288 -0
- package/docs/views.md +267 -0
- package/docs/vite.md +434 -0
- package/llms-full.txt +17694 -0
- package/llms.txt +116 -0
- package/package.json +38 -7
package/dist/core/storage.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
/**
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
}
|
package/dist/db/d1.d.ts
ADDED
|
@@ -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;
|