@zerotal/arch 1.7.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/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- package/src/tools/searchDocs.ts +345 -0
package/docs/seeding.md
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Seeding
|
|
3
|
+
description: Populate your database with repeatable test and demo data through ORM-aware seeder classes.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Seeding
|
|
7
|
+
|
|
8
|
+
Seeders populate your database with test or demo data. They work through your
|
|
9
|
+
[ORM](/docs/orm) models and pair naturally with [Factories](/docs/orm/factories),
|
|
10
|
+
which generate realistic records.
|
|
11
|
+
|
|
12
|
+
Reach for a seeder whenever you need a known starting state: a fresh developer
|
|
13
|
+
checkout that needs sample content, a demo environment, or the baseline rows a test
|
|
14
|
+
suite expects. Unlike [migrations](/docs/migrations), seeders are **not** tracked —
|
|
15
|
+
nothing records that they've run, so running one twice inserts the data twice. Design
|
|
16
|
+
each seeder to be safe to re-run (wipe first, or check before inserting) and you can
|
|
17
|
+
reset your local database to a clean, populated state any time.
|
|
18
|
+
|
|
19
|
+
Seeding ships as part of [`@zerotal/orm`](/docs/orm) — there is no separate
|
|
20
|
+
package to install or provider to register. Once the ORM is set up, the
|
|
21
|
+
`make:seeder` and `db:seed` commands are available.
|
|
22
|
+
|
|
23
|
+
## Getting Started
|
|
24
|
+
|
|
25
|
+
Seeding ships with `@zerotal/orm`. If you have the [database](/docs/database)
|
|
26
|
+
set up there is nothing further to install:
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
import { Seeder } from "@zerotal/orm";
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Run `bun zt db:seed` to execute every seeder in `database/seeders/`.
|
|
33
|
+
|
|
34
|
+
## Creating a seeder
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# in your project root
|
|
38
|
+
bun zt make:seeder UserSeeder
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This creates `database/seeders/UserSeeder.ts`. Every seeder extends `Seeder` and
|
|
42
|
+
implements `run()`:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
// database/seeders/UserSeeder.ts
|
|
46
|
+
import { Seeder } from "@zerotal/orm";
|
|
47
|
+
|
|
48
|
+
export class UserSeeder extends Seeder {
|
|
49
|
+
async run(): Promise<void> {
|
|
50
|
+
// seed logic here
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`run()` is the one method you implement, and it's called once when the seeder runs.
|
|
56
|
+
Put your inserts here — create records through your models or factories exactly as you
|
|
57
|
+
would in application code. Because seeders run through the ORM, casts, hooks, and
|
|
58
|
+
relationships all behave normally:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
// database/seeders/UserSeeder.ts
|
|
62
|
+
import { Seeder } from "@zerotal/orm";
|
|
63
|
+
import { User } from "../../app/models/User.ts";
|
|
64
|
+
|
|
65
|
+
export class UserSeeder extends Seeder {
|
|
66
|
+
async run(): Promise<void> {
|
|
67
|
+
// A predictable admin account you can always log in with locally
|
|
68
|
+
await User.create({ name: "Admin", email: "admin@example.com", password: "secret" });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Root seeder
|
|
74
|
+
|
|
75
|
+
Create a `DatabaseSeeder` that coordinates all other seeders. Use `this.call()`
|
|
76
|
+
to run child seeders — they execute in order inside a single transaction, so if
|
|
77
|
+
any fails, every change rolls back atomically.
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
// database/seeders/DatabaseSeeder.ts
|
|
81
|
+
import { Seeder, DB } from "@zerotal/orm";
|
|
82
|
+
import { UserSeeder } from "./UserSeeder.ts";
|
|
83
|
+
import { PostSeeder } from "./PostSeeder.ts";
|
|
84
|
+
import { TagSeeder } from "./TagSeeder.ts";
|
|
85
|
+
|
|
86
|
+
export class DatabaseSeeder extends Seeder {
|
|
87
|
+
async run(): Promise<void> {
|
|
88
|
+
// Wipe existing data in FK order before re-seeding
|
|
89
|
+
await DB.raw("DELETE FROM comments");
|
|
90
|
+
await DB.raw("DELETE FROM posts");
|
|
91
|
+
await DB.raw("DELETE FROM users");
|
|
92
|
+
|
|
93
|
+
await this.call([UserSeeder, TagSeeder, PostSeeder]);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Order matters: seed dependencies before dependents (users before posts).
|
|
99
|
+
|
|
100
|
+
> **Danger** — `DB.raw("DELETE FROM …")` permanently removes rows. Only wipe data
|
|
101
|
+
> in a seeder you run against development or test databases, never in production.
|
|
102
|
+
|
|
103
|
+
## Running seeders
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
# in your project root
|
|
107
|
+
bun zt db:seed
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The command looks for `database/seeders/DatabaseSeeder.ts`, instantiates it, and
|
|
111
|
+
calls `run()`. `DatabaseSeeder` is the single entry point — `db:seed` always runs that
|
|
112
|
+
one class, so it's the seeder you keep up to date as your app grows.
|
|
113
|
+
|
|
114
|
+
There's no flag to run an individual seeder. When you only want a subset during
|
|
115
|
+
development, comment out the seeders you don't need from the `this.call([...])` array,
|
|
116
|
+
or call one directly from a throwaway script. In tests, instantiate and run a seeder
|
|
117
|
+
yourself (`await new UserSeeder().run()`) to set up just the rows that test needs — see
|
|
118
|
+
[Database Testing](/docs/testing/database).
|
|
119
|
+
|
|
120
|
+
> **Note** — If no `DatabaseSeeder.ts` is found, `db:seed` falls back to a legacy
|
|
121
|
+
> `database/seeders/index.ts` that default-exports an async function. Prefer the
|
|
122
|
+
> class-based `DatabaseSeeder` for new projects.
|
|
123
|
+
|
|
124
|
+
## Using factories in seeders
|
|
125
|
+
|
|
126
|
+
[Factories](/docs/orm/factories) are the cleanest way to generate seed records.
|
|
127
|
+
Define them once, then call them from a seeder's `run()`:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
// database/seeders/PostSeeder.ts
|
|
131
|
+
import { Seeder } from "@zerotal/orm";
|
|
132
|
+
import { UserFactory } from "../factories/UserFactory.ts";
|
|
133
|
+
import { PostFactory } from "../factories/PostFactory.ts";
|
|
134
|
+
|
|
135
|
+
export class PostSeeder extends Seeder {
|
|
136
|
+
async run(): Promise<void> {
|
|
137
|
+
const authors = await UserFactory.count(10).create();
|
|
138
|
+
|
|
139
|
+
for (const author of authors) {
|
|
140
|
+
await PostFactory.for(author).count(5).create();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Here `UserFactory.count(10).create()` inserts ten authors, and `PostFactory.for(author)`
|
|
147
|
+
attaches each batch of posts to one of them — a quick way to build realistic related
|
|
148
|
+
data without hand-writing foreign keys. Factories also keep seeders short and readable:
|
|
149
|
+
the seeder says _how much_ data to make, the factory decides _what each record looks
|
|
150
|
+
like_.
|
|
151
|
+
|
|
152
|
+
See [Factories](/docs/orm/factories) for defining factories, the full API
|
|
153
|
+
(`create`, `make`, `count`, `for`, `state`, `afterCreate`, …), and the `fake` data
|
|
154
|
+
helper.
|
|
155
|
+
|
|
156
|
+
## Testing
|
|
157
|
+
|
|
158
|
+
Set your suite up once as described in [Testing](/docs/testing). A seeder is a
|
|
159
|
+
class with a `run()` method, so testing one is just calling it:
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
// tests/seeders/RoleSeeder.test.ts
|
|
163
|
+
import { test } from "bun:test";
|
|
164
|
+
import { assertDatabaseCount, assertDatabaseHas } from "@zerotal/testing";
|
|
165
|
+
import { RoleSeeder } from "../../database/seeders/RoleSeeder.ts";
|
|
166
|
+
|
|
167
|
+
test("seeds the three baseline roles", async () => {
|
|
168
|
+
await new RoleSeeder().run();
|
|
169
|
+
|
|
170
|
+
await assertDatabaseCount("roles", 3);
|
|
171
|
+
await assertDatabaseHas("roles", { name: "admin" });
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
**Test that re-running is safe.** A seeder that a colleague runs twice — or that
|
|
176
|
+
a deploy runs on every release — must not double its rows. This is the failure
|
|
177
|
+
seeders actually have:
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
// tests/seeders/RoleSeeder.test.ts
|
|
181
|
+
test("running twice does not duplicate rows", async () => {
|
|
182
|
+
await new RoleSeeder().run();
|
|
183
|
+
await new RoleSeeder().run();
|
|
184
|
+
|
|
185
|
+
await assertDatabaseCount("roles", 3);
|
|
186
|
+
});
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
If that fails, the seeder needs `updateOrCreate` rather than `create`.
|
|
190
|
+
|
|
191
|
+
**`call()` wraps its children in one transaction**, so a test for a composite
|
|
192
|
+
seeder can assert the all-or-nothing behaviour directly — make a late child throw
|
|
193
|
+
and check that the earlier one left nothing behind:
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
// tests/seeders/DatabaseSeeder.test.ts
|
|
197
|
+
test("a failing child rolls the whole run back", async () => {
|
|
198
|
+
await expect(new DatabaseSeeder().run()).rejects.toThrow();
|
|
199
|
+
|
|
200
|
+
await assertDatabaseCount("users", 0);
|
|
201
|
+
});
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
> **Note** — Factories used inside a seeder stay silent: model observers and
|
|
205
|
+
> hooks do not fire. That is deliberate, so seeding a thousand users doesn't send
|
|
206
|
+
> a thousand emails. See [Testing](/docs/orm#testing) in the ORM guide if you need
|
|
207
|
+
> the lifecycle to run.
|
|
208
|
+
|
|
209
|
+
## References
|
|
210
|
+
|
|
211
|
+
The `Seeder` base class, imported from `@zerotal/orm`:
|
|
212
|
+
|
|
213
|
+
| Member | Signature | Description |
|
|
214
|
+
| ------ | ---------------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
|
215
|
+
| `run` | `abstract run(): Promise<void>` | Your seed logic. Implement this on every seeder. |
|
|
216
|
+
| `call` | `call(seeders: (new () => Seeder)[]): Promise<void>` | Run the given seeder classes in order inside one transaction; any throw rolls them all back. |
|
|
217
|
+
|
|
218
|
+
Related commands:
|
|
219
|
+
|
|
220
|
+
| Command | Description |
|
|
221
|
+
| ------------------------- | ------------------------------------------------------- |
|
|
222
|
+
| `bun zt make:seeder Name` | Scaffold `database/seeders/Name.ts` extending `Seeder`. |
|
|
223
|
+
| `bun zt db:seed` | Run `database/seeders/DatabaseSeeder.ts`. |
|
|
224
|
+
|
|
225
|
+
## Next steps
|
|
226
|
+
|
|
227
|
+
- [Factories](/docs/orm/factories) — generating realistic model records.
|
|
228
|
+
- [Database Testing](/docs/testing/database) — seeding and resetting state in tests.
|
|
229
|
+
- [Migrations](/docs/migrations) — the schema your seeders populate.
|
package/docs/session.md
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Session
|
|
3
|
+
description: Give every HTTP request a signed, per-user data store you can read, write, and flash from anywhere in the pipeline.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Session
|
|
7
|
+
|
|
8
|
+
`@zerotal/session` attaches a signed, per-user data store to every HTTP request.
|
|
9
|
+
Read, write, flash, and regenerate session data from anywhere in the request
|
|
10
|
+
pipeline without managing cookies by hand.
|
|
11
|
+
|
|
12
|
+
## Getting Started
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
# in your project root
|
|
16
|
+
bun add @zerotal/session
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Register the provider
|
|
20
|
+
|
|
21
|
+
Add `SessionProvider` to the providers array in `bootstrap/providers.ts`:
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
// bootstrap/providers.ts
|
|
25
|
+
import { SessionProvider } from "@zerotal/session";
|
|
26
|
+
|
|
27
|
+
export default [
|
|
28
|
+
// …your other providers
|
|
29
|
+
SessionProvider,
|
|
30
|
+
];
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Registering the provider switches on the following (it only runs in the `web` and
|
|
34
|
+
`test` environments):
|
|
35
|
+
|
|
36
|
+
- `onRegister` — binds `session.driver` (the cookie or redis driver) and the
|
|
37
|
+
`session` accessor as lazy singletons in the container.
|
|
38
|
+
- `onBooting` — auto-registers `SessionMiddleware` via `app.useOnce()`, so the
|
|
39
|
+
session is loaded on every request, and warns if `session.secure` is off in
|
|
40
|
+
production.
|
|
41
|
+
- `onBooted` — pre-resolves both singletons so the `Session` facade can read them
|
|
42
|
+
synchronously.
|
|
43
|
+
|
|
44
|
+
> **Note** — `SessionProvider` registers only `SessionMiddleware`. CSRF protection
|
|
45
|
+
> is a separate middleware you opt into — see [CSRF protection](#csrf-protection).
|
|
46
|
+
|
|
47
|
+
## Configuration
|
|
48
|
+
|
|
49
|
+
Create `config/session.ts`. Use the `SessionConfig()` helper so every field stays
|
|
50
|
+
type-checked while supplying the defaults:
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
// config/session.ts
|
|
54
|
+
import { SessionConfig } from "@zerotal/session";
|
|
55
|
+
import { env } from "zerotal";
|
|
56
|
+
|
|
57
|
+
export default SessionConfig({
|
|
58
|
+
driver: "cookie", // 'cookie' | 'redis'
|
|
59
|
+
lifetime: 86400, // seconds — 24 hours
|
|
60
|
+
cookie: "zerotal_session",
|
|
61
|
+
httpOnly: true,
|
|
62
|
+
sameSite: "Lax",
|
|
63
|
+
secure: env("APP_ENV") === "production",
|
|
64
|
+
secret: env("APP_KEY", ""), // signs the session cookie
|
|
65
|
+
});
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
| Field | Required | Default | Description |
|
|
69
|
+
| ---------- | -------- | ----------- | ------------------------------------------------------------ |
|
|
70
|
+
| `driver` | no | `"cookie"` | Storage backend: `"cookie"` or `"redis"`. |
|
|
71
|
+
| `lifetime` | no | `86400` | Session lifetime in seconds (24 hours). |
|
|
72
|
+
| `cookie` | no | `"session"` | Name of the session cookie. |
|
|
73
|
+
| `httpOnly` | no | `true` | Marks the session cookie HTTP-only (hidden from JS). |
|
|
74
|
+
| `sameSite` | no | `"Lax"` | SameSite policy: `"Strict"`, `"Lax"`, or `"None"`. |
|
|
75
|
+
| `secure` | no | `false` | Require HTTPS for the cookie. Turn on in production. |
|
|
76
|
+
| `secret` | no | `""` | HMAC secret used to sign the cookie. Set this in production. |
|
|
77
|
+
|
|
78
|
+
Add a signing secret to `.env`:
|
|
79
|
+
|
|
80
|
+
```env
|
|
81
|
+
# .env
|
|
82
|
+
APP_KEY=your-random-32-char-secret-here
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
> **Danger** — Without a strong `secret`, session cookies can be forged. The cookie
|
|
86
|
+
> driver throws `SessionSecretMissingError` if constructed with an empty secret;
|
|
87
|
+
> always set `APP_KEY` (or `session.secret`) before deploying.
|
|
88
|
+
|
|
89
|
+
## Basic usage
|
|
90
|
+
|
|
91
|
+
`SessionMiddleware` attaches a `SessionManager` to every request. There are two
|
|
92
|
+
ways to reach it:
|
|
93
|
+
|
|
94
|
+
- **`ctx.session`** inside a route handler — the `SessionManager` lives on the
|
|
95
|
+
request `HttpContext` (middleware receives the same context, so it's
|
|
96
|
+
`ctx.session` there too).
|
|
97
|
+
- **The `Session` facade** anywhere — controllers, services, jobs — without
|
|
98
|
+
threading the context through.
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
// in a controller or service
|
|
102
|
+
import { Session } from "@zerotal/session";
|
|
103
|
+
|
|
104
|
+
Session.set("locale", "fr");
|
|
105
|
+
const locale = Session.get<string>("locale");
|
|
106
|
+
Session.flash("success", "Saved!");
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The facade resolves the current request's session via `RequestContext`, so it
|
|
110
|
+
works in any code running during a request. It requires `SessionMiddleware` to be
|
|
111
|
+
registered. Both `ctx.session` and `Session` expose the same methods (see the
|
|
112
|
+
[reference](#session-api) below).
|
|
113
|
+
|
|
114
|
+
## Reading and writing
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
// in a controller
|
|
118
|
+
import type { HttpContext } from "zerotal";
|
|
119
|
+
|
|
120
|
+
async action(ctx: HttpContext) {
|
|
121
|
+
// Write
|
|
122
|
+
ctx.session.set("locale", "fr");
|
|
123
|
+
ctx.session.set("cart", [1, 2, 3]);
|
|
124
|
+
|
|
125
|
+
// Read (returns unknown — cast to your type)
|
|
126
|
+
const locale = ctx.session.get("locale") as string;
|
|
127
|
+
const cart = ctx.session.get("cart") as number[];
|
|
128
|
+
|
|
129
|
+
// Check existence
|
|
130
|
+
const hasLocale = ctx.session.has("locale"); // boolean
|
|
131
|
+
|
|
132
|
+
// Read and remove in one call
|
|
133
|
+
const once = ctx.session.pull("cart");
|
|
134
|
+
|
|
135
|
+
// Delete one key
|
|
136
|
+
ctx.session.forget("locale");
|
|
137
|
+
|
|
138
|
+
// Wipe all session data
|
|
139
|
+
ctx.session.flush();
|
|
140
|
+
|
|
141
|
+
// Return the current session ID
|
|
142
|
+
const id = ctx.session.id();
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Flash data
|
|
147
|
+
|
|
148
|
+
Flash stores a value for **one subsequent request only** — it is automatically
|
|
149
|
+
swept after the next request. The canonical use case is POST-Redirect-GET status
|
|
150
|
+
messages.
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
// in a controller — survives exactly one redirect
|
|
154
|
+
ctx.session.flash("success", "Post created!");
|
|
155
|
+
ctx.session.flash("errors", { title: ["Required"] });
|
|
156
|
+
|
|
157
|
+
// Or via the ctx shorthand (same thing):
|
|
158
|
+
ctx.flash("success", "Post created!");
|
|
159
|
+
ctx.flash("errors", { title: ["Required"] });
|
|
160
|
+
|
|
161
|
+
// Or via the Session facade (anywhere — no context needed):
|
|
162
|
+
Session.flash("success", "Post created!");
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Read on the next request:
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
// in a controller
|
|
169
|
+
const msg = ctx.flashed<string>("success");
|
|
170
|
+
const errors = ctx.flashed<Record<string, string[]>>("errors");
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Full POST-Redirect-GET pattern:
|
|
174
|
+
|
|
175
|
+
```typescript
|
|
176
|
+
// in a controller
|
|
177
|
+
async store(ctx: HttpContext) {
|
|
178
|
+
await Post.create(await ctx.body());
|
|
179
|
+
ctx.flash("success", "Post published!");
|
|
180
|
+
ctx.redirect("/posts", 303); // 303 ensures the browser GETs the redirect
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async index(ctx: HttpContext) {
|
|
184
|
+
const success = ctx.flashed<string>("success"); // available here, gone after
|
|
185
|
+
ctx.view(PostsPage, { success });
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Session fixation protection
|
|
190
|
+
|
|
191
|
+
Always regenerate the session ID after a privilege change (login, logout, password
|
|
192
|
+
change) to prevent session-fixation attacks:
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
// in a controller
|
|
196
|
+
ctx.session.regenerate(); // issues a new session ID; data is preserved
|
|
197
|
+
// or: Session.regenerate();
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
> **Danger** — Skipping `regenerate()` on login lets an attacker who planted a
|
|
201
|
+
> known session ID before authentication ride the now-privileged session.
|
|
202
|
+
|
|
203
|
+
## Session drivers
|
|
204
|
+
|
|
205
|
+
| Driver | Notes |
|
|
206
|
+
| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
207
|
+
| `cookie` | Default. Session data is signed and stored in a browser cookie. No server storage needed. Max ~4 KB per session. |
|
|
208
|
+
| `redis` | Session data stored in Redis by ID. Use when sessions are large, shared across cluster nodes, or need server-side invalidation. Requires a Redis URL. |
|
|
209
|
+
|
|
210
|
+
### Which driver should I use?
|
|
211
|
+
|
|
212
|
+
- **`cookie`** — the default. Reach for it unless you outgrow it: it needs no
|
|
213
|
+
infrastructure and keeps state with the client.
|
|
214
|
+
- **`redis`** — switch when sessions exceed the ~4 KB cookie budget, must be shared
|
|
215
|
+
across multiple nodes, or need to be invalidated server-side (e.g. force-logout).
|
|
216
|
+
|
|
217
|
+
Switch drivers via the environment without changing application code:
|
|
218
|
+
|
|
219
|
+
```env
|
|
220
|
+
# .env.production
|
|
221
|
+
SESSION_DRIVER=redis
|
|
222
|
+
SESSION_REDIS=redis://localhost:6379
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
// config/session.ts
|
|
227
|
+
import { SessionConfig } from "@zerotal/session";
|
|
228
|
+
import { env } from "zerotal";
|
|
229
|
+
|
|
230
|
+
export default SessionConfig({
|
|
231
|
+
driver: env("SESSION_DRIVER", "cookie"),
|
|
232
|
+
// …
|
|
233
|
+
});
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
> **Note** — The redis driver reads its connection URL from the `session.redis`
|
|
237
|
+
> config key (defaulting to `redis://localhost:6379`). Wire it from an env var as
|
|
238
|
+
> shown above.
|
|
239
|
+
|
|
240
|
+
## CSRF protection
|
|
241
|
+
|
|
242
|
+
`CsrfMiddleware` defends state-changing requests against cross-site request forgery.
|
|
243
|
+
Unlike `SessionMiddleware`, it is **not** auto-registered — add it after the session
|
|
244
|
+
middleware, typically in the `web` middleware group:
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
// bootstrap/app.ts (or wherever you register middleware)
|
|
248
|
+
import { SessionMiddleware, CsrfMiddleware } from "@zerotal/session";
|
|
249
|
+
|
|
250
|
+
app.use([SessionMiddleware, CsrfMiddleware]);
|
|
251
|
+
// In production over HTTPS, mark the XSRF cookie Secure:
|
|
252
|
+
// app.use([SessionMiddleware, CsrfMiddleware.with({ secure: true })]);
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
On its first request the middleware stores a random token in the session. On any
|
|
256
|
+
unsafe method (anything other than `GET`, `HEAD`, `OPTIONS`) it compares — in
|
|
257
|
+
constant time — the session token against the `x-csrf-token` or `x-xsrf-token`
|
|
258
|
+
request header, returning **`419`** on a mismatch.
|
|
259
|
+
|
|
260
|
+
After every request it also sets a non-HttpOnly `XSRF-TOKEN` cookie, so Axios
|
|
261
|
+
(and therefore Inertia) reads it and replays it as the `X-XSRF-TOKEN` header
|
|
262
|
+
automatically — no manual wiring for those clients.
|
|
263
|
+
|
|
264
|
+
To expose the token elsewhere (HTML meta tag, Inertia shared props), read it with
|
|
265
|
+
`CsrfMiddleware.token()`:
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
// in a controller or Inertia shared-props factory
|
|
269
|
+
import { CsrfMiddleware } from "@zerotal/session";
|
|
270
|
+
|
|
271
|
+
const token = CsrfMiddleware.token(); // reads the active request's session
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
For a plain HTML form posting via `fetch`, surface the token in a meta tag and send
|
|
275
|
+
it as a header:
|
|
276
|
+
|
|
277
|
+
```html
|
|
278
|
+
<!-- in your layout -->
|
|
279
|
+
<meta name="csrf-token" content="${CsrfMiddleware.token()}" />
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
```typescript
|
|
283
|
+
// in your client JS
|
|
284
|
+
const token = document.querySelector('meta[name="csrf-token"]')?.getAttribute("content");
|
|
285
|
+
|
|
286
|
+
fetch("/posts", {
|
|
287
|
+
method: "POST",
|
|
288
|
+
headers: {
|
|
289
|
+
"Content-Type": "application/json",
|
|
290
|
+
"X-CSRF-Token": token ?? "",
|
|
291
|
+
},
|
|
292
|
+
body: JSON.stringify({ title: "Hello" }),
|
|
293
|
+
});
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
> **Warning** — The token is validated from a request **header**, not a `_token`
|
|
297
|
+
> form field. A classic `<form method="POST">` with no JavaScript will not pass
|
|
298
|
+
> the header, so submit through `fetch`/Axios (Inertia does this for you).
|
|
299
|
+
|
|
300
|
+
## Accessing the session outside a handler
|
|
301
|
+
|
|
302
|
+
Use the **`Session` facade** — it reaches the current request's session through
|
|
303
|
+
`RequestContext`, so service-layer code never has to thread `ctx` through:
|
|
304
|
+
|
|
305
|
+
```typescript
|
|
306
|
+
// in a service
|
|
307
|
+
import { Session } from "@zerotal/session";
|
|
308
|
+
|
|
309
|
+
function rememberLocale(locale: string) {
|
|
310
|
+
Session.set("locale", locale);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function currentLocale(): string | undefined {
|
|
314
|
+
return Session.get<string>("locale");
|
|
315
|
+
}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
This is the recommended approach for anything that isn't a route handler. If you
|
|
319
|
+
need the raw `HttpContext` for other reasons, `RequestContext.tryGet()` (or
|
|
320
|
+
`RequestContext.get()` to throw when absent) still gives it to you, but for the
|
|
321
|
+
session itself prefer the facade.
|
|
322
|
+
|
|
323
|
+
## Authenticated sessions
|
|
324
|
+
|
|
325
|
+
`AuthSessionMiddleware` extends `SessionMiddleware`: after loading the session it
|
|
326
|
+
reads `session.get("user_id")`, looks the user up with a callback you supply, and
|
|
327
|
+
attaches the result to `ctx.user` for the rest of the request.
|
|
328
|
+
|
|
329
|
+
```typescript
|
|
330
|
+
// bootstrap/app.ts
|
|
331
|
+
import { AuthSessionMiddleware, CookieDriver } from "@zerotal/session";
|
|
332
|
+
import { env } from "zerotal";
|
|
333
|
+
import { User } from "../app/models/User.ts";
|
|
334
|
+
|
|
335
|
+
app.use(
|
|
336
|
+
class extends AuthSessionMiddleware {
|
|
337
|
+
constructor() {
|
|
338
|
+
super(new CookieDriver(env("APP_KEY", "")), (id) => User.find(id));
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
);
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
## Testing
|
|
345
|
+
|
|
346
|
+
Pre-seed session data with `withSession()`, then assert on the response with
|
|
347
|
+
`assertSessionHas()`:
|
|
348
|
+
|
|
349
|
+
```typescript
|
|
350
|
+
// in a test
|
|
351
|
+
import { createTestApp } from "@zerotal/testing";
|
|
352
|
+
|
|
353
|
+
const res = await testApp.withSession({ locale: "fr", cart: [1, 2, 3] }).get("/checkout");
|
|
354
|
+
|
|
355
|
+
res.assertOk();
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
```typescript
|
|
359
|
+
// in a test
|
|
360
|
+
const res = await testApp.post("/locale", { locale: "fr" });
|
|
361
|
+
|
|
362
|
+
res.assertSessionHas("locale");
|
|
363
|
+
res.assertSessionHas("locale", "fr");
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
```typescript
|
|
367
|
+
// in a test — assert a flash key was written before a redirect
|
|
368
|
+
const res = await testApp.actingAs(user).post("/posts", { title: "Hello" });
|
|
369
|
+
|
|
370
|
+
res.assertRedirect("/posts");
|
|
371
|
+
res.assertSessionHas("success"); // flash key was written
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
## References
|
|
375
|
+
|
|
376
|
+
### Session API
|
|
377
|
+
|
|
378
|
+
In a handler the session is `ctx.session`; the `Session` facade exposes the same
|
|
379
|
+
methods anywhere. The facade is generic (`Session.get<T>(key)`), while
|
|
380
|
+
`ctx.session.get` returns `unknown`.
|
|
381
|
+
|
|
382
|
+
| Method | Signature | Description |
|
|
383
|
+
| ------------------------------------------------------ | --------------------------------------- | ---------------------------------------- |
|
|
384
|
+
| `ctx.session.get(key)` / `Session.get<T>(key)` | `(key: string) => unknown` | Read a value (`undefined` if absent). |
|
|
385
|
+
| `ctx.session.set(key, value)` / `Session.set(...)` | `(key: string, value: unknown) => void` | Write a value. |
|
|
386
|
+
| `ctx.session.has(key)` / `Session.has(key)` | `(key: string) => boolean` | Check whether a key exists. |
|
|
387
|
+
| `ctx.session.pull(key)` / `Session.pull<T>(key)` | `(key: string) => unknown` | Read a value and remove it in one call. |
|
|
388
|
+
| `ctx.session.forget(key)` / `Session.forget(key)` | `(key: string) => void` | Delete a key. |
|
|
389
|
+
| `ctx.session.flush()` / `Session.flush()` | `() => void` | Delete all keys. |
|
|
390
|
+
| `ctx.session.flash(key, value)` / `Session.flash(...)` | `(key: string, value: unknown) => void` | Write a one-request-only value. |
|
|
391
|
+
| `ctx.session.regenerate()` / `Session.regenerate()` | `() => void` | Issue a new session ID (data preserved). |
|
|
392
|
+
| `ctx.session.id()` / `Session.id()` | `() => string` | Return the current session ID. |
|
|
393
|
+
|
|
394
|
+
### HttpContext helpers
|
|
395
|
+
|
|
396
|
+
| Method | Signature | Description |
|
|
397
|
+
| ----------------------- | --------------------------------------- | --------------------------------------------- |
|
|
398
|
+
| `ctx.flash(key, value)` | `(key: string, value: unknown) => void` | Shorthand for `ctx.session.flash()`. |
|
|
399
|
+
| `ctx.flashed<T>(key)` | `(key: string) => T \| undefined` | Read a value flashed in the previous request. |
|
|
400
|
+
|
|
401
|
+
### CsrfMiddleware
|
|
402
|
+
|
|
403
|
+
| Member | Signature | Description |
|
|
404
|
+
| ---------------------------- | -------------------------------------------- | --------------------------------------------------- |
|
|
405
|
+
| `CsrfMiddleware.token(ctx?)` | `(ctx?: HttpContext) => string \| undefined` | Read the current request's CSRF token from session. |
|
|
406
|
+
| `CsrfMiddleware.with(opts)` | `(opts: { secure?: boolean }) => Middleware` | Configure the middleware (e.g. a `Secure` cookie). |
|
|
407
|
+
|
|
408
|
+
### Errors
|
|
409
|
+
|
|
410
|
+
Session errors extend `SessionError`, which extends the framework's
|
|
411
|
+
`ZerotalError`. Most surface at boot rather than per request — they are
|
|
412
|
+
configuration faults, and failing loudly at startup beats silently losing
|
|
413
|
+
sessions.
|
|
414
|
+
|
|
415
|
+
| Error | Code | Raised when |
|
|
416
|
+
| ---------------------------- | -------------------------- | ---------------------------------------------------------- |
|
|
417
|
+
| `SessionError` | `E_SESSION` | Base class — catch this to handle any session failure. |
|
|
418
|
+
| `SessionSecretMissingError` | `E_SESSION_SECRET_MISSING` | No `SESSION_SECRET` is configured. Raised at boot. |
|
|
419
|
+
| `SessionDriverMissingError` | `E_SESSION_DRIVER_MISSING` | The configured driver name matches nothing registered. |
|
|
420
|
+
| `SessionCookieOverflowError` | — | The serialised session exceeds the 4 KB a cookie can hold. |
|
|
421
|
+
|
|
422
|
+
`SessionCookieOverflowError` is the one you meet in real use. The `cookie` driver
|
|
423
|
+
stores the whole session in the cookie itself, so putting a user object — or a
|
|
424
|
+
flash message with a stack trace — into the session overflows a hard browser
|
|
425
|
+
limit:
|
|
426
|
+
|
|
427
|
+
```typescript
|
|
428
|
+
// in a controller
|
|
429
|
+
import { SessionCookieOverflowError } from "@zerotal/session";
|
|
430
|
+
|
|
431
|
+
try {
|
|
432
|
+
http.session.put("report", hugeObject);
|
|
433
|
+
} catch (error) {
|
|
434
|
+
if (error instanceof SessionCookieOverflowError) {
|
|
435
|
+
// Keep a key in the session, the payload somewhere with room.
|
|
436
|
+
const id = await Cache.put(hugeObject);
|
|
437
|
+
http.session.put("reportId", id);
|
|
438
|
+
} else throw error;
|
|
439
|
+
}
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
The fix is nearly always to store an identifier rather than the object, or to
|
|
443
|
+
move to a server-side driver where the cookie holds only the session id.
|
|
444
|
+
|
|
445
|
+
## Next steps
|
|
446
|
+
|
|
447
|
+
- [Middleware](/docs/middleware) — where `SessionMiddleware` and `CsrfMiddleware`
|
|
448
|
+
sit in the pipeline.
|
|
449
|
+
- [HttpContext](/docs/context) — the `ctx` object that carries the session.
|
|
450
|
+
- [Authentication](/docs/authentication) — build on regenerated, user-scoped sessions.
|
|
451
|
+
- [Cookies](/docs/cookies) — the lower-level cookie API the cookie driver builds on.
|