@shaferllc/keel 0.36.0 → 0.58.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/README.md +14 -2
- package/dist/core/application.d.ts +58 -2
- package/dist/core/application.js +99 -3
- package/dist/core/authorization.d.ts +52 -0
- package/dist/core/authorization.js +97 -0
- package/dist/core/broadcasting.d.ts +49 -0
- package/dist/core/broadcasting.js +84 -0
- package/dist/core/broker.d.ts +398 -0
- package/dist/core/broker.js +602 -0
- package/dist/core/crypto.d.ts +1 -1
- package/dist/core/crypto.js +12 -3
- package/dist/core/database.d.ts +26 -1
- package/dist/core/database.js +65 -2
- package/dist/core/decorators.d.ts +39 -0
- package/dist/core/decorators.js +72 -0
- package/dist/core/exceptions.d.ts +77 -6
- package/dist/core/exceptions.js +168 -10
- package/dist/core/helpers.d.ts +6 -0
- package/dist/core/helpers.js +12 -0
- package/dist/core/http/kernel.js +14 -2
- package/dist/core/http/router.d.ts +14 -0
- package/dist/core/http/router.js +30 -1
- package/dist/core/index.d.ts +28 -6
- package/dist/core/index.js +15 -3
- package/dist/core/logger.d.ts +5 -0
- package/dist/core/logger.js +24 -2
- package/dist/core/migrations.js +3 -3
- package/dist/core/model.d.ts +19 -1
- package/dist/core/model.js +72 -4
- package/dist/core/provider.d.ts +13 -4
- package/dist/core/provider.js +12 -2
- package/dist/core/redis.d.ts +78 -0
- package/dist/core/redis.js +176 -0
- package/dist/core/request-logger.d.ts +26 -0
- package/dist/core/request-logger.js +48 -0
- package/dist/core/request.d.ts +17 -1
- package/dist/core/request.js +27 -1
- package/dist/core/scheduler.d.ts +60 -0
- package/dist/core/scheduler.js +166 -0
- package/dist/core/session.js +17 -2
- package/dist/core/storage.d.ts +57 -0
- package/dist/core/storage.js +98 -0
- package/dist/core/template.d.ts +50 -0
- package/dist/core/template.js +753 -0
- package/dist/core/testing.d.ts +54 -0
- package/dist/core/testing.js +141 -0
- package/dist/core/transformer.d.ts +89 -0
- package/dist/core/transformer.js +152 -0
- package/dist/core/validation.d.ts +20 -0
- package/dist/core/validation.js +52 -1
- package/dist/core/vite.d.ts +117 -0
- package/dist/core/vite.js +258 -0
- package/dist/vite/index.d.ts +40 -0
- package/dist/vite/index.js +146 -0
- package/package.json +16 -1
package/README.md
CHANGED
|
@@ -163,33 +163,45 @@ See [docs/architecture.md](./docs/architecture.md) for the full picture.
|
|
|
163
163
|
|-------|----------------|
|
|
164
164
|
| [Getting Started](./docs/getting-started.md) | Install, run, first route and controller |
|
|
165
165
|
| [The Service Container](./docs/container.md) | Binding and resolving services, DI |
|
|
166
|
-
| [Service Providers](./docs/providers.md) |
|
|
166
|
+
| [Service Providers](./docs/providers.md) | Plugin system: register/boot lifecycle, options |
|
|
167
167
|
| [Configuration](./docs/configuration.md) | `config/*.ts`, dot-notation, `env()` |
|
|
168
168
|
| [Routing](./docs/routing.md) | Closures, controller tuples, groups, resources, domains |
|
|
169
169
|
| [URL Builder](./docs/url-builder.md) | Named-route URLs, signed URLs |
|
|
170
170
|
| [Hashing & Encryption](./docs/hashing.md) | Password hashing, AES value encryption |
|
|
171
171
|
| [Controllers](./docs/controllers.md) | Classes, DI, single-action, lazy-loaded |
|
|
172
172
|
| [Request & Response](./docs/request-response.md) | Input, cookies, output, `abort()` |
|
|
173
|
+
| [Request Decorators](./docs/decorators.md) | Lazy, memoized per-request values |
|
|
174
|
+
| [Lifecycle Hooks](./docs/hooks.md) | onReady/onShutdown, graceful shutdown, onRoute |
|
|
173
175
|
| [Sessions](./docs/sessions.md) | Cookie-backed sessions, flash messages |
|
|
174
176
|
| [Authentication](./docs/authentication.md) | Session auth, guards, user provider |
|
|
177
|
+
| [Authorization](./docs/authorization.md) | Gates & policies, `can`/`authorize` |
|
|
175
178
|
| [Database](./docs/database.md) | Driver-agnostic query builder |
|
|
176
179
|
| [Models](./docs/models.md) | Active-record: find/create/save, casts, relations |
|
|
177
180
|
| [Migrations](./docs/migrations.md) | Schema builder + migrator, dialect-aware |
|
|
178
181
|
| [Factories & Seeders](./docs/factories.md) | Built-in Faker, model factories, seeders |
|
|
179
182
|
| [Mail](./docs/mail.md) | Fluent mailer, pluggable transports, edge-safe |
|
|
180
183
|
| [Queues & Jobs](./docs/queues.md) | Dispatch jobs, pluggable drivers, workers |
|
|
184
|
+
| [Task Scheduling](./docs/scheduling.md) | Cron-style recurring tasks, one trigger |
|
|
181
185
|
| [Notifications](./docs/notifications.md) | Multi-channel (mail/db), queueable |
|
|
186
|
+
| [Broadcasting](./docs/broadcasting.md) | Real-time channels, pluggable, presence auth |
|
|
187
|
+
| [Transformers](./docs/transformers.md) | Shape models into API JSON; conditional fields, relations |
|
|
182
188
|
| [Events](./docs/events.md) | Emit/listen decoupling, async listeners |
|
|
189
|
+
| [Service Broker](./docs/broker.md) | Moleculer-style services, call/emit, pluggable transport |
|
|
183
190
|
| [Cache](./docs/cache.md) | TTLs, the remember pattern, pluggable stores |
|
|
184
|
-
| [
|
|
191
|
+
| [Redis](./docs/redis.md) | Pluggable client, memory driver, cache adapter |
|
|
192
|
+
| [Logger](./docs/logger.md) | Structured logging, per-request `reqId`, redaction |
|
|
185
193
|
| [Static Files](./docs/static-files.md) | serveStatic(), caching, dot-file safety |
|
|
194
|
+
| [Storage](./docs/storage.md) | Pluggable disks (local/S3/R2), edge-safe |
|
|
186
195
|
| [Views](./docs/views.md) | Hono JSX components, layouts, the View service |
|
|
196
|
+
| [Templates](./docs/templates.md) | `{{ }}` + `@`-tag templating engine, edge-safe |
|
|
187
197
|
| [Middleware](./docs/middleware.md) | Global middleware, writing your own |
|
|
188
198
|
| [Rate Limiting](./docs/rate-limiting.md) | rateLimiter() middleware, per-key buckets |
|
|
189
199
|
| [Errors](./docs/errors.md) | HTTP exceptions, debug page, custom handlers |
|
|
200
|
+
| [Testing](./docs/testing.md) | Inject requests, fluent response assertions |
|
|
190
201
|
| [Debugging](./docs/debugging.md) | dump() and dd() (dump-and-die) |
|
|
191
202
|
| [Validation](./docs/validation.md) | `validate()` with Zod, auto-422 field errors |
|
|
192
203
|
| [Inertia](./docs/inertia.md) | Server-side Inertia.js adapter |
|
|
204
|
+
| [Vite](./docs/vite.md) | Frontend build: HMR in dev, hashed manifest in prod |
|
|
193
205
|
| [The Console](./docs/console.md) | `serve`, `routes`, `make:*` generators |
|
|
194
206
|
| [Architecture](./docs/architecture.md) | Container, kernel, request lifecycle |
|
|
195
207
|
| [Built on Hono](./docs/hono.md) | The Hono layer underneath, and using it directly |
|
|
@@ -12,29 +12,85 @@ import { Config, type ConfigData } from "./config.js";
|
|
|
12
12
|
import { Router } from "./http/router.js";
|
|
13
13
|
import { View } from "./view.js";
|
|
14
14
|
import { type ProviderClass } from "./provider.js";
|
|
15
|
+
import type { Listener } from "./events.js";
|
|
16
|
+
/** A configurator: a plain function that sets the app up and may return anything. */
|
|
17
|
+
export type Configurator = (app: Application) => unknown;
|
|
15
18
|
export interface BootOptions {
|
|
16
19
|
/** Auto-load .env and config/*.ts from the filesystem. Default: true (Node). */
|
|
17
20
|
discoverConfig?: boolean;
|
|
18
21
|
/** Config to merge in directly — the way to configure on Workers. */
|
|
19
22
|
config?: ConfigData;
|
|
20
23
|
}
|
|
24
|
+
/** A lifecycle hook — receives the application, may be async. */
|
|
25
|
+
export type LifecycleHook = (app: Application) => void | Promise<void>;
|
|
21
26
|
export declare class Application extends Container {
|
|
22
27
|
readonly basePath: string;
|
|
23
28
|
private providers;
|
|
24
29
|
private booted;
|
|
30
|
+
private terminated;
|
|
31
|
+
private readyHooks;
|
|
32
|
+
private shutdownHooks;
|
|
25
33
|
constructor(basePath?: string);
|
|
26
34
|
path(...segments: string[]): string;
|
|
27
35
|
config(): Config;
|
|
28
36
|
router(): Router;
|
|
29
37
|
view(): View;
|
|
38
|
+
/**
|
|
39
|
+
* Run a configurator function against the app and return the app for
|
|
40
|
+
* chaining — Feathers' lightweight plugin idiom, an inline alternative to a
|
|
41
|
+
* full `ServiceProvider`. The function may bind services, register routes, or
|
|
42
|
+
* merge config. Use `register()` when you need the register/boot two-phase
|
|
43
|
+
* lifecycle; use `configure()` for one-shot setup.
|
|
44
|
+
*
|
|
45
|
+
* app.configure(rateLimit({ max: 100 })).configure(auth());
|
|
46
|
+
*/
|
|
47
|
+
configure(configurator: Configurator): this;
|
|
48
|
+
/**
|
|
49
|
+
* Set an app-wide value — Feathers' `app.set`. Backed by the config
|
|
50
|
+
* repository, so `app.set("db.url", …)` and `config().get("db.url")` share
|
|
51
|
+
* one store. Chainable.
|
|
52
|
+
*/
|
|
53
|
+
set(key: string, value: unknown): this;
|
|
54
|
+
/** Read an app-wide value set via `set()` (or any config key). */
|
|
55
|
+
get<T = unknown>(key: string, fallback?: T): T;
|
|
56
|
+
/**
|
|
57
|
+
* Subscribe to an app event — delegates to the `Events` singleton so
|
|
58
|
+
* `app.on(...)` and the global `listen()` helper reach the same emitter.
|
|
59
|
+
* Returns an unsubscribe function.
|
|
60
|
+
*/
|
|
61
|
+
on<T = unknown>(event: string, listener: Listener<T>): () => void;
|
|
62
|
+
/** Subscribe for a single emission. Returns an unsubscribe function. */
|
|
63
|
+
once<T = unknown>(event: string, listener: Listener<T>): () => void;
|
|
64
|
+
/** Unsubscribe a listener registered with `on()`/`once()`. */
|
|
65
|
+
off<T = unknown>(event: string, listener: Listener<T>): this;
|
|
66
|
+
/** Emit an app event, awaiting every listener in registration order. */
|
|
67
|
+
emit<T = unknown>(event: string, payload?: T): Promise<void>;
|
|
30
68
|
/** Merge a config object into the repository under its top-level keys. */
|
|
31
69
|
private mergeConfig;
|
|
32
70
|
/** Load .env via dotenv (Node only; no-op elsewhere). */
|
|
33
71
|
private loadEnv;
|
|
34
72
|
/** Load every /config/*.ts file under its filename key (Node only). */
|
|
35
73
|
private loadConfig;
|
|
36
|
-
/** Register a provider
|
|
37
|
-
register(Provider: ProviderClass): this;
|
|
74
|
+
/** Register a provider, optionally with options passed to its constructor. */
|
|
75
|
+
register(Provider: ProviderClass, options?: unknown): this;
|
|
38
76
|
/** Load config, register providers, then boot them. */
|
|
39
77
|
boot(providers?: ProviderClass[], options?: BootOptions): Promise<this>;
|
|
78
|
+
/**
|
|
79
|
+
* Run `hook` once the application has finished booting. If it's already
|
|
80
|
+
* booted, the hook runs immediately.
|
|
81
|
+
*/
|
|
82
|
+
onReady(hook: LifecycleHook): this;
|
|
83
|
+
/**
|
|
84
|
+
* Register a shutdown hook — close database/Redis connections, flush queues,
|
|
85
|
+
* etc. Hooks run in reverse registration order (LIFO) on `terminate()`.
|
|
86
|
+
*/
|
|
87
|
+
onShutdown(hook: LifecycleHook): this;
|
|
88
|
+
/**
|
|
89
|
+
* Gracefully shut the application down: run every shutdown hook (newest
|
|
90
|
+
* first). Idempotent — a second call is a no-op. A hook that throws doesn't
|
|
91
|
+
* stop the others; the first error is re-thrown after all have run.
|
|
92
|
+
*/
|
|
93
|
+
terminate(): Promise<void>;
|
|
94
|
+
/** Whether `terminate()` has run. */
|
|
95
|
+
get isTerminated(): boolean;
|
|
40
96
|
}
|
package/dist/core/application.js
CHANGED
|
@@ -19,6 +19,9 @@ export class Application extends Container {
|
|
|
19
19
|
basePath;
|
|
20
20
|
providers = [];
|
|
21
21
|
booted = false;
|
|
22
|
+
terminated = false;
|
|
23
|
+
readyHooks = [];
|
|
24
|
+
shutdownHooks = [];
|
|
22
25
|
constructor(basePath = ".") {
|
|
23
26
|
super();
|
|
24
27
|
this.basePath = basePath;
|
|
@@ -48,6 +51,53 @@ export class Application extends Container {
|
|
|
48
51
|
view() {
|
|
49
52
|
return this.make(View);
|
|
50
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Run a configurator function against the app and return the app for
|
|
56
|
+
* chaining — Feathers' lightweight plugin idiom, an inline alternative to a
|
|
57
|
+
* full `ServiceProvider`. The function may bind services, register routes, or
|
|
58
|
+
* merge config. Use `register()` when you need the register/boot two-phase
|
|
59
|
+
* lifecycle; use `configure()` for one-shot setup.
|
|
60
|
+
*
|
|
61
|
+
* app.configure(rateLimit({ max: 100 })).configure(auth());
|
|
62
|
+
*/
|
|
63
|
+
configure(configurator) {
|
|
64
|
+
configurator(this);
|
|
65
|
+
return this;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Set an app-wide value — Feathers' `app.set`. Backed by the config
|
|
69
|
+
* repository, so `app.set("db.url", …)` and `config().get("db.url")` share
|
|
70
|
+
* one store. Chainable.
|
|
71
|
+
*/
|
|
72
|
+
set(key, value) {
|
|
73
|
+
this.config().set(key, value);
|
|
74
|
+
return this;
|
|
75
|
+
}
|
|
76
|
+
/** Read an app-wide value set via `set()` (or any config key). */
|
|
77
|
+
get(key, fallback) {
|
|
78
|
+
return this.config().get(key, fallback);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Subscribe to an app event — delegates to the `Events` singleton so
|
|
82
|
+
* `app.on(...)` and the global `listen()` helper reach the same emitter.
|
|
83
|
+
* Returns an unsubscribe function.
|
|
84
|
+
*/
|
|
85
|
+
on(event, listener) {
|
|
86
|
+
return this.make(Events).on(event, listener);
|
|
87
|
+
}
|
|
88
|
+
/** Subscribe for a single emission. Returns an unsubscribe function. */
|
|
89
|
+
once(event, listener) {
|
|
90
|
+
return this.make(Events).once(event, listener);
|
|
91
|
+
}
|
|
92
|
+
/** Unsubscribe a listener registered with `on()`/`once()`. */
|
|
93
|
+
off(event, listener) {
|
|
94
|
+
this.make(Events).off(event, listener);
|
|
95
|
+
return this;
|
|
96
|
+
}
|
|
97
|
+
/** Emit an app event, awaiting every listener in registration order. */
|
|
98
|
+
async emit(event, payload) {
|
|
99
|
+
await this.make(Events).emit(event, payload);
|
|
100
|
+
}
|
|
51
101
|
/** Merge a config object into the repository under its top-level keys. */
|
|
52
102
|
mergeConfig(data) {
|
|
53
103
|
const repo = this.make(Config);
|
|
@@ -91,9 +141,9 @@ export class Application extends Container {
|
|
|
91
141
|
// No config dir or not on Node — fine.
|
|
92
142
|
}
|
|
93
143
|
}
|
|
94
|
-
/** Register a provider
|
|
95
|
-
register(Provider) {
|
|
96
|
-
const provider = new Provider(this);
|
|
144
|
+
/** Register a provider, optionally with options passed to its constructor. */
|
|
145
|
+
register(Provider, options) {
|
|
146
|
+
const provider = new Provider(this, options);
|
|
97
147
|
this.providers.push(provider);
|
|
98
148
|
return this;
|
|
99
149
|
}
|
|
@@ -119,6 +169,52 @@ export class Application extends Container {
|
|
|
119
169
|
await provider.boot();
|
|
120
170
|
}
|
|
121
171
|
this.booted = true;
|
|
172
|
+
for (const hook of this.readyHooks)
|
|
173
|
+
await hook(this);
|
|
122
174
|
return this;
|
|
123
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* Run `hook` once the application has finished booting. If it's already
|
|
178
|
+
* booted, the hook runs immediately.
|
|
179
|
+
*/
|
|
180
|
+
onReady(hook) {
|
|
181
|
+
if (this.booted)
|
|
182
|
+
void hook(this);
|
|
183
|
+
else
|
|
184
|
+
this.readyHooks.push(hook);
|
|
185
|
+
return this;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Register a shutdown hook — close database/Redis connections, flush queues,
|
|
189
|
+
* etc. Hooks run in reverse registration order (LIFO) on `terminate()`.
|
|
190
|
+
*/
|
|
191
|
+
onShutdown(hook) {
|
|
192
|
+
this.shutdownHooks.push(hook);
|
|
193
|
+
return this;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Gracefully shut the application down: run every shutdown hook (newest
|
|
197
|
+
* first). Idempotent — a second call is a no-op. A hook that throws doesn't
|
|
198
|
+
* stop the others; the first error is re-thrown after all have run.
|
|
199
|
+
*/
|
|
200
|
+
async terminate() {
|
|
201
|
+
if (this.terminated)
|
|
202
|
+
return;
|
|
203
|
+
this.terminated = true;
|
|
204
|
+
let firstError;
|
|
205
|
+
for (const hook of [...this.shutdownHooks].reverse()) {
|
|
206
|
+
try {
|
|
207
|
+
await hook(this);
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
firstError ??= err;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (firstError)
|
|
214
|
+
throw firstError;
|
|
215
|
+
}
|
|
216
|
+
/** Whether `terminate()` has run. */
|
|
217
|
+
get isTerminated() {
|
|
218
|
+
return this.terminated;
|
|
219
|
+
}
|
|
124
220
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authorization — gates and policies over the top of authentication. Where
|
|
3
|
+
* `auth()` answers *who you are*, this answers *what you're allowed to do*.
|
|
4
|
+
*
|
|
5
|
+
* // a gate: an ad-hoc ability
|
|
6
|
+
* define("update-post", (user, post) => post.authorId === user.id);
|
|
7
|
+
* if (await can("update-post", post)) { ... }
|
|
8
|
+
* await authorize("update-post", post); // throws 403 otherwise
|
|
9
|
+
*
|
|
10
|
+
* // a policy: abilities grouped per model
|
|
11
|
+
* class PostPolicy {
|
|
12
|
+
* update(user, post) { return post.authorId === user.id; }
|
|
13
|
+
* delete(user, post) { return user.admin || post.authorId === user.id; }
|
|
14
|
+
* }
|
|
15
|
+
* policy(Post, PostPolicy);
|
|
16
|
+
* await can("update", post); // routes to PostPolicy.update
|
|
17
|
+
*
|
|
18
|
+
* The current user is resolved from `auth().user()` by default; override with
|
|
19
|
+
* `setUserResolver` (e.g. token auth) or check a specific user with `canFor`.
|
|
20
|
+
*/
|
|
21
|
+
type User = unknown;
|
|
22
|
+
type Args = unknown[];
|
|
23
|
+
type Constructor = new (...args: never[]) => object;
|
|
24
|
+
/** A gate callback — receives the user and the checked arguments. */
|
|
25
|
+
export type GateCallback = (user: User, ...args: Args) => boolean | Promise<boolean>;
|
|
26
|
+
/** Runs before every check; return a boolean to short-circuit (e.g. admin bypass). */
|
|
27
|
+
export type BeforeCallback = (user: User, ability: string, args: Args) => boolean | undefined | Promise<boolean | undefined>;
|
|
28
|
+
/** Define a gate — an ad-hoc ability keyed by name. */
|
|
29
|
+
export declare function define(ability: string, callback: GateCallback): void;
|
|
30
|
+
/**
|
|
31
|
+
* Register a policy (instance or class) for a model — its methods are abilities.
|
|
32
|
+
* `impl` is intentionally loose (`object` / class) so a normally-typed policy
|
|
33
|
+
* class — `update(user: User, post: Post)` — fits without wrestling the compiler.
|
|
34
|
+
*/
|
|
35
|
+
export declare function policy(model: Constructor, impl: object | (new () => object)): void;
|
|
36
|
+
/** Register a callback that runs before every check (return a boolean to decide). */
|
|
37
|
+
export declare function gateBefore(callback: BeforeCallback): void;
|
|
38
|
+
/** Override how the "current user" is resolved (default: `auth().user()`). */
|
|
39
|
+
export declare function setUserResolver(resolver: () => User | Promise<User>): void;
|
|
40
|
+
/** Reset gates, policies, the before-hook, and the resolver (test helper). */
|
|
41
|
+
export declare function clearAuthorization(): void;
|
|
42
|
+
/** Whether the current user is allowed the ability (with the given arguments). */
|
|
43
|
+
export declare function can(ability: string, ...args: Args): Promise<boolean>;
|
|
44
|
+
/** The negation of `can`. */
|
|
45
|
+
export declare function cannot(ability: string, ...args: Args): Promise<boolean>;
|
|
46
|
+
/** Like `can`, but for a specific user rather than the current one. */
|
|
47
|
+
export declare function canFor(user: User, ability: string, ...args: Args): Promise<boolean>;
|
|
48
|
+
/** Throw a 403 `ForbiddenException` unless the current user is allowed. */
|
|
49
|
+
export declare function authorize(ability: string, ...args: Args): Promise<void>;
|
|
50
|
+
/** Like `authorize`, but for a specific user. */
|
|
51
|
+
export declare function authorizeFor(user: User, ability: string, ...args: Args): Promise<void>;
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authorization — gates and policies over the top of authentication. Where
|
|
3
|
+
* `auth()` answers *who you are*, this answers *what you're allowed to do*.
|
|
4
|
+
*
|
|
5
|
+
* // a gate: an ad-hoc ability
|
|
6
|
+
* define("update-post", (user, post) => post.authorId === user.id);
|
|
7
|
+
* if (await can("update-post", post)) { ... }
|
|
8
|
+
* await authorize("update-post", post); // throws 403 otherwise
|
|
9
|
+
*
|
|
10
|
+
* // a policy: abilities grouped per model
|
|
11
|
+
* class PostPolicy {
|
|
12
|
+
* update(user, post) { return post.authorId === user.id; }
|
|
13
|
+
* delete(user, post) { return user.admin || post.authorId === user.id; }
|
|
14
|
+
* }
|
|
15
|
+
* policy(Post, PostPolicy);
|
|
16
|
+
* await can("update", post); // routes to PostPolicy.update
|
|
17
|
+
*
|
|
18
|
+
* The current user is resolved from `auth().user()` by default; override with
|
|
19
|
+
* `setUserResolver` (e.g. token auth) or check a specific user with `canFor`.
|
|
20
|
+
*/
|
|
21
|
+
import { ForbiddenException } from "./exceptions.js";
|
|
22
|
+
import { auth } from "./auth.js";
|
|
23
|
+
const gates = new Map();
|
|
24
|
+
const policies = new Map();
|
|
25
|
+
let beforeCallback;
|
|
26
|
+
let userResolver = () => auth().user();
|
|
27
|
+
/** Define a gate — an ad-hoc ability keyed by name. */
|
|
28
|
+
export function define(ability, callback) {
|
|
29
|
+
gates.set(ability, callback);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Register a policy (instance or class) for a model — its methods are abilities.
|
|
33
|
+
* `impl` is intentionally loose (`object` / class) so a normally-typed policy
|
|
34
|
+
* class — `update(user: User, post: Post)` — fits without wrestling the compiler.
|
|
35
|
+
*/
|
|
36
|
+
export function policy(model, impl) {
|
|
37
|
+
const instance = typeof impl === "function" ? new impl() : impl;
|
|
38
|
+
policies.set(model, instance);
|
|
39
|
+
}
|
|
40
|
+
/** Register a callback that runs before every check (return a boolean to decide). */
|
|
41
|
+
export function gateBefore(callback) {
|
|
42
|
+
beforeCallback = callback;
|
|
43
|
+
}
|
|
44
|
+
/** Override how the "current user" is resolved (default: `auth().user()`). */
|
|
45
|
+
export function setUserResolver(resolver) {
|
|
46
|
+
userResolver = resolver;
|
|
47
|
+
}
|
|
48
|
+
/** Reset gates, policies, the before-hook, and the resolver (test helper). */
|
|
49
|
+
export function clearAuthorization() {
|
|
50
|
+
gates.clear();
|
|
51
|
+
policies.clear();
|
|
52
|
+
beforeCallback = undefined;
|
|
53
|
+
userResolver = () => auth().user();
|
|
54
|
+
}
|
|
55
|
+
async function evaluate(user, ability, args) {
|
|
56
|
+
if (beforeCallback) {
|
|
57
|
+
const decided = await beforeCallback(user, ability, args);
|
|
58
|
+
if (typeof decided === "boolean")
|
|
59
|
+
return decided;
|
|
60
|
+
}
|
|
61
|
+
// Policy: the first argument is a model with a registered policy + matching method.
|
|
62
|
+
const subject = args[0];
|
|
63
|
+
if (subject && typeof subject === "object") {
|
|
64
|
+
const found = policies.get(subject.constructor);
|
|
65
|
+
const method = found?.[ability];
|
|
66
|
+
if (typeof method === "function")
|
|
67
|
+
return Boolean(await method.call(found, user, ...args));
|
|
68
|
+
}
|
|
69
|
+
const gate = gates.get(ability);
|
|
70
|
+
if (gate)
|
|
71
|
+
return Boolean(await gate(user, ...args));
|
|
72
|
+
return false; // unknown ability — deny by default
|
|
73
|
+
}
|
|
74
|
+
/** Whether the current user is allowed the ability (with the given arguments). */
|
|
75
|
+
export async function can(ability, ...args) {
|
|
76
|
+
return evaluate(await userResolver(), ability, args);
|
|
77
|
+
}
|
|
78
|
+
/** The negation of `can`. */
|
|
79
|
+
export async function cannot(ability, ...args) {
|
|
80
|
+
return !(await can(ability, ...args));
|
|
81
|
+
}
|
|
82
|
+
/** Like `can`, but for a specific user rather than the current one. */
|
|
83
|
+
export async function canFor(user, ability, ...args) {
|
|
84
|
+
return evaluate(user, ability, args);
|
|
85
|
+
}
|
|
86
|
+
/** Throw a 403 `ForbiddenException` unless the current user is allowed. */
|
|
87
|
+
export async function authorize(ability, ...args) {
|
|
88
|
+
if (!(await can(ability, ...args))) {
|
|
89
|
+
throw new ForbiddenException(`Unauthorized: ${ability}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Like `authorize`, but for a specific user. */
|
|
93
|
+
export async function authorizeFor(user, ability, ...args) {
|
|
94
|
+
if (!(await canFor(user, ability, ...args))) {
|
|
95
|
+
throw new ForbiddenException(`Unauthorized: ${ability}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Broadcasting — push events to clients over named channels, in real time. Like
|
|
3
|
+
* the database and mail layers it's built on a pluggable `Broadcaster`, so the
|
|
4
|
+
* core owns no socket: point it at Pusher/Ably (`fetch`), a Cloudflare Durable
|
|
5
|
+
* Object, or the built-in `MemoryBroadcaster` (in-process fan-out, for tests and
|
|
6
|
+
* single-instance workers).
|
|
7
|
+
*
|
|
8
|
+
* await broadcast("orders.42", "status", { state: "shipped" });
|
|
9
|
+
*
|
|
10
|
+
* Private and presence channels are gated by `channelAuth` — register who may
|
|
11
|
+
* subscribe, then have your socket endpoint call `authorizeChannel`. It composes
|
|
12
|
+
* with `auth()` and the authorization layer.
|
|
13
|
+
*/
|
|
14
|
+
/** The bridge to a real-time backend — implement `publish` for your provider. */
|
|
15
|
+
export interface Broadcaster {
|
|
16
|
+
publish(channels: string[], event: string, payload: unknown): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
/** A same-process subscriber (e.g. a Durable Object or an SSE loop). */
|
|
19
|
+
export type Subscriber = (event: string, payload: unknown, channel: string) => void;
|
|
20
|
+
/** In-process pub/sub — the default; fans out to local subscribers. Ideal for tests. */
|
|
21
|
+
export declare class MemoryBroadcaster implements Broadcaster {
|
|
22
|
+
private subs;
|
|
23
|
+
publish(channels: string[], event: string, payload: unknown): Promise<void>;
|
|
24
|
+
/** Listen on a channel; returns an unsubscribe function. */
|
|
25
|
+
subscribe(channel: string, subscriber: Subscriber): () => void;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Decides whether `user` may join a channel. Return `false` to deny, `true` to
|
|
29
|
+
* allow, or an object of member data to allow *and* join a presence channel.
|
|
30
|
+
*/
|
|
31
|
+
export type ChannelAuthorizer = (user: unknown, params: Record<string, string>) => boolean | Record<string, unknown> | Promise<boolean | Record<string, unknown>>;
|
|
32
|
+
/**
|
|
33
|
+
* Register an authorizer for a channel name pattern. `{param}` segments are
|
|
34
|
+
* captured and handed to the authorizer, e.g. `channelAuth("orders.{id}", …)`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function channelAuth(pattern: string, authorizer: ChannelAuthorizer): void;
|
|
37
|
+
/**
|
|
38
|
+
* Authorize `user` for `channel`. Runs the first matching `channelAuth` rule and
|
|
39
|
+
* returns its result; a channel with no rule is public and returns `true`.
|
|
40
|
+
*/
|
|
41
|
+
export declare function authorizeChannel(channel: string, user: unknown): Promise<boolean | Record<string, unknown>>;
|
|
42
|
+
/** Clear all channel rules (test helper). */
|
|
43
|
+
export declare function clearChannels(): void;
|
|
44
|
+
/** Register the default broadcaster used by `broadcast()`. */
|
|
45
|
+
export declare function setBroadcaster(instance: Broadcaster): Broadcaster;
|
|
46
|
+
/** The default broadcaster. */
|
|
47
|
+
export declare function getBroadcaster(): Broadcaster;
|
|
48
|
+
/** Publish an event with a payload to one or more channels. */
|
|
49
|
+
export declare function broadcast(channels: string | string[], event: string, payload?: unknown): Promise<void>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Broadcasting — push events to clients over named channels, in real time. Like
|
|
3
|
+
* the database and mail layers it's built on a pluggable `Broadcaster`, so the
|
|
4
|
+
* core owns no socket: point it at Pusher/Ably (`fetch`), a Cloudflare Durable
|
|
5
|
+
* Object, or the built-in `MemoryBroadcaster` (in-process fan-out, for tests and
|
|
6
|
+
* single-instance workers).
|
|
7
|
+
*
|
|
8
|
+
* await broadcast("orders.42", "status", { state: "shipped" });
|
|
9
|
+
*
|
|
10
|
+
* Private and presence channels are gated by `channelAuth` — register who may
|
|
11
|
+
* subscribe, then have your socket endpoint call `authorizeChannel`. It composes
|
|
12
|
+
* with `auth()` and the authorization layer.
|
|
13
|
+
*/
|
|
14
|
+
/* ---------------------------- memory broadcaster -------------------------- */
|
|
15
|
+
/** In-process pub/sub — the default; fans out to local subscribers. Ideal for tests. */
|
|
16
|
+
export class MemoryBroadcaster {
|
|
17
|
+
subs = new Map();
|
|
18
|
+
async publish(channels, event, payload) {
|
|
19
|
+
for (const channel of channels) {
|
|
20
|
+
const set = this.subs.get(channel);
|
|
21
|
+
if (set)
|
|
22
|
+
for (const cb of set)
|
|
23
|
+
cb(event, payload, channel);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Listen on a channel; returns an unsubscribe function. */
|
|
27
|
+
subscribe(channel, subscriber) {
|
|
28
|
+
const set = this.subs.get(channel) ?? new Set();
|
|
29
|
+
set.add(subscriber);
|
|
30
|
+
this.subs.set(channel, set);
|
|
31
|
+
return () => {
|
|
32
|
+
set.delete(subscriber);
|
|
33
|
+
if (set.size === 0)
|
|
34
|
+
this.subs.delete(channel);
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const rules = [];
|
|
39
|
+
/**
|
|
40
|
+
* Register an authorizer for a channel name pattern. `{param}` segments are
|
|
41
|
+
* captured and handed to the authorizer, e.g. `channelAuth("orders.{id}", …)`.
|
|
42
|
+
*/
|
|
43
|
+
export function channelAuth(pattern, authorizer) {
|
|
44
|
+
const keys = [];
|
|
45
|
+
const source = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\{(\w+)\\\}/g, (_, k) => {
|
|
46
|
+
keys.push(k);
|
|
47
|
+
return "([^.]+)";
|
|
48
|
+
});
|
|
49
|
+
rules.push({ regex: new RegExp(`^${source}$`), keys, authorizer });
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Authorize `user` for `channel`. Runs the first matching `channelAuth` rule and
|
|
53
|
+
* returns its result; a channel with no rule is public and returns `true`.
|
|
54
|
+
*/
|
|
55
|
+
export async function authorizeChannel(channel, user) {
|
|
56
|
+
for (const rule of rules) {
|
|
57
|
+
const match = rule.regex.exec(channel);
|
|
58
|
+
if (!match)
|
|
59
|
+
continue;
|
|
60
|
+
const params = {};
|
|
61
|
+
rule.keys.forEach((key, i) => (params[key] = match[i + 1]));
|
|
62
|
+
return rule.authorizer(user, params);
|
|
63
|
+
}
|
|
64
|
+
return true; // public channel
|
|
65
|
+
}
|
|
66
|
+
/** Clear all channel rules (test helper). */
|
|
67
|
+
export function clearChannels() {
|
|
68
|
+
rules.length = 0;
|
|
69
|
+
}
|
|
70
|
+
/* -------------------------------- global ---------------------------------- */
|
|
71
|
+
let broadcaster = new MemoryBroadcaster();
|
|
72
|
+
/** Register the default broadcaster used by `broadcast()`. */
|
|
73
|
+
export function setBroadcaster(instance) {
|
|
74
|
+
broadcaster = instance;
|
|
75
|
+
return broadcaster;
|
|
76
|
+
}
|
|
77
|
+
/** The default broadcaster. */
|
|
78
|
+
export function getBroadcaster() {
|
|
79
|
+
return broadcaster;
|
|
80
|
+
}
|
|
81
|
+
/** Publish an event with a payload to one or more channels. */
|
|
82
|
+
export function broadcast(channels, event, payload) {
|
|
83
|
+
return broadcaster.publish(Array.isArray(channels) ? channels : [channels], event, payload);
|
|
84
|
+
}
|