@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/cache.js
CHANGED
|
@@ -5,6 +5,23 @@
|
|
|
5
5
|
* the global `cache()` helper.
|
|
6
6
|
*
|
|
7
7
|
* const stats = await cache().remember("stats", 60, () => computeStats());
|
|
8
|
+
*
|
|
9
|
+
* Features borrowed from bentocache (AdonisJS' cache), kept inside keel's
|
|
10
|
+
* single-store, edge-safe model:
|
|
11
|
+
*
|
|
12
|
+
* - Stampede protection — concurrent `remember()` calls for the same cold key
|
|
13
|
+
* run the factory ONCE and share the result, instead of dog-piling.
|
|
14
|
+
* - Grace / stale-on-error — with a `grace` window, an expired value is kept
|
|
15
|
+
* a little longer and served if the refreshing factory throws.
|
|
16
|
+
* - Tags — group entries and invalidate them together with `deleteByTag()`.
|
|
17
|
+
* - Namespaces — `namespace("users")` scopes keys under a prefix, and its
|
|
18
|
+
* `flush()` clears only that namespace.
|
|
19
|
+
*
|
|
20
|
+
* Tag and namespace invalidation use version stamps, not a key index: each tag
|
|
21
|
+
* carries a counter, every entry records the counter values it was written at,
|
|
22
|
+
* and invalidation just bumps the counter — so an entry whose tag has moved on
|
|
23
|
+
* reads as a miss. O(1) invalidation, no key-set bookkeeping, works on any
|
|
24
|
+
* `CacheStore`.
|
|
8
25
|
*/
|
|
9
26
|
/** The default in-memory store. */
|
|
10
27
|
export class MemoryStore {
|
|
@@ -29,24 +46,109 @@ export class MemoryStore {
|
|
|
29
46
|
this.data.clear();
|
|
30
47
|
}
|
|
31
48
|
}
|
|
49
|
+
function isEntry(x) {
|
|
50
|
+
return typeof x === "object" && x !== null && x.__keelCache === 1;
|
|
51
|
+
}
|
|
52
|
+
// The store key that holds a tag's version counter. Never namespaced, never
|
|
53
|
+
// expired — a bump must outlive every entry that recorded the old version.
|
|
54
|
+
const TAG_VERSION_PREFIX = "\u0000keel:tagv:";
|
|
32
55
|
export class Cache {
|
|
33
56
|
store;
|
|
34
57
|
constructor(store = new MemoryStore()) {
|
|
35
58
|
this.store = store;
|
|
36
59
|
}
|
|
60
|
+
// Key prefix + implicit tag for a namespaced cache (empty on the root).
|
|
61
|
+
prefix = "";
|
|
62
|
+
nsTag;
|
|
63
|
+
// Stampede protection: in-flight factory promises, keyed by prefixed key.
|
|
64
|
+
inflight = new Map();
|
|
65
|
+
/** Apply this cache's namespace prefix to a logical key. */
|
|
66
|
+
k(key) {
|
|
67
|
+
return this.prefix + key;
|
|
68
|
+
}
|
|
69
|
+
tagKey(tag) {
|
|
70
|
+
return TAG_VERSION_PREFIX + tag;
|
|
71
|
+
}
|
|
72
|
+
async tagVersion(tag) {
|
|
73
|
+
const v = await this.store.get(this.tagKey(tag));
|
|
74
|
+
return typeof v === "number" ? v : 0;
|
|
75
|
+
}
|
|
76
|
+
/** Snapshot the current version of each tag, to stamp onto a new entry. */
|
|
77
|
+
async stampTags(tags) {
|
|
78
|
+
const out = {};
|
|
79
|
+
for (const tag of tags)
|
|
80
|
+
out[tag] = await this.tagVersion(tag);
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
/** True if any recorded tag version is behind the live one (invalidated). */
|
|
84
|
+
async tagsInvalidated(recorded) {
|
|
85
|
+
for (const [tag, ver] of Object.entries(recorded)) {
|
|
86
|
+
if ((await this.tagVersion(tag)) > ver)
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Read the raw envelope for a key. Returns undefined when absent OR when a tag
|
|
93
|
+
* has invalidated it — tag/namespace invalidation is a hard miss (not grace-
|
|
94
|
+
* eligible). TTL freshness is left to callers, so grace can still see a stale
|
|
95
|
+
* entry.
|
|
96
|
+
*/
|
|
97
|
+
async entry(key) {
|
|
98
|
+
const raw = await this.store.get(this.k(key));
|
|
99
|
+
if (raw === undefined)
|
|
100
|
+
return undefined;
|
|
101
|
+
const e = isEntry(raw) ? raw : { __keelCache: 1, v: raw, e: 0 };
|
|
102
|
+
if (e.t && (await this.tagsInvalidated(e.t)))
|
|
103
|
+
return undefined;
|
|
104
|
+
return e;
|
|
105
|
+
}
|
|
106
|
+
async write(key, value, ttlSeconds, graceSeconds = 0, tags = []) {
|
|
107
|
+
const allTags = this.nsTag ? [this.nsTag, ...tags] : tags;
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
const e = ttlSeconds ? now + ttlSeconds * 1000 : 0;
|
|
110
|
+
const storeTtlMs = ttlSeconds ? (ttlSeconds + graceSeconds) * 1000 : undefined;
|
|
111
|
+
const t = allTags.length ? await this.stampTags(allTags) : undefined;
|
|
112
|
+
const entry = { __keelCache: 1, v: value, e };
|
|
113
|
+
if (t)
|
|
114
|
+
entry.t = t;
|
|
115
|
+
await this.store.set(this.k(key), entry, storeTtlMs);
|
|
116
|
+
}
|
|
37
117
|
async get(key, fallback) {
|
|
38
|
-
const
|
|
39
|
-
|
|
118
|
+
const entry = await this.entry(key);
|
|
119
|
+
if (!entry)
|
|
120
|
+
return fallback;
|
|
121
|
+
if (entry.e && entry.e < Date.now())
|
|
122
|
+
return fallback; // expired (grace-retained)
|
|
123
|
+
return entry.v;
|
|
124
|
+
}
|
|
125
|
+
/** Store a value, optionally expiring after `ttlSeconds`, with optional tags. */
|
|
126
|
+
async put(key, value, ttlSeconds, options) {
|
|
127
|
+
await this.write(key, value, ttlSeconds, 0, options?.tags ?? []);
|
|
40
128
|
}
|
|
41
|
-
/** Store a value
|
|
42
|
-
async
|
|
43
|
-
await this.
|
|
129
|
+
/** Store a value only if the key is absent. Returns whether it was written. */
|
|
130
|
+
async add(key, value, ttlSeconds, options) {
|
|
131
|
+
if (await this.has(key))
|
|
132
|
+
return false;
|
|
133
|
+
await this.put(key, value, ttlSeconds, options);
|
|
134
|
+
return true;
|
|
44
135
|
}
|
|
45
136
|
async has(key) {
|
|
46
|
-
|
|
137
|
+
const entry = await this.entry(key);
|
|
138
|
+
if (!entry)
|
|
139
|
+
return false;
|
|
140
|
+
return !(entry.e && entry.e < Date.now());
|
|
141
|
+
}
|
|
142
|
+
/** The inverse of `has` — true when the key is absent or expired. */
|
|
143
|
+
async missing(key) {
|
|
144
|
+
return !(await this.has(key));
|
|
47
145
|
}
|
|
48
146
|
async forget(key) {
|
|
49
|
-
await this.store.delete(key);
|
|
147
|
+
await this.store.delete(this.k(key));
|
|
148
|
+
}
|
|
149
|
+
/** Forget several keys at once. */
|
|
150
|
+
async forgetMany(keys) {
|
|
151
|
+
await Promise.all(keys.map((k) => this.store.delete(this.k(k))));
|
|
50
152
|
}
|
|
51
153
|
/** Read and remove a value. */
|
|
52
154
|
async pull(key, fallback) {
|
|
@@ -55,27 +157,83 @@ export class Cache {
|
|
|
55
157
|
return value;
|
|
56
158
|
}
|
|
57
159
|
async flush() {
|
|
58
|
-
|
|
160
|
+
// A namespace clears itself by moving its tag version on; the root wipes the
|
|
161
|
+
// whole store.
|
|
162
|
+
if (this.nsTag)
|
|
163
|
+
await this.deleteByTag([this.nsTag]);
|
|
164
|
+
else
|
|
165
|
+
await this.store.clear();
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Invalidate every entry carrying any of these tags by bumping the tag's
|
|
169
|
+
* version — entries stamped with the old version then read as a miss. O(#tags),
|
|
170
|
+
* no key scan; invalidated entries fall out on their own TTL.
|
|
171
|
+
*/
|
|
172
|
+
async deleteByTag(tags) {
|
|
173
|
+
for (const tag of tags) {
|
|
174
|
+
const current = await this.tagVersion(tag);
|
|
175
|
+
await this.store.set(this.tagKey(tag), current + 1); // no TTL — must persist
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* A cache scoped under a key prefix. Keys written through it live at
|
|
180
|
+
* `name:key`, and its `flush()` clears only this namespace (via an implicit
|
|
181
|
+
* tag) — the rest of the store is untouched. Namespaces nest.
|
|
182
|
+
*
|
|
183
|
+
* const users = cache().namespace("users");
|
|
184
|
+
* await users.put("1", user); // stored at "users:1"
|
|
185
|
+
* await users.flush(); // clears only the users namespace
|
|
186
|
+
*/
|
|
187
|
+
namespace(name) {
|
|
188
|
+
const child = new Cache(this.store);
|
|
189
|
+
child.prefix = `${this.prefix}${name}:`;
|
|
190
|
+
child.nsTag = `\u0000keel:ns:${this.prefix}${name}`;
|
|
191
|
+
return child;
|
|
59
192
|
}
|
|
60
193
|
/**
|
|
61
194
|
* Return the cached value, or compute it with `factory`, cache it for
|
|
62
195
|
* `ttlSeconds`, and return it.
|
|
196
|
+
*
|
|
197
|
+
* Concurrent calls for the same cold key share one factory run (stampede
|
|
198
|
+
* protection). With `{ grace }`, an expired value is retained that many extra
|
|
199
|
+
* seconds and served if the refreshing factory throws (stale-on-error). With
|
|
200
|
+
* `{ tags }`, the cached value joins those tags for `deleteByTag`.
|
|
63
201
|
*/
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (existing !== undefined)
|
|
67
|
-
return existing;
|
|
68
|
-
const value = await factory();
|
|
69
|
-
await this.put(key, value, ttlSeconds);
|
|
70
|
-
return value;
|
|
202
|
+
remember(key, ttlSeconds, factory, options) {
|
|
203
|
+
return this.resolve(key, ttlSeconds, factory, options?.grace ?? 0, options?.tags ?? []);
|
|
71
204
|
}
|
|
72
|
-
/** Like remember(), but cached forever (no TTL). */
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
205
|
+
/** Like remember(), but cached forever (no TTL). Also stampede-protected. */
|
|
206
|
+
rememberForever(key, factory, options) {
|
|
207
|
+
return this.resolve(key, undefined, factory, 0, options?.tags ?? []);
|
|
208
|
+
}
|
|
209
|
+
async resolve(key, ttlSeconds, factory, graceSeconds, tags) {
|
|
210
|
+
const entry = await this.entry(key);
|
|
211
|
+
const fresh = entry && (!entry.e || entry.e > Date.now());
|
|
212
|
+
if (fresh)
|
|
213
|
+
return entry.v;
|
|
214
|
+
// Stampede protection: join an in-flight computation for this key.
|
|
215
|
+
const pending = this.inflight.get(key);
|
|
216
|
+
if (pending)
|
|
217
|
+
return pending;
|
|
218
|
+
const run = (async () => {
|
|
219
|
+
try {
|
|
220
|
+
// Defer to a microtask so a synchronously-throwing factory rejects the
|
|
221
|
+
// shared promise only after every joiner has attached its handler.
|
|
222
|
+
const value = await Promise.resolve().then(factory);
|
|
223
|
+
await this.write(key, value, ttlSeconds, graceSeconds, tags);
|
|
224
|
+
return value;
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
// Grace: a stale-but-retained value rescues a failing refresh.
|
|
228
|
+
if (entry && graceSeconds > 0)
|
|
229
|
+
return entry.v;
|
|
230
|
+
throw err;
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
this.inflight.delete(key);
|
|
234
|
+
}
|
|
235
|
+
})();
|
|
236
|
+
this.inflight.set(key, run);
|
|
237
|
+
return run;
|
|
80
238
|
}
|
|
81
239
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Code-generation templates for `keel make:*`. */
|
|
2
|
+
export declare function controllerStub(name: string): string;
|
|
3
|
+
export declare function resourceControllerStub(name: string): string;
|
|
4
|
+
export declare function providerStub(name: string): string;
|
|
5
|
+
export declare function middlewareStub(name: string): string;
|
|
6
|
+
/** `name` is the model class (e.g. "User") the factory builds. */
|
|
7
|
+
export declare function factoryStub(model: string): string;
|
|
8
|
+
export declare function seederStub(name: string): string;
|
|
9
|
+
export declare function jobStub(name: string): string;
|
|
10
|
+
export declare function notificationStub(name: string): string;
|
|
11
|
+
/** `name` is the class (e.g. "UserTransformer"); `model` is the value it maps. */
|
|
12
|
+
export declare function transformerStub(name: string, model: string): string;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/** Code-generation templates for `keel make:*`. */
|
|
2
|
+
export function controllerStub(name) {
|
|
3
|
+
return `import type { Ctx } from "@shaferllc/keel/core";
|
|
4
|
+
|
|
5
|
+
export class ${name} {
|
|
6
|
+
index(c: Ctx) {
|
|
7
|
+
return c.json({ controller: "${name}", action: "index" });
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
`;
|
|
11
|
+
}
|
|
12
|
+
export function resourceControllerStub(name) {
|
|
13
|
+
const actions = ["index", "create", "store", "show", "edit", "update", "destroy"];
|
|
14
|
+
const body = actions
|
|
15
|
+
.map((a) => ` ${a}(c: Ctx) {\n return c.json({ action: "${a}" });\n }`)
|
|
16
|
+
.join("\n\n");
|
|
17
|
+
return `import type { Ctx } from "@shaferllc/keel/core";
|
|
18
|
+
|
|
19
|
+
export class ${name} {
|
|
20
|
+
${body}
|
|
21
|
+
}
|
|
22
|
+
`;
|
|
23
|
+
}
|
|
24
|
+
export function providerStub(name) {
|
|
25
|
+
return `import { ServiceProvider } from "@shaferllc/keel/core";
|
|
26
|
+
|
|
27
|
+
export class ${name} extends ServiceProvider {
|
|
28
|
+
register(): void {
|
|
29
|
+
// Bind services into the container here.
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
boot(): void {
|
|
33
|
+
// Resolve and wire things up here.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
`;
|
|
37
|
+
}
|
|
38
|
+
export function middlewareStub(name) {
|
|
39
|
+
const fn = name.charAt(0).toLowerCase() + name.slice(1);
|
|
40
|
+
return `import type { MiddlewareHandler } from "hono";
|
|
41
|
+
|
|
42
|
+
export const ${fn}: MiddlewareHandler = async (c, next) => {
|
|
43
|
+
// ...before
|
|
44
|
+
await next();
|
|
45
|
+
// ...after
|
|
46
|
+
};
|
|
47
|
+
`;
|
|
48
|
+
}
|
|
49
|
+
/** `name` is the model class (e.g. "User") the factory builds. */
|
|
50
|
+
export function factoryStub(model) {
|
|
51
|
+
return `import { factory } from "@shaferllc/keel/core";
|
|
52
|
+
import { ${model} } from "../../app/Models/${model}.js";
|
|
53
|
+
|
|
54
|
+
export const ${model.toLowerCase()}Factory = factory(${model}, (f) => ({
|
|
55
|
+
// Describe one ${model}'s attributes; \`f\` is a Faker.
|
|
56
|
+
name: f.name(),
|
|
57
|
+
email: f.email(),
|
|
58
|
+
}));
|
|
59
|
+
`;
|
|
60
|
+
}
|
|
61
|
+
export function seederStub(name) {
|
|
62
|
+
return `import { Seeder } from "@shaferllc/keel/core";
|
|
63
|
+
|
|
64
|
+
export class ${name} extends Seeder {
|
|
65
|
+
async run(): Promise<void> {
|
|
66
|
+
// Populate the database, e.g.:
|
|
67
|
+
// await userFactory.count(10).create();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
`;
|
|
71
|
+
}
|
|
72
|
+
export function jobStub(name) {
|
|
73
|
+
return `import { Job } from "@shaferllc/keel/core";
|
|
74
|
+
|
|
75
|
+
export class ${name} extends Job {
|
|
76
|
+
constructor(/* pass the data this job needs */) {
|
|
77
|
+
super();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async handle(): Promise<void> {
|
|
81
|
+
// Do the background work here.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
`;
|
|
85
|
+
}
|
|
86
|
+
export function notificationStub(name) {
|
|
87
|
+
return `import { Notification, type Notifiable, type MailContent } from "@shaferllc/keel/core";
|
|
88
|
+
|
|
89
|
+
export class ${name} extends Notification {
|
|
90
|
+
via(_notifiable: Notifiable): string[] {
|
|
91
|
+
return ["mail"];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
toMail(_notifiable: Notifiable): MailContent {
|
|
95
|
+
return {
|
|
96
|
+
subject: "${name}",
|
|
97
|
+
text: "Notification body.",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
`;
|
|
102
|
+
}
|
|
103
|
+
/** `name` is the class (e.g. "UserTransformer"); `model` is the value it maps. */
|
|
104
|
+
export function transformerStub(name, model) {
|
|
105
|
+
return `import { Transformer, type Attributes } from "@shaferllc/keel/core";
|
|
106
|
+
// import { ${model} } from "../Models/${model}.js";
|
|
107
|
+
|
|
108
|
+
export class ${name} extends Transformer</* ${model} */ any> {
|
|
109
|
+
transform(item: /* ${model} */ any): Attributes {
|
|
110
|
+
// Map the value to the exact shape your API exposes.
|
|
111
|
+
return {
|
|
112
|
+
id: item.id,
|
|
113
|
+
// name: item.name,
|
|
114
|
+
// email: this.when(canSeeEmail, item.email),
|
|
115
|
+
// posts: this.whenLoaded(item, "posts", new PostTransformer()),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
`;
|
|
120
|
+
}
|
package/dist/core/container.d.ts
CHANGED
|
@@ -13,12 +13,32 @@ export type Factory<T> = (app: Container) => T;
|
|
|
13
13
|
export declare class Container {
|
|
14
14
|
private bindings;
|
|
15
15
|
private instances;
|
|
16
|
+
private swaps;
|
|
16
17
|
/** Register a transient binding — a fresh value every resolve. */
|
|
17
18
|
bind<T>(token: Token<T>, factory: Factory<T>): this;
|
|
18
19
|
/** Register a shared binding — resolved once, then cached. */
|
|
19
20
|
singleton<T>(token: Token<T>, factory: Factory<T>): this;
|
|
20
21
|
/** Register an already-constructed value as a shared instance. */
|
|
21
22
|
instance<T>(token: Token<T>, value: T): T;
|
|
23
|
+
/**
|
|
24
|
+
* Register an alias that resolves to another token — `alias("router", Router)`
|
|
25
|
+
* lets `make("router")` return whatever `make(Router)` does, honoring the
|
|
26
|
+
* target's own sharing (the target owns the singleton; the alias just points).
|
|
27
|
+
*/
|
|
28
|
+
alias<T>(alias: Token<T>, target: Token<T>): this;
|
|
29
|
+
/**
|
|
30
|
+
* Temporarily replace a binding with a fake — for tests. The replacement is
|
|
31
|
+
* shared (resolved once), and the original binding/instance is remembered so
|
|
32
|
+
* `restore()` can put it back. Idempotent per token: the first swap saves the
|
|
33
|
+
* original; later swaps just change the fake.
|
|
34
|
+
*
|
|
35
|
+
* app.swap(Mailer, () => fakeMailer);
|
|
36
|
+
* // … exercise code that resolves Mailer …
|
|
37
|
+
* app.restore(Mailer);
|
|
38
|
+
*/
|
|
39
|
+
swap<T>(token: Token<T>, factory: Factory<T>): this;
|
|
40
|
+
/** Undo a `swap()` — restore the original binding. No token restores every swap. */
|
|
41
|
+
restore(token?: Token): this;
|
|
22
42
|
/** True if the token is bound or has a cached instance. */
|
|
23
43
|
bound(token: Token): boolean;
|
|
24
44
|
/** Resolve a token out of the container. */
|
package/dist/core/container.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
export class Container {
|
|
11
11
|
bindings = new Map();
|
|
12
12
|
instances = new Map();
|
|
13
|
+
swaps = new Map();
|
|
13
14
|
/** Register a transient binding — a fresh value every resolve. */
|
|
14
15
|
bind(token, factory) {
|
|
15
16
|
this.bindings.set(token, { factory, shared: false });
|
|
@@ -25,6 +26,57 @@ export class Container {
|
|
|
25
26
|
this.instances.set(token, value);
|
|
26
27
|
return value;
|
|
27
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Register an alias that resolves to another token — `alias("router", Router)`
|
|
31
|
+
* lets `make("router")` return whatever `make(Router)` does, honoring the
|
|
32
|
+
* target's own sharing (the target owns the singleton; the alias just points).
|
|
33
|
+
*/
|
|
34
|
+
alias(alias, target) {
|
|
35
|
+
this.bindings.set(alias, { factory: (app) => app.make(target), shared: false });
|
|
36
|
+
return this;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Temporarily replace a binding with a fake — for tests. The replacement is
|
|
40
|
+
* shared (resolved once), and the original binding/instance is remembered so
|
|
41
|
+
* `restore()` can put it back. Idempotent per token: the first swap saves the
|
|
42
|
+
* original; later swaps just change the fake.
|
|
43
|
+
*
|
|
44
|
+
* app.swap(Mailer, () => fakeMailer);
|
|
45
|
+
* // … exercise code that resolves Mailer …
|
|
46
|
+
* app.restore(Mailer);
|
|
47
|
+
*/
|
|
48
|
+
swap(token, factory) {
|
|
49
|
+
if (!this.swaps.has(token)) {
|
|
50
|
+
this.swaps.set(token, {
|
|
51
|
+
binding: this.bindings.get(token),
|
|
52
|
+
instance: this.instances.get(token),
|
|
53
|
+
hadInstance: this.instances.has(token),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
this.bindings.set(token, { factory, shared: true });
|
|
57
|
+
this.instances.delete(token); // force the next make() through the fake
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
/** Undo a `swap()` — restore the original binding. No token restores every swap. */
|
|
61
|
+
restore(token) {
|
|
62
|
+
if (token === undefined) {
|
|
63
|
+
for (const t of [...this.swaps.keys()])
|
|
64
|
+
this.restore(t);
|
|
65
|
+
return this;
|
|
66
|
+
}
|
|
67
|
+
const saved = this.swaps.get(token);
|
|
68
|
+
if (!saved)
|
|
69
|
+
return this;
|
|
70
|
+
this.swaps.delete(token);
|
|
71
|
+
this.instances.delete(token);
|
|
72
|
+
if (saved.binding)
|
|
73
|
+
this.bindings.set(token, saved.binding);
|
|
74
|
+
else
|
|
75
|
+
this.bindings.delete(token);
|
|
76
|
+
if (saved.hadInstance)
|
|
77
|
+
this.instances.set(token, saved.instance);
|
|
78
|
+
return this;
|
|
79
|
+
}
|
|
28
80
|
/** True if the token is bound or has a cached instance. */
|
|
29
81
|
bound(token) {
|
|
30
82
|
return this.bindings.has(token) || this.instances.has(token);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CORS — Cross-Origin Resource Sharing. Register it in your HTTP kernel (or on a
|
|
3
|
+
* route group) to let browsers on other origins call your API. It answers
|
|
4
|
+
* preflight `OPTIONS` requests automatically and sets the `Access-Control-*`
|
|
5
|
+
* headers on every response.
|
|
6
|
+
*
|
|
7
|
+
* this.use(cors()); // reflect any origin (dev)
|
|
8
|
+
* this.use(cors({ origin: ["https://app.example.com"], credentials: true }));
|
|
9
|
+
*
|
|
10
|
+
* `origin` accepts a boolean (`true` reflects the caller, `false` blocks), `"*"`,
|
|
11
|
+
* an allowlist array, or a predicate. When `credentials` is on, `"*"` isn't legal
|
|
12
|
+
* per the spec, so the caller's origin is reflected and `Vary: Origin` is set.
|
|
13
|
+
*/
|
|
14
|
+
import type { Context, MiddlewareHandler } from "hono";
|
|
15
|
+
export interface CorsOptions {
|
|
16
|
+
/** Who may call: `true` reflects the request origin, `false` blocks, `"*"` any, an allowlist, or a predicate. Default `true`. */
|
|
17
|
+
origin?: boolean | string | string[] | ((origin: string, c: Context) => boolean | string);
|
|
18
|
+
/** Allowed methods for cross-origin requests. */
|
|
19
|
+
methods?: string[];
|
|
20
|
+
/** Allowed request headers: `true` reflects what the browser asks for, or an allowlist. Default `true`. */
|
|
21
|
+
headers?: boolean | string[];
|
|
22
|
+
/** Response headers JS may read (`Access-Control-Expose-Headers`). */
|
|
23
|
+
exposeHeaders?: string[];
|
|
24
|
+
/** Send `Access-Control-Allow-Credentials: true` (cookies/authorization). Default `false`. */
|
|
25
|
+
credentials?: boolean;
|
|
26
|
+
/** Preflight cache seconds (`Access-Control-Max-Age`). `null` omits it. Default 86400. */
|
|
27
|
+
maxAge?: number | null;
|
|
28
|
+
}
|
|
29
|
+
export declare function cors(options?: CorsOptions): MiddlewareHandler;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CORS — Cross-Origin Resource Sharing. Register it in your HTTP kernel (or on a
|
|
3
|
+
* route group) to let browsers on other origins call your API. It answers
|
|
4
|
+
* preflight `OPTIONS` requests automatically and sets the `Access-Control-*`
|
|
5
|
+
* headers on every response.
|
|
6
|
+
*
|
|
7
|
+
* this.use(cors()); // reflect any origin (dev)
|
|
8
|
+
* this.use(cors({ origin: ["https://app.example.com"], credentials: true }));
|
|
9
|
+
*
|
|
10
|
+
* `origin` accepts a boolean (`true` reflects the caller, `false` blocks), `"*"`,
|
|
11
|
+
* an allowlist array, or a predicate. When `credentials` is on, `"*"` isn't legal
|
|
12
|
+
* per the spec, so the caller's origin is reflected and `Vary: Origin` is set.
|
|
13
|
+
*/
|
|
14
|
+
const DEFAULT_METHODS = ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"];
|
|
15
|
+
/** Resolve the allowed origin for this request, or `null` to disallow. */
|
|
16
|
+
function resolveOrigin(option, requestOrigin, c) {
|
|
17
|
+
if (option === undefined || option === true)
|
|
18
|
+
return requestOrigin || "*";
|
|
19
|
+
if (option === false)
|
|
20
|
+
return null;
|
|
21
|
+
if (option === "*")
|
|
22
|
+
return "*";
|
|
23
|
+
if (typeof option === "string")
|
|
24
|
+
return option;
|
|
25
|
+
if (Array.isArray(option))
|
|
26
|
+
return option.includes(requestOrigin) ? requestOrigin : null;
|
|
27
|
+
const result = option(requestOrigin, c);
|
|
28
|
+
if (result === true)
|
|
29
|
+
return requestOrigin || "*";
|
|
30
|
+
if (result === false)
|
|
31
|
+
return null;
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
export function cors(options = {}) {
|
|
35
|
+
const methods = options.methods ?? DEFAULT_METHODS;
|
|
36
|
+
const maxAge = options.maxAge === undefined ? 86400 : options.maxAge;
|
|
37
|
+
const credentials = options.credentials ?? false;
|
|
38
|
+
return async (c, next) => {
|
|
39
|
+
const requestOrigin = c.req.header("origin") ?? "";
|
|
40
|
+
let allowOrigin = resolveOrigin(options.origin, requestOrigin, c);
|
|
41
|
+
// With credentials, "*" is illegal — reflect the concrete origin instead.
|
|
42
|
+
if (allowOrigin === "*" && credentials)
|
|
43
|
+
allowOrigin = requestOrigin || null;
|
|
44
|
+
const shared = {};
|
|
45
|
+
if (allowOrigin)
|
|
46
|
+
shared["Access-Control-Allow-Origin"] = allowOrigin;
|
|
47
|
+
// Reflecting a specific origin makes the response vary by it (caches).
|
|
48
|
+
if (allowOrigin && allowOrigin !== "*")
|
|
49
|
+
shared["Vary"] = "Origin";
|
|
50
|
+
if (credentials)
|
|
51
|
+
shared["Access-Control-Allow-Credentials"] = "true";
|
|
52
|
+
// Preflight — answer directly with all headers, never reaching the route.
|
|
53
|
+
if (c.req.method === "OPTIONS" && c.req.header("access-control-request-method")) {
|
|
54
|
+
const headers = { ...shared, "Access-Control-Allow-Methods": methods.join(", ") };
|
|
55
|
+
const allowHeaders = options.headers === undefined || options.headers === true
|
|
56
|
+
? c.req.header("access-control-request-headers") ?? ""
|
|
57
|
+
: options.headers.join(", ");
|
|
58
|
+
if (allowHeaders)
|
|
59
|
+
headers["Access-Control-Allow-Headers"] = allowHeaders;
|
|
60
|
+
if (maxAge != null)
|
|
61
|
+
headers["Access-Control-Max-Age"] = String(maxAge);
|
|
62
|
+
return c.body(null, 204, headers);
|
|
63
|
+
}
|
|
64
|
+
await next();
|
|
65
|
+
// Set on the final response (survives a handler that returns a fresh Response).
|
|
66
|
+
for (const [name, value] of Object.entries(shared))
|
|
67
|
+
c.header(name, value);
|
|
68
|
+
if (options.exposeHeaders?.length) {
|
|
69
|
+
c.header("Access-Control-Expose-Headers", options.exposeHeaders.join(", "));
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
package/dist/core/crypto.d.ts
CHANGED
|
@@ -18,12 +18,48 @@ export declare const hash: {
|
|
|
18
18
|
verify(hashed: string, password: string): Promise<boolean>;
|
|
19
19
|
/** Whether a hash was made with fewer iterations than the current default. */
|
|
20
20
|
needsRehash(hashed: string, iterations?: number): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Swap real hashing for a trivial, **insecure** scheme so tests that create
|
|
23
|
+
* many users don't pay for PBKDF2. `make` returns `fake$<password>` and
|
|
24
|
+
* `verify` just compares — near-instant. Call in a test setup hook; undo with
|
|
25
|
+
* `restore()`. Never use outside tests.
|
|
26
|
+
*/
|
|
27
|
+
fake(): void;
|
|
28
|
+
/** Restore real PBKDF2 hashing after `fake()`. */
|
|
29
|
+
restore(): void;
|
|
30
|
+
/**
|
|
31
|
+
* A valid dummy hash (of a random secret) at the default cost. Compare against
|
|
32
|
+
* it when a user *isn't* found so login spends the same time as a wrong
|
|
33
|
+
* password — otherwise a fast "no such user" response leaks which emails are
|
|
34
|
+
* registered (a timing/enumeration attack).
|
|
35
|
+
*
|
|
36
|
+
* const user = await findUserByEmail(email);
|
|
37
|
+
* const ok = await hash.verify(user?.password ?? hash.dummy, password);
|
|
38
|
+
* if (ok && user) auth().login(user.id); // `user &&` so the dummy never authenticates
|
|
39
|
+
*/
|
|
40
|
+
dummy: string;
|
|
21
41
|
};
|
|
42
|
+
export interface EncryptOptions {
|
|
43
|
+
/** Time-to-live — seconds (number) or a duration string (`"30m"`, `"1h"`, `"7d"`). */
|
|
44
|
+
expiresIn?: number | string;
|
|
45
|
+
/** Bind the token to a context; `decrypt` must pass the same `purpose` or gets `null`. */
|
|
46
|
+
purpose?: string;
|
|
47
|
+
}
|
|
22
48
|
export declare const encryption: {
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Encrypt any JSON-serializable value (AES-GCM), keyed by `config('app.key')`.
|
|
51
|
+
* `expiresIn` makes the token self-expire; `purpose` binds it to a context
|
|
52
|
+
* (e.g. `"password-reset"`) so a token minted for one use can't be replayed for
|
|
53
|
+
* another.
|
|
54
|
+
*/
|
|
55
|
+
encrypt(value: unknown, options?: EncryptOptions): Promise<string>;
|
|
56
|
+
/**
|
|
57
|
+
* Decrypt a value; returns `null` if the payload is tampered, invalid, expired,
|
|
58
|
+
* or minted for a different `purpose`. Never throws.
|
|
59
|
+
*/
|
|
60
|
+
decrypt<T = unknown>(payload: string, options?: {
|
|
61
|
+
purpose?: string;
|
|
62
|
+
}): Promise<T | null>;
|
|
27
63
|
};
|
|
28
64
|
/** Standard registered claims, plus whatever custom fields you sign. */
|
|
29
65
|
export interface JwtPayload {
|