@zerotal/arch 1.8.1 → 1.10.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/api-surface.md +6 -6
- package/docs/admin/actions.md +15 -0
- package/docs/admin/auth.md +10 -0
- package/docs/admin/dashboard.md +12 -0
- package/docs/admin/extending-ui.md +14 -0
- package/docs/admin/forms.md +15 -0
- package/docs/admin/operations.md +12 -0
- package/docs/admin/resources.md +6 -0
- package/docs/admin/tables.md +21 -0
- package/docs/audit.md +5 -0
- package/docs/authentication.md +110 -1
- package/docs/broadcasting/references.md +17 -0
- package/docs/cache.md +5 -0
- package/docs/carbon.md +5 -0
- package/docs/changelog.md +281 -0
- package/docs/client/index.md +17 -0
- package/docs/commands.md +6 -0
- package/docs/components.md +73 -0
- package/docs/config-system.md +54 -0
- package/docs/cookies.md +6 -0
- package/docs/deployment.md +151 -13
- package/docs/devtools.md +5 -0
- package/docs/email-verification.md +26 -1
- package/docs/encryption.md +21 -0
- package/docs/errors.md +2 -0
- package/docs/flow/components.md +54 -0
- package/docs/flow/forms.md +57 -0
- package/docs/flow/references.md +14 -0
- package/docs/getting-started.md +38 -0
- package/docs/health.md +19 -0
- package/docs/helpers.md +150 -0
- package/docs/i18n.md +5 -0
- package/docs/inertia/middleware.md +44 -0
- package/docs/inertia/props.md +70 -0
- package/docs/inertia/ssr.md +95 -10
- package/docs/lock.md +15 -0
- package/docs/logger.md +38 -0
- package/docs/middleware.md +31 -0
- package/docs/migrations.md +47 -0
- package/docs/monitor.md +59 -0
- package/docs/notifications.md +11 -0
- package/docs/orm/casts.md +6 -0
- package/docs/orm/lifecycle.md +18 -0
- package/docs/orm/queries.md +10 -0
- package/docs/orm/relationships.md +30 -0
- package/docs/queue.md +10 -0
- package/docs/rate-limiting.md +84 -21
- package/docs/responses.md +23 -0
- package/docs/routing.md +16 -0
- package/docs/scheduler.md +82 -8
- package/docs/session.md +6 -0
- package/docs/social.md +10 -0
- package/docs/storage.md +21 -0
- package/docs/support-policy.md +13 -1
- package/docs/telemetry.md +8 -0
- package/docs/tenancy.md +6 -0
- package/docs/testing/index.md +105 -0
- package/docs/upgrade.md +48 -0
- package/docs/validator.md +9 -0
- package/docs/view.md +6 -0
- package/package.json +3 -3
- package/src/install/guidelines.ts +1 -1
- package/src/mcp/stdio.ts +3 -3
- package/src/tools/_probe.ts +2 -2
package/docs/monitor.md
CHANGED
|
@@ -262,6 +262,40 @@ Every colour in the panel resolves to a token — `bg-card`, `text-muted-foregro
|
|
|
262
262
|
|
|
263
263
|
Practically, this means re-branding the monitor is a few CSS variables rather than a fork, and a contributed section written against the same tokens matches the built-in ones for free.
|
|
264
264
|
|
|
265
|
+
## The snapshot shape
|
|
266
|
+
|
|
267
|
+
**Export JSON** hands you a `MonitorSnapshot` — the whole panel for the selected range, as data.
|
|
268
|
+
It is worth knowing the shape if you post it somewhere, diff two of them, or drive an alerting
|
|
269
|
+
integration off it rather than off the built-in thresholds.
|
|
270
|
+
|
|
271
|
+
`range` is a `MonitorRange`, and the rest is one field per thing the panel draws:
|
|
272
|
+
|
|
273
|
+
| Area | Fields and their row types |
|
|
274
|
+
| -------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
275
|
+
| Live | `pulse: PulseStats` — in-flight requests, connections, rate, error rate. Not windowed. |
|
|
276
|
+
| Overview | `statCards: StatCard[]`, `percentiles: Percentile[]`, `apdex`, `throughput`, `slowRoutes: RouteStat[]` |
|
|
277
|
+
| Requests | `requests` / `slowRequests: RequestEntry[]`, `topUsers: UserUsage[]`, `topMemory: MemoryRoute[]` |
|
|
278
|
+
| Outgoing | `outgoingHttp: OutgoingHttp[]` |
|
|
279
|
+
| Queues | `queueStats: QueueMetric[]`, `queues: QueueRow[]`, `failedJobs: FailedJob[]`, `deadLetter: DeadJob[]` |
|
|
280
|
+
| Schedule | `scheduledJobs: ScheduledJob[]`, `scheduledRuns: FeedEvent[]`, `slowJobs` |
|
|
281
|
+
| Database | `dbStats: DbStat[]`, `slowQueries: SlowQuery[]`, `transactions: TxStats`, `nplusOnes: NPlusOne[]` |
|
|
282
|
+
| Exceptions | `exceptions: ExceptionGroup[]` — grouped, so one recurring error is one row. |
|
|
283
|
+
| Cache & realtime | `cache: CacheStats`, `realtime: RealtimeStats` |
|
|
284
|
+
| Mail & notifications | `mail`, `notifications: NotificationEntry[]` |
|
|
285
|
+
| Models | `models`, `recentModels: ModelEvent[]` |
|
|
286
|
+
| Health | `health: HealthEntry[]`, `gauges: Gauge[]`, `commands: CommandEntry[]` |
|
|
287
|
+
| Feeds | `security` / `logs: FeedEvent[]`, `alertHistory: AlertEntry[]` |
|
|
288
|
+
|
|
289
|
+
A few names carry more than their field suggests. `CacheKey` and `CacheStats` separate the hot
|
|
290
|
+
keys from the aggregate. `StatusClassCount` is the 2xx/3xx/4xx/5xx split behind an error rate.
|
|
291
|
+
`ConnectedClient` and `WsAction` are what `realtime` counts. `CheckIn` is a heartbeat from a
|
|
292
|
+
scheduled job that reported in, `UptimeCheck` an external probe, `RequestSpan` / `RequestQuery`
|
|
293
|
+
/ `RequestPayload` / `RequestLog` the detail behind one `RequestEntry`, and `RouteDetail` the
|
|
294
|
+
per-route drill-down. `SystemMeta` and `StorageInfo` describe the machine rather than the app.
|
|
295
|
+
|
|
296
|
+
`Tone` (and `MonitorTone`) is the good/warn/bad colouring the panel applies to a stat, and
|
|
297
|
+
`AlertContext` is what an alert carries when it fires.
|
|
298
|
+
|
|
265
299
|
## Adding your own sections
|
|
266
300
|
|
|
267
301
|
The panel owns the shell, the time-range selector, the storage and the retention policy — but it doesn't own the knowledge of what is worth watching about any given package. So it's a **host**: it publishes a write surface as the `monitor.panel` container binding, and a package pushes a section into it at boot.
|
|
@@ -318,6 +352,25 @@ export default MonitorConfig({
|
|
|
318
352
|
});
|
|
319
353
|
```
|
|
320
354
|
|
|
355
|
+
### Typing a section
|
|
356
|
+
|
|
357
|
+
Declaring the host's shape locally is the documented path and stays supported. If you would
|
|
358
|
+
rather have the real types — because your `resolve` is large enough that a typo in a column key
|
|
359
|
+
should be a compile error rather than a blank cell — they are exported:
|
|
360
|
+
|
|
361
|
+
| Type | What it is |
|
|
362
|
+
| -------------------- | -------------------------------------------------------------------------- |
|
|
363
|
+
| `MonitorSection` | The whole contribution: `id`, `label`, optional `group`, and `resolve`. |
|
|
364
|
+
| `MonitorSectionData` | What `resolve` returns — the `stats` and `tables` below. |
|
|
365
|
+
| `MonitorStat` | One figure: label, value, optional `percent` and `tone`. |
|
|
366
|
+
| `MonitorTable` | One table: title, columns, rows, and the `empty` line when there are none. |
|
|
367
|
+
| `MonitorTableColumn` | One column: `key`, `label`, and the `mono` / `align` presentation flags. |
|
|
368
|
+
| `MonitorRow` | One row — a record keyed by the columns' `key` values. |
|
|
369
|
+
|
|
370
|
+
Importing them means depending on `@zerotal/monitor`, which is the trade the structural form
|
|
371
|
+
exists to avoid. For a section of two stats and one table, the local interface is still the
|
|
372
|
+
better answer.
|
|
373
|
+
|
|
321
374
|
### Scheduled tasks
|
|
322
375
|
|
|
323
376
|
`@zerotal/scheduler` ships the first contributed section. A cron task that silently stopped firing is one of the harder failures to notice — nothing errors, work just stops happening — so the section leads with counts of tasks that are failing or have never run, then lists every task with its cron expression, last result, duration and next due time. Install both providers and it appears under **Infrastructure**; no configuration.
|
|
@@ -369,6 +422,12 @@ Types: `MonitorConfigShape`, `ResolvedMonitorConfig`, `MonitorStoreOptions`,
|
|
|
369
422
|
`@zerotal/monitor` ships no CLI commands. The panel is a route, not a console
|
|
370
423
|
tool — everything is read through the browser or the Prometheus endpoint.
|
|
371
424
|
|
|
425
|
+
### The metrics snapshot
|
|
426
|
+
|
|
427
|
+
`httpMetrics()` returns an `HttpMetricsSnapshot` — request counts, durations and status classes
|
|
428
|
+
since the process started. It is what the Prometheus endpoint renders, and it is exported so an
|
|
429
|
+
app can ship the same numbers somewhere the panel does not reach.
|
|
430
|
+
|
|
372
431
|
## Next steps
|
|
373
432
|
|
|
374
433
|
- [Telemetry](/docs/telemetry) — export the same signal to an OTLP backend for long-term storage.
|
package/docs/notifications.md
CHANGED
|
@@ -840,6 +840,17 @@ durable record is the database channel.
|
|
|
840
840
|
| `OnDemandNotifiable` | The recipient `Notify.route()` builds — a destination with no model behind it. Its database rows are keyed to a random id nothing can query back, so on-demand notifications normally declare transport channels only. |
|
|
841
841
|
| `RichLine` | The chainable line returned inside `MailMessage` for mixed formatting (`.text()`, `.color()`). |
|
|
842
842
|
|
|
843
|
+
## Types
|
|
844
|
+
|
|
845
|
+
| Type | What it is |
|
|
846
|
+
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
847
|
+
| `MailDriver` | The transport contract — implement it to send through something the built-ins do not cover. |
|
|
848
|
+
| `MailPayload` | One message as the driver receives it. |
|
|
849
|
+
| `MailAddress`, `AddressInput` | A recipient, and the forms one may be given in. |
|
|
850
|
+
| `MailAttachment` | A file on a message. |
|
|
851
|
+
| `TextStyle` | How the plain-text alternative is derived from the HTML. |
|
|
852
|
+
| `SmsConfigShape`, `TwilioConfigShape`, `VonageConfigShape` | SMS configuration, and the two providers' own settings. |
|
|
853
|
+
|
|
843
854
|
## Next steps
|
|
844
855
|
|
|
845
856
|
- [Broadcasting](/docs/broadcasting) — the real-time broadcast channel and channel auth.
|
package/docs/orm/casts.md
CHANGED
|
@@ -357,6 +357,12 @@ Cast options accepted by `@column()`:
|
|
|
357
357
|
| `cast` | shorthand string, `{ get, set }`, or `CastContract` | The transform applied on read/write. |
|
|
358
358
|
| `enumValues` | `Record<string, string \| number>` | The TS enum object, paired with `cast: "enum"`. |
|
|
359
359
|
|
|
360
|
+
## Types
|
|
361
|
+
|
|
362
|
+
`CastField` is what a cast declaration holds. `EncryptedCastName` names the encrypted cast
|
|
363
|
+
variants, and `isEncryptedCast` says whether a given cast is one — which matters because an
|
|
364
|
+
encrypted column cannot be queried by value, only by a blind index kept beside it.
|
|
365
|
+
|
|
360
366
|
## Next steps
|
|
361
367
|
|
|
362
368
|
- [ORM](/docs/orm) — defining models and columns.
|
package/docs/orm/lifecycle.md
CHANGED
|
@@ -413,6 +413,24 @@ the two are identical.
|
|
|
413
413
|
| `static dispatchesEvents` | `Record<string, new (model) => object>` | Maps lifecycle keys to app-bus event classes. |
|
|
414
414
|
| `static massPrune` | `boolean` (default `false`) | Permanently delete prunable rows instead of soft-deleting. |
|
|
415
415
|
|
|
416
|
+
## Types
|
|
417
|
+
|
|
418
|
+
**State machines.** `StateMachine` guards a column against transitions that should not happen —
|
|
419
|
+
an order going from `shipped` back to `pending` is a bug, and the place to refuse it is the model
|
|
420
|
+
rather than every call site.
|
|
421
|
+
|
|
422
|
+
| Type | What it is |
|
|
423
|
+
| ----------------------------------------- | ---------------------------------------------------------------------- |
|
|
424
|
+
| `StateGuard` | The condition allowing one transition. |
|
|
425
|
+
| `TransitionCallback`, `TransitionContext` | What runs on a transition, and what it receives. |
|
|
426
|
+
| `TransitionResult` | Whether it happened. |
|
|
427
|
+
| `RejectTransition` | The refusal — a value, so a caller can branch on it rather than catch. |
|
|
428
|
+
|
|
429
|
+
**Errors.** `TransactionError` wraps a failure inside a transaction with what was being
|
|
430
|
+
attempted; `UnsupportedDialectError` is thrown when a driver genuinely cannot do something —
|
|
431
|
+
a signed URL on a local disk, an advisory lock on SQLite — rather than failing quietly and
|
|
432
|
+
returning nothing. `TransactionContext` is the ambient handle a transaction carries.
|
|
433
|
+
|
|
416
434
|
## Next steps
|
|
417
435
|
|
|
418
436
|
- [ORM](/docs/orm) — model basics and the `dispatchesEvents` bridge.
|
package/docs/orm/queries.md
CHANGED
|
@@ -683,6 +683,16 @@ Child models inherit all global scopes registered on a parent model.
|
|
|
683
683
|
| `toRawSql` | `toRawSql(): string` | SQL with values inlined (logging only). |
|
|
684
684
|
| `clone` | `clone(): this` | Copy the builder to branch conditions. |
|
|
685
685
|
|
|
686
|
+
## Types
|
|
687
|
+
|
|
688
|
+
| Type | What it is |
|
|
689
|
+
| --------------------- | --------------------------------------------------------------- |
|
|
690
|
+
| `WhereOperator` | The comparison operators `where()` accepts. |
|
|
691
|
+
| `OrderDirection` | `asc` / `desc`. |
|
|
692
|
+
| `DatePart` | The parts date helpers can compare on — year, month, day. |
|
|
693
|
+
| `PaginateMeta` | The page, per-page, total and last-page a paginator reports. |
|
|
694
|
+
| `GlobalScopeCallback` | A scope applied to every query for a model until it is removed. |
|
|
695
|
+
|
|
686
696
|
## Next steps
|
|
687
697
|
|
|
688
698
|
- [ORM](/docs/orm) — model definition, columns, and configuration.
|
|
@@ -432,6 +432,36 @@ Pivot collection methods on a `ManyToMany<T>` relation.
|
|
|
432
432
|
| `sync` | `sync(ids[]): Promise<void>` | Replace all pivot rows with the given set. |
|
|
433
433
|
| `toggle` | `toggle(id \| id[]): Promise<void>` | Attach missing ids and detach present ones. |
|
|
434
434
|
|
|
435
|
+
### Types
|
|
436
|
+
|
|
437
|
+
Every decorator above returns a typed relation, and every one takes an options shape. Both are
|
|
438
|
+
exported, so a helper that builds relations or a signature that accepts one can be annotated.
|
|
439
|
+
|
|
440
|
+
| Type | What it is |
|
|
441
|
+
| -------------------- | ---------------------------------------------------------------------------------- |
|
|
442
|
+
| `HasOne<T>` | What `@hasOne` produces — one related record, or `null`. |
|
|
443
|
+
| `HasMany<T>` | What `@hasMany` produces — a queryable collection of related records. |
|
|
444
|
+
| `BelongsTo<T>` | What `@belongsTo` produces — the owning record, or `null`. |
|
|
445
|
+
| `RelationType` | The relation kinds as a union: `hasOne`, `hasMany`, `belongsTo`, `manyToMany`, … |
|
|
446
|
+
| `RelationDefinition` | One declared relation: its type, target model, and keys. |
|
|
447
|
+
| `RelationMetadata` | What the decorator records about a relation, read by eager loading and `whereHas`. |
|
|
448
|
+
| `RelationConstraint` | The callback form — `with({ posts: (q) => q.where(…) })`. |
|
|
449
|
+
| `WithLoaded<T, K>` | A model type narrowed to say which relations are loaded, so reading one is safe. |
|
|
450
|
+
|
|
451
|
+
The option shapes match their decorators: `HasManyThroughOptions`, `ManyToManyOptions`,
|
|
452
|
+
`MorphOneOptions`, `MorphManyOptions`, `MorphToOptions`, `MorphToManyOptions` and
|
|
453
|
+
`MorphedByManyOptions`.
|
|
454
|
+
|
|
455
|
+
`relationRegistry` is the map the decorators write into and eager loading reads back. It is
|
|
456
|
+
framework wiring rather than something an app calls, but it is exported because the testing
|
|
457
|
+
helpers reach for it.
|
|
458
|
+
|
|
459
|
+
> **`RelationNotLoadedError`** is thrown when you read a relation that was never loaded, rather
|
|
460
|
+
> than returning `undefined`. That is the whole reason the error exists: a silent `undefined`
|
|
461
|
+
> reads as "no related records" and is indistinguishable from a genuine empty result, so an
|
|
462
|
+
> N+1 you meant to fix becomes a page that quietly shows nothing. Load it with `with()`, or ask
|
|
463
|
+
> for it explicitly with `await post.load("author")`.
|
|
464
|
+
|
|
435
465
|
## Next steps
|
|
436
466
|
|
|
437
467
|
- [ORM queries](/docs/orm/queries) — eager loading, `whereHas`, and aggregates in depth.
|
package/docs/queue.md
CHANGED
|
@@ -550,6 +550,16 @@ try {
|
|
|
550
550
|
process is draining, not that anything is broken, so the right response is to
|
|
551
551
|
re-dispatch on the next boot rather than to fail the request.
|
|
552
552
|
|
|
553
|
+
## Types
|
|
554
|
+
|
|
555
|
+
| Type | What it is |
|
|
556
|
+
| -------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
557
|
+
| `QueueDriver` | The contract a backend implements — implement it to queue somewhere the built-ins do not reach. |
|
|
558
|
+
| `JobRecord`, `JobStatus` | A queued job as stored, and where it is in its life. |
|
|
559
|
+
| `SerializedJob` | The wire form — what a driver actually persists. |
|
|
560
|
+
| `BatchOptions`, `BatchRecord`, `BatchStatus` | A batch's settings, its stored form, and its progress. |
|
|
561
|
+
| `WorkerPoolOptions`, `WorkerResult` | How many workers run and what one attempt returned. |
|
|
562
|
+
|
|
553
563
|
## Next steps
|
|
554
564
|
|
|
555
565
|
- [Scheduler](/docs/scheduler) — run recurring jobs alongside the queue worker.
|
package/docs/rate-limiting.md
CHANGED
|
@@ -65,10 +65,53 @@ ThrottleMiddleware.with({
|
|
|
65
65
|
| `keyResolver` | no | client IP | Function returning the rate-limit key for a request. |
|
|
66
66
|
| `trustedProxies` | no | `undefined` | Number of trusted upstream proxies (see the warning below). |
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
`X-Forwarded-For` is written by the client, so it is consulted **only** when
|
|
69
|
+
`trustedProxies` says how many proxies sit in front of the app — the count is what says
|
|
70
|
+
which entry is not attacker-controlled. Left `undefined` (or `0`), the unspoofable socket
|
|
71
|
+
address is used.
|
|
72
|
+
|
|
73
|
+
**Counted from the right, and that is the whole of it.** Each proxy _appends_ the address
|
|
74
|
+
it received the request from, so the rightmost entries are the ones your own
|
|
75
|
+
infrastructure wrote and the leftmost is whatever the client sent. Reading the header
|
|
76
|
+
left-to-right — the obvious way, and how most hand-rolled versions do it — hands the
|
|
77
|
+
limiter's key to the attacker: they set `X-Forwarded-For: <your CFO's IP>`, spend the
|
|
78
|
+
budget, and the person whose address they borrowed is locked out of the form. A limiter
|
|
79
|
+
that can be aimed is worse than no limiter, because it looks like it is working.
|
|
80
|
+
|
|
81
|
+
> **Danger** — That default is right, and it is the wrong answer the moment you deploy
|
|
82
|
+
> behind a proxy. The socket address is then the _proxy's_ — `127.0.0.1` for every visitor
|
|
83
|
+
> — so everyone shares one bucket per form and the limiter inverts into the thing it was
|
|
84
|
+
> installed to prevent: one attacker making twenty bad sign-ins a minute locks the whole
|
|
85
|
+
> staff out of the console. Nothing fails; you put Caddy in front, everything works, and
|
|
86
|
+
> the limiter quietly stops telling people apart. Set `trustedProxies` to the number of
|
|
87
|
+
> proxies you actually run. `zt doctor` warns when a production-like deployment has a
|
|
88
|
+
> throttle and no `trustedProxies` — see [Deployment](/docs/deployment#behind-a-reverse-proxy).
|
|
89
|
+
|
|
90
|
+
### One `.with()` call, one bucket
|
|
91
|
+
|
|
92
|
+
Each `.with()` call returns its own class, and the hit counter belongs to the class — so
|
|
93
|
+
**re-using one `.with()` export on two routes gives them a shared budget**:
|
|
94
|
+
|
|
95
|
+
```typescript fragment
|
|
96
|
+
// One bucket: 5 attempts across BOTH forms.
|
|
97
|
+
const AuthThrottle = ThrottleMiddleware.with({ maxAttempts: 5, windowSeconds: 60 });
|
|
98
|
+
Router.post("/login", AuthController, "login", [AuthThrottle]);
|
|
99
|
+
Router.post("/two-factor", AuthController, "challenge", [AuthThrottle]);
|
|
100
|
+
|
|
101
|
+
// A bucket each, which is almost always what was meant.
|
|
102
|
+
Router.post("/login", AuthController, "login", [
|
|
103
|
+
ThrottleMiddleware.with({ maxAttempts: 5, windowSeconds: 60 }),
|
|
104
|
+
]);
|
|
105
|
+
Router.post("/two-factor", AuthController, "challenge", [
|
|
106
|
+
ThrottleMiddleware.with({ maxAttempts: 5, windowSeconds: 60 }),
|
|
107
|
+
]);
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The sharing is deliberate — it is what lets you spend one allowance across a group of
|
|
111
|
+
related routes on purpose — and it is not what a reader expects from a factory. On a sign-in
|
|
112
|
+
flow it bites: a handful of fumbled passwords can spend the allowance a legitimate person
|
|
113
|
+
needs to answer their second factor. Call `.with()` once per thing that deserves its own
|
|
114
|
+
budget.
|
|
72
115
|
|
|
73
116
|
## RateLimiter — named limiters
|
|
74
117
|
|
|
@@ -82,8 +125,9 @@ import { RateLimiter } from "zerotal";
|
|
|
82
125
|
// 1000 req/hour per authenticated user (falls back to IP when unauthenticated)
|
|
83
126
|
RateLimiter.for("api").limit(1000).every(3600).byUser().register();
|
|
84
127
|
|
|
85
|
-
// 5 login attempts per minute, per IP
|
|
86
|
-
|
|
128
|
+
// 5 login attempts per minute, per IP. `.trustedProxies(1)` because this app is
|
|
129
|
+
// behind one reverse proxy — without it every visitor shares the proxy's address.
|
|
130
|
+
RateLimiter.for("login").limit(5).every(60).byIp().trustedProxies(1).register();
|
|
87
131
|
|
|
88
132
|
// 500 req/min keyed by an API-key header (unknown key → per IP)
|
|
89
133
|
RateLimiter.for("partner").limit(500).every(60).byApiKey("x-api-key").register();
|
|
@@ -105,12 +149,30 @@ RateLimiter.for("upload")
|
|
|
105
149
|
Each `.by*()` call sets how requests are bucketed. The default (no `.by*()` call)
|
|
106
150
|
is the client IP.
|
|
107
151
|
|
|
108
|
-
| Method | Keys on
|
|
109
|
-
| -------------------- |
|
|
110
|
-
| `.byUser()` | `ctx.user.id`
|
|
111
|
-
| `.byApiKey(header?)` | `x-api-key` header (or a custom header)
|
|
112
|
-
| `.byIp()` |
|
|
113
|
-
| `.by(fn)` | Return value of your function
|
|
152
|
+
| Method | Keys on | Falls back to |
|
|
153
|
+
| -------------------- | --------------------------------------- | ------------------------ |
|
|
154
|
+
| `.byUser()` | `ctx.user.id` | IP when unauthenticated |
|
|
155
|
+
| `.byApiKey(header?)` | `x-api-key` header (or a custom header) | IP when header is absent |
|
|
156
|
+
| `.byIp()` | Client IP (the explicit default) | `'unknown'` |
|
|
157
|
+
| `.by(fn)` | Return value of your function | — |
|
|
158
|
+
|
|
159
|
+
> **Danger** — **Every one of the built-in strategies can end up keying on an
|
|
160
|
+
> address**, including `.byUser()` and `.byApiKey()` — for a request with no user and
|
|
161
|
+
> no key, which on a login form is every request that matters. So a named limiter
|
|
162
|
+
> behind a reverse proxy needs `.trustedProxies(n)` for exactly the reason
|
|
163
|
+
> [`ThrottleMiddleware` does](#throttlemiddleware--inline): without it the address is
|
|
164
|
+
> the socket's, which is the _proxy's_, and every visitor shares one bucket. A
|
|
165
|
+
> `login` limiter of five attempts a minute becomes five attempts a minute for your
|
|
166
|
+
> whole user base, and one attacker locks everybody out.
|
|
167
|
+
>
|
|
168
|
+
> ```typescript fragment
|
|
169
|
+
> // config/limiters.ts — behind one reverse proxy
|
|
170
|
+
> RateLimiter.for("login").limit(5).every(60).byIp().trustedProxies(1).register();
|
|
171
|
+
> ```
|
|
172
|
+
>
|
|
173
|
+
> `zt doctor` reports a named limiter that keys on an address and was never told
|
|
174
|
+
> about a proxy. `.by(fn)` is yours — it is exempt, and resolving the address is on
|
|
175
|
+
> you if you use one.
|
|
114
176
|
|
|
115
177
|
### Applying a named limiter
|
|
116
178
|
|
|
@@ -228,15 +290,16 @@ await app.post("/login", { email: "a@b.c" }, { "X-Forwarded-For": "10.0.0.7" });
|
|
|
228
290
|
|
|
229
291
|
### `LimiterDefinition` (fluent)
|
|
230
292
|
|
|
231
|
-
| Method
|
|
232
|
-
|
|
|
233
|
-
| `limit`
|
|
234
|
-
| `every`
|
|
235
|
-
| `byUser`
|
|
236
|
-
| `byApiKey`
|
|
237
|
-
| `byIp`
|
|
238
|
-
| `by`
|
|
239
|
-
| `
|
|
293
|
+
| Method | Signature | Description |
|
|
294
|
+
| ---------------- | -------------------------------------------- | ---------------------------------------------------------- |
|
|
295
|
+
| `limit` | `limit(max: number): this` | Maximum requests in the window (default `60`). |
|
|
296
|
+
| `every` | `every(seconds: number): this` | Window duration in seconds (default `60`). |
|
|
297
|
+
| `byUser` | `byUser(): this` | Key by `ctx.user.id`; IP when unauthenticated. |
|
|
298
|
+
| `byApiKey` | `byApiKey(header?: string): this` | Key by header value (default `x-api-key`); IP when absent. |
|
|
299
|
+
| `byIp` | `byIp(): this` | Key by client IP (the explicit default). |
|
|
300
|
+
| `by` | `by(fn: (ctx: HttpContext) => string): this` | Key by your own resolver. |
|
|
301
|
+
| `trustedProxies` | `trustedProxies(count: number): this` | Proxies in front of the app — required behind one. |
|
|
302
|
+
| `register` | `register(): this` | Register the limiter with the global registry. |
|
|
240
303
|
|
|
241
304
|
### `ThrottleMiddleware`
|
|
242
305
|
|
package/docs/responses.md
CHANGED
|
@@ -327,6 +327,29 @@ Common status codes:
|
|
|
327
327
|
| `ctx.redirect(url, 303)` | 303 | After POST/PUT/DELETE |
|
|
328
328
|
| `ctx.redirect(url, 301)` | 301 | Permanent redirect |
|
|
329
329
|
|
|
330
|
+
## Negotiating by client
|
|
331
|
+
|
|
332
|
+
One route, three audiences. `negotiate(ctx)` picks a branch from the `Accept` header and how the
|
|
333
|
+
request arrived, so a handler answers a browser, an API client and the console without three
|
|
334
|
+
copies of the logic:
|
|
335
|
+
|
|
336
|
+
```typescript fragment
|
|
337
|
+
import { negotiate } from "zerotal/http";
|
|
338
|
+
|
|
339
|
+
await negotiate(http)({
|
|
340
|
+
web: () => http.redirect("/dashboard"),
|
|
341
|
+
api: () => http.json({ ok: true }),
|
|
342
|
+
cli: () => http.text("done"),
|
|
343
|
+
});
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
`NegotiateMap` is that object; `WebContext`, `ApiContext` and `CliContext` are what each branch
|
|
347
|
+
receives. A missing branch falls through to `web`, because the browser is the audience most
|
|
348
|
+
likely to be looking.
|
|
349
|
+
|
|
350
|
+
This is what the framework's own error handler uses, which is why a 422 is a redirect-with-errors
|
|
351
|
+
for a form post and a JSON body for a fetch — see [Errors](/docs/errors#response-format-by-client-type).
|
|
352
|
+
|
|
330
353
|
## Next steps
|
|
331
354
|
|
|
332
355
|
- [Requests Context](/docs/context) — read input from the incoming request.
|
package/docs/routing.md
CHANGED
|
@@ -1081,6 +1081,22 @@ Every route registration returns a chainable handle:
|
|
|
1081
1081
|
| `bun zt route:list` | — | Print every route (`-m` method, `-p` path, `--name`, `-v` middleware). |
|
|
1082
1082
|
| `bun zt route:types` | `--check` | Write `types/routes.generated.ts`; `--check` fails when it is stale. |
|
|
1083
1083
|
|
|
1084
|
+
## Types
|
|
1085
|
+
|
|
1086
|
+
| Type | What it is |
|
|
1087
|
+
| -------------------- | --------------------------------------------------------------------------------------------------------- |
|
|
1088
|
+
| `HttpMethod` | The verbs a route can answer on. |
|
|
1089
|
+
| `HttpResponse` | What a handler may return when it does not write to `ctx.response`. |
|
|
1090
|
+
| `ControllerResponse` | A controller action's return type — `void`, a `ResponseBuilder`, or a `MarkdownBuilder`. |
|
|
1091
|
+
| `RouteParams` | The params a pattern captures, as a record. |
|
|
1092
|
+
| `ParamsOf<P>` | The params of one pattern, derived from the pattern string — what makes `route()` refuse a missing `:id`. |
|
|
1093
|
+
| `RouteRegistry` | The generated name → pattern table, augmented by `types/routes.generated.ts`. |
|
|
1094
|
+
| `RouteMiddleware` | What a route's middleware list accepts — a class, an array, or the method map. |
|
|
1095
|
+
| `GroupOptions` | `Router.group({ prefix, middleware, domain })`. |
|
|
1096
|
+
| `RoutingConfig` | The `routing` config namespace. |
|
|
1097
|
+
| `FileRoutingConfig` | File-router settings within it. |
|
|
1098
|
+
| `WebSocketHandlers` | The handler set a WebSocket route registers. |
|
|
1099
|
+
|
|
1084
1100
|
## Next steps
|
|
1085
1101
|
|
|
1086
1102
|
- [Controllers](/docs/controllers) — move route logic out of closures.
|
package/docs/scheduler.md
CHANGED
|
@@ -61,17 +61,43 @@ import { SchedulerConfig } from "@zerotal/scheduler";
|
|
|
61
61
|
import { env } from "zerotal";
|
|
62
62
|
|
|
63
63
|
export default SchedulerConfig({
|
|
64
|
-
timezone: env("APP_TIMEZONE", "
|
|
64
|
+
timezone: env("APP_TIMEZONE", "Africa/Johannesburg"),
|
|
65
65
|
});
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
-
| Field | Required | Default
|
|
69
|
-
| ---------- | -------- |
|
|
70
|
-
| `timezone` | no |
|
|
68
|
+
| Field | Required | Default | Description |
|
|
69
|
+
| ---------- | -------- | --------------- | ---------------------------------------------------------------------------------- |
|
|
70
|
+
| `timezone` | no | the system zone | IANA zone every cron expression is read in, unless a task sets its own `timezone`. |
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
## Timezones
|
|
73
|
+
|
|
74
|
+
A cron expression is a wall clock: `0 3 * * *` means three in the morning
|
|
75
|
+
_somewhere_. By default that somewhere is the server's zone. Set `scheduler.timezone`
|
|
76
|
+
to make it one zone for the whole app, or `timezone` on a single schedule to override
|
|
77
|
+
it:
|
|
78
|
+
|
|
79
|
+
```typescript fragment
|
|
80
|
+
export class SendDailyReports extends Schedule {
|
|
81
|
+
cron = "0 8 * * *";
|
|
82
|
+
timezone = "Africa/Johannesburg"; // 08:00 there, whatever the server is on
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The zone is evaluated by Zerotal, not by `Bun.cron` — which reads the system zone and
|
|
87
|
+
has no option to change it. A zoned task ticks every minute and runs on the ticks
|
|
88
|
+
where its expression matches the clock in its own zone, so it stays correct across a
|
|
89
|
+
daylight-saving change rather than drifting by an hour twice a year. A minute is also
|
|
90
|
+
the finest granularity `Bun.cron` accepts, so nothing is given up.
|
|
91
|
+
|
|
92
|
+
Two consequences worth knowing:
|
|
93
|
+
|
|
94
|
+
- **A skipped hour skips the schedules inside it, and a repeated hour runs them
|
|
95
|
+
twice.** That is what every cron does. A task at `0 2 * * *` in a zone that springs
|
|
96
|
+
from 01:59 to 03:00 does not run that day.
|
|
97
|
+
- **An unknown zone name refuses at boot, loudly, and takes only its own task out.**
|
|
98
|
+
A registration failure used to propagate: the worker died during boot and
|
|
99
|
+
restart-looped, so one bad schedule stopped every schedule in the app. Now the
|
|
100
|
+
others start and the log names the one that did not.
|
|
75
101
|
|
|
76
102
|
## Defining schedules
|
|
77
103
|
|
|
@@ -134,7 +160,7 @@ Every setting is an optional property (or method) on your `Schedule` subclass:
|
|
|
134
160
|
| `cron` | `string` | Cron expression (5- or 6-field). Set this **or** override `frequency()`. |
|
|
135
161
|
| `frequency(every)` | method | Build the cadence fluently; return the task (see helpers below). |
|
|
136
162
|
| `name` | `string` | Task name in `schedule:list` and logs. Defaults to the class name. |
|
|
137
|
-
| `timezone` | `string` | IANA timezone the cron is evaluated in.
|
|
163
|
+
| `timezone` | `string` | IANA timezone the cron is evaluated in — overrides `scheduler.timezone`. See [Timezones](#timezones). |
|
|
138
164
|
| `withoutOverlapping` | `boolean \| OverlapLockOptions` | Skip a tick while a previous run is active; also takes a cross-process lock when a lock driver is configured. |
|
|
139
165
|
| `environments` | `string[]` | Only run when `APP_ENV` is one of these. |
|
|
140
166
|
| `inBackground` | `boolean` | Run the body without blocking the scheduler tick. |
|
|
@@ -489,6 +515,19 @@ override async onStarted(): Promise<void> {
|
|
|
489
515
|
For production, run the worker as a separate process so it can be scaled, restarted,
|
|
490
516
|
and monitored independently of the web server.
|
|
491
517
|
|
|
518
|
+
> **A web process says so when it is not running your schedules.** `app/schedules/`
|
|
519
|
+
> is only discovered in `worker` and `console`, so a web process skips it by not
|
|
520
|
+
> looking — which used to be completely silent, and is how an app runs for weeks in
|
|
521
|
+
> production with every schedule written and none of them ever firing. A boot line
|
|
522
|
+
> now names it:
|
|
523
|
+
>
|
|
524
|
+
> ```
|
|
525
|
+
> Skipping 3 file(s) in app/schedules — the "schedules" convention does not run in env=web (it runs in: worker, console).
|
|
526
|
+
> ```
|
|
527
|
+
>
|
|
528
|
+
> Seeing that on a web process is correct. Seeing it and having no worker running is
|
|
529
|
+
> the hole.
|
|
530
|
+
|
|
492
531
|
## References
|
|
493
532
|
|
|
494
533
|
The `Scheduler` facade resolves the `scheduler` container binding — a
|
|
@@ -514,6 +553,30 @@ The `Scheduler` facade resolves the `scheduler` container binding — a
|
|
|
514
553
|
| `stop` | `stop(): void` | Stop every running task. |
|
|
515
554
|
| `tasks` | `get tasks(): ReadonlyMap<string, ScheduledTask>` | The registered tasks, keyed by name. |
|
|
516
555
|
|
|
556
|
+
### Timezone helpers
|
|
557
|
+
|
|
558
|
+
The zone arithmetic the scheduler uses to evaluate a cron somewhere other than the
|
|
559
|
+
server, exported because an app doing its own time-window logic needs the same
|
|
560
|
+
answers.
|
|
561
|
+
|
|
562
|
+
| Export | Signature | Description |
|
|
563
|
+
| ------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
|
564
|
+
| `isValidTimeZone` | `isValidTimeZone(tz: string): boolean` | Whether this runtime knows the IANA zone. Check before storing one a user typed. |
|
|
565
|
+
| `wallClockIn` | `wallClockIn(date: Date, tz: string): Date` | The same instant, shifted so the Date's _local_ getters read that zone's clock face. |
|
|
566
|
+
| `CronExpression.matchesIn` | `matchesIn(date: Date, tz: string): boolean` | Whether the expression fires at `date`, read in `tz`. |
|
|
567
|
+
| `CronExpression.nextRunAfterIn` | `nextRunAfterIn(expr, from: Date, tz): Date \| null` | The next real instant the expression fires on that zone's clock — correct across a DST change. |
|
|
568
|
+
|
|
569
|
+
`wallClockIn` returns a Date that is a lie about the instant and true about the clock
|
|
570
|
+
face: its epoch value is off by the zone offset. Pass it to a field comparison, never
|
|
571
|
+
back to a caller.
|
|
572
|
+
|
|
573
|
+
### Errors
|
|
574
|
+
|
|
575
|
+
| Error | Thrown when |
|
|
576
|
+
| ---------------------- | ------------------------------------------------------------------------------------------- |
|
|
577
|
+
| `SchedulerError` | Base class for everything this package throws. Catch it to catch them all. |
|
|
578
|
+
| `UnknownTimeZoneError` | A task declares a `timezone` this runtime does not know — at registration, naming the task. |
|
|
579
|
+
|
|
517
580
|
### ScheduledTask introspection
|
|
518
581
|
|
|
519
582
|
| Member | Signature | Description |
|
|
@@ -525,6 +588,17 @@ The `Scheduler` facade resolves the `scheduler` container binding — a
|
|
|
525
588
|
| `lastDurationMs` | `get lastDurationMs(): number \| undefined` | Duration of the last run in ms. |
|
|
526
589
|
| `isRunning` | `get isRunning(): boolean` | `true` while a run is in flight. |
|
|
527
590
|
|
|
591
|
+
## Types
|
|
592
|
+
|
|
593
|
+
| Type | What it is |
|
|
594
|
+
| --------------------------------------- | -------------------------------------------------------------------------------------------- |
|
|
595
|
+
| `CronExpression` | The schedule string a task declares. |
|
|
596
|
+
| `TaskGuard` | A condition deciding whether a due run actually happens — a feature flag, a leader election. |
|
|
597
|
+
| `TaskHook` | What runs before or after a task. |
|
|
598
|
+
| `ScheduleRunRecord`, `ScheduleRunStore` | One recorded run, and where the log is kept. |
|
|
599
|
+
| `RunLogConfig` | How much of that log is retained. |
|
|
600
|
+
| `OutputMailer` | Sending a task's output somewhere when it finishes. |
|
|
601
|
+
|
|
528
602
|
## Next steps
|
|
529
603
|
|
|
530
604
|
- [Queue](/docs/queue) — schedules typically dispatch jobs; the worker runs both.
|
package/docs/session.md
CHANGED
|
@@ -444,6 +444,12 @@ try {
|
|
|
444
444
|
The fix is nearly always to store an identifier rather than the object, or to
|
|
445
445
|
move to a server-side driver where the cookie holds only the session id.
|
|
446
446
|
|
|
447
|
+
## Types
|
|
448
|
+
|
|
449
|
+
`SessionDriver` is the storage contract — implement it to keep sessions somewhere the built-ins
|
|
450
|
+
do not reach. `SessionPayload` is what a driver loads and saves: the id and the data bag.
|
|
451
|
+
`SessionAccessor` is the request-scoped handle `ctx.session` exposes.
|
|
452
|
+
|
|
447
453
|
## Next steps
|
|
448
454
|
|
|
449
455
|
- [Middleware](/docs/middleware) — where `SessionMiddleware` and `CsrfMiddleware`
|
package/docs/social.md
CHANGED
|
@@ -516,6 +516,16 @@ validation errors is exactly the string you forward to `?error=`.
|
|
|
516
516
|
| `OAuthUserFetchError` | provider message | User-profile fetch failed. |
|
|
517
517
|
| `AppleClientSecretError` | Apple credentials message | Apple driver has neither a JWT nor raw key trio. |
|
|
518
518
|
|
|
519
|
+
## Types
|
|
520
|
+
|
|
521
|
+
| Type | What it is |
|
|
522
|
+
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
523
|
+
| `OAuth2Config` | What a provider entry in `config/services.ts` holds — id, secret, redirect URI. |
|
|
524
|
+
| `AppleOAuth2Config` | Apple's, which needs more: a team id, a key id, and the private key it signs with. |
|
|
525
|
+
| `SocialSession` | What the driver stashes between the redirect out and the callback back. |
|
|
526
|
+
| `GoogleDriver` | Exported so a custom driver can extend it rather than reimplement OIDC. |
|
|
527
|
+
| `FakeSocialDriver` | What `Social.fake()` installs — returns a canned profile instead of exchanging a code. See [Mocking](/docs/testing/mocking). |
|
|
528
|
+
|
|
519
529
|
## Next steps
|
|
520
530
|
|
|
521
531
|
- [Authentication](/docs/authentication) — establish the session after a social login.
|
package/docs/storage.md
CHANGED
|
@@ -626,6 +626,27 @@ Manager and exports from `zerotal/storage`:
|
|
|
626
626
|
| `StorageProvider` | `class` | Registers the `storage` binding. |
|
|
627
627
|
| `DiskNotConfiguredError` | `class` | Thrown when a named disk is missing from config. |
|
|
628
628
|
|
|
629
|
+
### Types
|
|
630
|
+
|
|
631
|
+
The config shapes are exported, so a `config/storage.ts` can be annotated and a helper that
|
|
632
|
+
builds disk options can be typed:
|
|
633
|
+
|
|
634
|
+
| Type | What it is |
|
|
635
|
+
| --------------------- | -------------------------------------------------------------------------- |
|
|
636
|
+
| `DiskConfig` | The discriminated union of every disk config — `local` and `s3` today. |
|
|
637
|
+
| `LocalDiskConfig` | A local disk: `root`, plus `serve` when it is reachable over HTTP. |
|
|
638
|
+
| `S3DiskConfig` | An S3 disk: credentials, `region`, `bucket`, and an optional public `url`. |
|
|
639
|
+
| `DiskServeConfig` | How a disk is exposed over HTTP — the `serve` block `public` uses. |
|
|
640
|
+
| `StorageFilesOptions` | Options for `StorageFilesMiddleware`, which serves a disk's files. |
|
|
641
|
+
| `FakeStoredFile` | What a faked disk records in tests, for asserting on what was written. |
|
|
642
|
+
|
|
643
|
+
`StorageError` is the base every storage failure extends, and `UnsupportedOperationError` is
|
|
644
|
+
what a driver throws for something it genuinely cannot do — a local disk asked for a signed
|
|
645
|
+
URL, for instance — rather than failing quietly and returning nothing.
|
|
646
|
+
|
|
647
|
+
`S3Driver` is exported so a driver for another S3-compatible service can extend it rather than
|
|
648
|
+
reimplement request signing.
|
|
649
|
+
|
|
629
650
|
## Next steps
|
|
630
651
|
|
|
631
652
|
- [Validator](/docs/validator) — validate file uploads before storing them.
|
package/docs/support-policy.md
CHANGED
|
@@ -109,7 +109,19 @@ outcomes — promote, keep with a new date and the reason, or withdraw.
|
|
|
109
109
|
|
|
110
110
|
| Package | Now | Reviewed by |
|
|
111
111
|
| ------------- | -------------- | ----------- |
|
|
112
|
-
| `@zerotal/ai` | `experimental` | **1.
|
|
112
|
+
| `@zerotal/ai` | `experimental` | **1.11.0** |
|
|
113
|
+
|
|
114
|
+
`@zerotal/ai`'s date moved once, from 1.9.0, and the reason is the same one that made it
|
|
115
|
+
experimental in the first place: **it graduates in the release after its first real users, and
|
|
116
|
+
it has not had them yet.** Promoting on a date rather than on evidence is how a label becomes
|
|
117
|
+
decoration — a `stable` promise is only worth making about an API that something has pushed
|
|
118
|
+
against.
|
|
119
|
+
|
|
120
|
+
The date is a forcing function, not a prediction. It moved once; a second move needs a better
|
|
121
|
+
reason than the first, or the honest answer is to withdraw the package rather than keep
|
|
122
|
+
re-dating it. Its surface triage and the tests for its SSE parser and prompt redaction are worth
|
|
123
|
+
doing meanwhile, and are tracked separately — they improve the package whichever way the review
|
|
124
|
+
goes, and they are what would otherwise turn the deadline into a scramble.
|
|
113
125
|
|
|
114
126
|
`@zerotal/arch` held `beta` with the same date, was reviewed ahead of it, and is
|
|
115
127
|
`stable` — the release that carried the promotion is the one its
|