@wabot-dev/create 0.0.2 → 0.0.4

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.
@@ -0,0 +1,238 @@
1
+ import fs from 'fs/promises'
2
+ import os from 'os'
3
+ import path from 'path'
4
+
5
+ import { getSkill, listSkillNames, listSkills } from './registry.mjs'
6
+
7
+ const supportedAgents = {
8
+ codex: ['.codex', 'skills'],
9
+ claude: ['.claude', 'skills'],
10
+ agents: ['.agents', 'skills'],
11
+ }
12
+
13
+ export function listSupportedAgents() {
14
+ return Object.keys(supportedAgents)
15
+ }
16
+
17
+ export async function installSkillsInProject(projectDir, agents, skillNames) {
18
+ if (!projectDir) {
19
+ throw new Error('installSkillsInProject: projectDir is required')
20
+ }
21
+
22
+ const resolvedAgents = (agents && agents.length > 0 ? agents : ['claude']).map((agent) => {
23
+ if (!supportedAgents[agent]) {
24
+ throw new Error(
25
+ `Unsupported agent "${agent}". Supported: ${Object.keys(supportedAgents).join(', ')}`,
26
+ )
27
+ }
28
+ return agent
29
+ })
30
+
31
+ const skills =
32
+ skillNames && skillNames.length > 0 ? skillNames.map((name) => requireSkill(name)) : listSkills()
33
+
34
+ const installations = []
35
+
36
+ for (const agent of resolvedAgents) {
37
+ const baseDir = path.join(projectDir, ...supportedAgents[agent])
38
+ await fs.mkdir(baseDir, { recursive: true })
39
+
40
+ for (const skill of skills) {
41
+ const installDir = path.join(baseDir, skill.name)
42
+ try {
43
+ await fs.cp(skill.sourceDir, installDir, {
44
+ recursive: true,
45
+ force: false,
46
+ errorOnExist: true,
47
+ })
48
+ installations.push({ agent, skill: skill.name, installDir, status: 'installed' })
49
+ } catch (error) {
50
+ if (error?.code === 'ERR_FS_CP_EEXIST' || error?.code === 'EEXIST') {
51
+ installations.push({ agent, skill: skill.name, installDir, status: 'skipped' })
52
+ continue
53
+ }
54
+ throw error
55
+ }
56
+ }
57
+ }
58
+
59
+ return { agents: resolvedAgents, skills: skills.map((skill) => skill.name), installations }
60
+ }
61
+
62
+ function requireSkill(name) {
63
+ const skill = getSkill(name)
64
+ if (!skill) {
65
+ throw new Error(
66
+ `Unknown skill "${name}". Available skills: ${listSkillNames().join(', ')}`,
67
+ )
68
+ }
69
+ return skill
70
+ }
71
+
72
+ export async function installSkill(skillName, options, cwd = process.cwd()) {
73
+ const skill = getSkill(skillName)
74
+
75
+ if (!skill) {
76
+ throw new Error(
77
+ `Unknown skill "${skillName}". Available skills: ${listSkillNames().join(', ')}`,
78
+ )
79
+ }
80
+
81
+ const plan = await buildInstallPlan(skill, options, cwd)
82
+
83
+ for (const destination of plan.destinations) {
84
+ await fs.mkdir(destination.baseDir, { recursive: true })
85
+ await fs.cp(skill.sourceDir, destination.installDir, {
86
+ recursive: true,
87
+ force: false,
88
+ errorOnExist: true,
89
+ })
90
+ }
91
+
92
+ return {
93
+ skill,
94
+ mode: plan.mode,
95
+ homeRoot: plan.homeRoot,
96
+ installations: plan.destinations.map((destination) => ({
97
+ agent: destination.agent,
98
+ installDir: destination.installDir,
99
+ })),
100
+ }
101
+ }
102
+
103
+ async function buildInstallPlan(skill, options, cwd) {
104
+ await assertPathExists(skill.sourceDir, `Packaged skill source not found at ${skill.sourceDir}`)
105
+
106
+ if (options.local && options.global) {
107
+ throw new Error('Choose only one install mode: --local or --global')
108
+ }
109
+
110
+ if (!options.global && (options.target || options.targets || options.home)) {
111
+ throw new Error('--home, --target, and --targets can only be used together with --global')
112
+ }
113
+
114
+ if (options.local) {
115
+ const installDir = path.join(cwd, '.agents', 'skills', skill.name)
116
+ await assertDestinationAvailable(installDir)
117
+
118
+ return {
119
+ mode: 'local',
120
+ homeRoot: null,
121
+ destinations: [
122
+ {
123
+ agent: 'agents',
124
+ baseDir: path.join(cwd, '.agents', 'skills'),
125
+ installDir,
126
+ },
127
+ ],
128
+ }
129
+ }
130
+
131
+ const homeRoot = resolveHomeRoot(options.home)
132
+ const targets = resolveTargets(options)
133
+ const destinations = targets.map((agent) => {
134
+ const baseDir = resolveAgentSkillsDir(homeRoot, agent)
135
+
136
+ return {
137
+ agent,
138
+ baseDir,
139
+ installDir: path.join(baseDir, skill.name),
140
+ }
141
+ })
142
+
143
+ await Promise.all(destinations.map((destination) => assertDestinationAvailable(destination.installDir)))
144
+
145
+ return {
146
+ mode: 'global',
147
+ homeRoot,
148
+ destinations,
149
+ }
150
+ }
151
+
152
+ function resolveTargets(options) {
153
+ const parsedTargets = []
154
+
155
+ if (options.target) {
156
+ parsedTargets.push(options.target)
157
+ }
158
+
159
+ if (options.targets) {
160
+ parsedTargets.push(
161
+ ...String(options.targets)
162
+ .split(',')
163
+ .map((target) => target.trim())
164
+ .filter(Boolean),
165
+ )
166
+ }
167
+
168
+ if (parsedTargets.length === 0) {
169
+ return ['codex']
170
+ }
171
+
172
+ const uniqueTargets = [...new Set(parsedTargets)]
173
+
174
+ if (uniqueTargets.length !== parsedTargets.length) {
175
+ throw new Error('Duplicate targets are not allowed in --target/--targets')
176
+ }
177
+
178
+ for (const target of uniqueTargets) {
179
+ if (!supportedAgents[target]) {
180
+ throw new Error(
181
+ `Unsupported target "${target}". Supported targets: ${Object.keys(supportedAgents).join(', ')}`,
182
+ )
183
+ }
184
+ }
185
+
186
+ return uniqueTargets
187
+ }
188
+
189
+ function resolveHomeRoot(explicitHome) {
190
+ if (explicitHome) {
191
+ return path.resolve(explicitHome)
192
+ }
193
+
194
+ const homeRoot = os.homedir()
195
+
196
+ if (!homeRoot) {
197
+ throw new Error('Unable to resolve a home directory. Pass --home <path> explicitly.')
198
+ }
199
+
200
+ return path.resolve(homeRoot)
201
+ }
202
+
203
+ function resolveAgentSkillsDir(homeRoot, agent) {
204
+ const segments = supportedAgents[agent]
205
+
206
+ if (!segments) {
207
+ throw new Error(
208
+ `Unsupported target "${agent}". Supported targets: ${Object.keys(supportedAgents).join(', ')}`,
209
+ )
210
+ }
211
+
212
+ return path.join(homeRoot, ...segments)
213
+ }
214
+
215
+ async function assertDestinationAvailable(installDir) {
216
+ try {
217
+ await fs.access(installDir)
218
+ throw new Error(`Skill already exists at ${installDir}`)
219
+ } catch (error) {
220
+ if (error?.code === 'ENOENT') {
221
+ return
222
+ }
223
+
224
+ throw error
225
+ }
226
+ }
227
+
228
+ async function assertPathExists(targetPath, errorMessage) {
229
+ try {
230
+ await fs.access(targetPath)
231
+ } catch (error) {
232
+ if (error?.code === 'ENOENT') {
233
+ throw new Error(errorMessage)
234
+ }
235
+
236
+ throw error
237
+ }
238
+ }
@@ -0,0 +1,64 @@
1
+ import { fileURLToPath } from 'url'
2
+
3
+ function packagedSkill(name, description) {
4
+ return {
5
+ name,
6
+ description,
7
+ sourceDir: fileURLToPath(new URL(`../../skills/${name}`, import.meta.url)),
8
+ }
9
+ }
10
+
11
+ const skillDefinitions = {
12
+ 'wabot-framework': packagedSkill(
13
+ 'wabot-framework',
14
+ 'Umbrella skill — project boot, layout, scripts, and where to dive for each subsystem',
15
+ ),
16
+ 'wabot-di-config': packagedSkill(
17
+ 'wabot-di-config',
18
+ 'DI lifecycles, Env, and typed config references',
19
+ ),
20
+ 'wabot-validation': packagedSkill(
21
+ 'wabot-validation',
22
+ 'Validators, @description, validateAndTransform, Mapper',
23
+ ),
24
+ 'wabot-persistence': packagedSkill(
25
+ 'wabot-persistence',
26
+ 'Entities, repositories, query DSL, mem/pg extensions',
27
+ ),
28
+ 'wabot-mindset': packagedSkill(
29
+ 'wabot-mindset',
30
+ 'Mindsets, modules, models, ChatOperator',
31
+ ),
32
+ 'wabot-chat': packagedSkill(
33
+ 'wabot-chat',
34
+ 'Chat controllers, channels (cmd/socket/telegram/whatsapp/wasender), chat-bot adapters',
35
+ ),
36
+ 'wabot-rest-socket': packagedSkill(
37
+ 'wabot-rest-socket',
38
+ 'REST and Socket.IO controllers, middlewares, handshake guards',
39
+ ),
40
+ 'wabot-async': packagedSkill(
41
+ 'wabot-async',
42
+ 'Commands, cron handlers, @transaction, the Async service',
43
+ ),
44
+ 'wabot-auth': packagedSkill(
45
+ 'wabot-auth',
46
+ 'Auth<D> scope, JWT and API key guards, token issuing',
47
+ ),
48
+ 'wabot-ops': packagedSkill(
49
+ 'wabot-ops',
50
+ 'Logger, Locker, CustomError, Password, Random',
51
+ ),
52
+ }
53
+
54
+ export function getSkill(skillName) {
55
+ return skillDefinitions[skillName] ?? null
56
+ }
57
+
58
+ export function listSkillNames() {
59
+ return Object.keys(skillDefinitions).sort()
60
+ }
61
+
62
+ export function listSkills() {
63
+ return listSkillNames().map((name) => skillDefinitions[name])
64
+ }
package/package.json CHANGED
@@ -1,19 +1,18 @@
1
1
  {
2
2
  "name": "@wabot-dev/create",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Project creator for Wabot Framework",
5
5
  "license": "ISC",
6
6
  "author": "",
7
7
  "type": "module",
8
8
  "main": "index.js",
9
- "scripts": {
10
- "test": "echo \"Error: no test specified\" && exit 1"
11
- },
12
9
  "bin": {
13
10
  "create-wabot": "./bin/create-wabot.mjs"
14
11
  },
15
12
  "files": [
16
- "bin"
13
+ "bin",
14
+ "lib",
15
+ "skills"
17
16
  ],
18
17
  "dependencies": {
19
18
  "chalk": "^5.4.1",
@@ -0,0 +1,139 @@
1
+ ---
2
+ name: wabot-async
3
+ description: Use when running background work in Wabot — fire-and-forget commands, scheduled deferred commands, cron handlers, or wrapping side-effectful methods in a DB transaction. Covers @command, @commandHandler, @cronHandler, @transaction, the Async service (runCommand, scheduleCommand, IScheduleAt), ICommandHandler / ICronHandler, and how the in-memory vs PG job/cron stores are wired by the project runner.
4
+ ---
5
+
6
+ # Async commands, cron, and transactions
7
+
8
+ Wabot's async layer handles three kinds of work:
9
+
10
+ - **Commands** — typed messages with a registered handler. Run immediately (`runCommand`) or scheduled (`scheduleCommand`).
11
+ - **Crons** — handlers that fire on a cron expression.
12
+ - **Transactions** — wrap a method so all repository writes happen inside one DB transaction per registered adapter.
13
+
14
+ The job store, cron job store, and transaction adapter are auto-selected by the project runner based on `DATABASE_URL` (in-memory ↔ PostgreSQL). No manual registration.
15
+
16
+ ## Commands
17
+
18
+ A command is a plain class decorated with `@command(name)`. Fields use the standard validators from `wabot-validation`.
19
+
20
+ ```typescript
21
+ import { command, isNumber, isString, min } from '@wabot-dev/framework'
22
+
23
+ @command('orders.charge')
24
+ export class ChargeOrder {
25
+ @isString() orderId!: string
26
+ @isNumber() @min(1) amountCents!: number
27
+ }
28
+ ```
29
+
30
+ The handler implements `ICommandHandler<C>` and is bound with `@commandHandler(Command)`:
31
+
32
+ ```typescript
33
+ import { commandHandler, ICommandHandler, injectable } from '@wabot-dev/framework'
34
+ import { ChargeOrder } from './ChargeOrder'
35
+
36
+ @commandHandler(ChargeOrder)
37
+ export class ChargeOrderHandler implements ICommandHandler<ChargeOrder> {
38
+ constructor(private payments: PaymentService) {}
39
+
40
+ async handle(cmd: ChargeOrder) {
41
+ await this.payments.charge(cmd.orderId, cmd.amountCents)
42
+ }
43
+ }
44
+ ```
45
+
46
+ Trigger it with the `Async` singleton:
47
+
48
+ ```typescript
49
+ import { Async, container } from '@wabot-dev/framework'
50
+
51
+ const async = container.resolve(Async)
52
+
53
+ // Run as soon as a worker is free
54
+ await async.runCommand(ChargeOrder, { orderId: 'o_1', amountCents: 2500 })
55
+
56
+ // Schedule for later: absolute Date or relative delay
57
+ await async.scheduleCommand(ChargeOrder, { orderId: 'o_1', amountCents: 2500 }, { minutes: 30 })
58
+ await async.scheduleCommand(ChargeOrder, { orderId: 'o_1', amountCents: 2500 }, new Date('2026-12-31T08:00:00Z'))
59
+ ```
60
+
61
+ `IScheduleAt = Date | { seconds: number } | { minutes: number } | { hours: number } | { days: number }`.
62
+
63
+ `Async.runCommand` returns a `Job` immediately (it has an `id` you can persist). The payload is validated against the command class before the job is stored — bad shapes throw `CustomError`.
64
+
65
+ `@commandHandler` applies `@injectable()`. There is at most one handler per command name; registering a second throws.
66
+
67
+ ## Cron handlers
68
+
69
+ ```typescript
70
+ import { cronHandler, ICronHandler } from '@wabot-dev/framework'
71
+
72
+ @cronHandler({ name: 'daily-cleanup', cron: '0 3 * * *' })
73
+ export class DailyCleanup implements ICronHandler {
74
+ constructor(private games: GameRepository) {}
75
+
76
+ async handle() {
77
+ // ...
78
+ }
79
+
80
+ // Optional. Called when handle() throws.
81
+ async handleError(err: any) {
82
+ // ...
83
+ }
84
+ }
85
+ ```
86
+
87
+ - `cron` accepts standard 5-field or 6-field (with seconds) cron expressions.
88
+ - `disabled: true` turns a handler off without removing it (useful for guarded rollouts).
89
+ - `@cronHandler` applies `@singleton()` automatically — do not stack lifecycle decorators.
90
+
91
+ The runner registers these into `CronScheduler`, which persists per-cron state in `CronJobRepository` so missed runs aren't replayed on restart.
92
+
93
+ ## Transactions
94
+
95
+ `@transaction(dbNames?)` wraps an async method so every adapter listed runs the call inside a transaction. Without arguments, all registered adapters participate (today there is one: `'pg'` when `DATABASE_URL` is Postgres).
96
+
97
+ ```typescript
98
+ import { transaction } from '@wabot-dev/framework'
99
+
100
+ @injectable()
101
+ export class Checkout {
102
+ @transaction(['pg'])
103
+ async checkout(orderId: string) {
104
+ await this.orderRepository.update(...)
105
+ await this.paymentRepository.create(...)
106
+ }
107
+ }
108
+ ```
109
+
110
+ Behavior:
111
+ - Repository writes performed inside the method go to the same client/connection.
112
+ - Throwing rolls back; returning commits.
113
+ - Nested `@transaction`-decorated calls reuse the outer transaction (no nested begin).
114
+ - If you decorate with `@transaction(['pg'])` and the app is running without Postgres, the call still executes but no PG transaction wraps it (the in-memory adapter is a no-op).
115
+
116
+ ## Manual run / stop
117
+
118
+ In production the runner discovers and starts everything. For testing or custom hosts:
119
+
120
+ ```typescript
121
+ import { runCommandHandlers, runCronHandlers, stopCommandHandlers, stopCronHandlers } from '@wabot-dev/framework'
122
+
123
+ runCommandHandlers([ChargeOrderHandler])
124
+ runCronHandlers([DailyCleanup])
125
+
126
+ // later
127
+ stopCommandHandlers([ChargeOrderHandler])
128
+ stopCronHandlers([DailyCleanup])
129
+ ```
130
+
131
+ `testAsync` is exported for integration tests against the real job/cron schedulers — see `wabot-ts/src/feature/async/testAsync.ts` for an example.
132
+
133
+ ## Rules
134
+
135
+ - Command classes are *data* classes. Don't put behavior on them — that belongs in the handler.
136
+ - Don't reuse a command name for two handlers. The framework throws on boot.
137
+ - Don't `await async.runCommand(...)` inside a request handler expecting it to finish synchronously — `runCommand` schedules the job and returns immediately. Use `async.scheduleCommand(..., { seconds: 0 })` if you want the same fire-and-forget behavior explicitly.
138
+ - Cron expressions in seconds (6 fields) are supported but be careful: the in-memory adapter polls fast, the PG adapter polls at a lower rate.
139
+ - Wrap multi-repository writes in `@transaction(['pg'])` rather than chaining manual `withPgTransaction` calls.
@@ -0,0 +1,149 @@
1
+ ---
2
+ name: wabot-auth
3
+ description: Use when adding authentication to a Wabot REST endpoint or Socket.IO namespace, issuing JWT access/refresh tokens, or protecting endpoints with API keys. Covers the chat/request-scoped Auth<D> service, @jwtGuard / @jwtHandshakeGuard, JwtConfig and the JWT_* env vars, Jwt.createToken / findRefreshTokenAuthInfo, JwtRefreshToken, and @apiKeyGuard / @apiKeyHandshakeGuard with PgApiKeyRepository / RemoteApiKeyRepository.
4
+ ---
5
+
6
+ # Authentication
7
+
8
+ Wabot's auth is request-scoped. A guard middleware verifies the credential, then writes the resolved auth info into the per-request/socket `Auth<D>` instance. Downstream code reads it with `auth.require()`.
9
+
10
+ ## `Auth<D>`
11
+
12
+ ```typescript
13
+ import { Auth, injectable } from '@wabot-dev/framework'
14
+
15
+ interface SessionInfo {
16
+ userId: string
17
+ role: 'admin' | 'user'
18
+ }
19
+
20
+ @injectable()
21
+ export class OrdersService {
22
+ constructor(private auth: Auth<SessionInfo>) {}
23
+
24
+ list() {
25
+ const session = this.auth.require() // throws CustomError({ httpCode: 401 }) if not assigned
26
+ return this.repo.findByUserId(session.userId)
27
+ }
28
+ }
29
+ ```
30
+
31
+ `Auth` is `@scoped(Lifecycle.ContainerScoped)` and exists once per request, chat, or socket connection. Methods:
32
+
33
+ - `assign(info)` — sets the auth info (used by guards). Throws if already assigned.
34
+ - `override(info)` — replaces even when assigned (use cautiously).
35
+ - `require()` — returns the info or throws 401.
36
+ - `isAssigned()` / `wasOverrided()` / `clear()`.
37
+
38
+ ## JWT — REST
39
+
40
+ ```typescript
41
+ import { jwtGuard, onGet, onPost, restController } from '@wabot-dev/framework'
42
+
43
+ @restController('/account')
44
+ export class AccountController {
45
+ @onGet('/me')
46
+ @jwtGuard()
47
+ async me(_, auth: Auth<SessionInfo>) {
48
+ return auth.require()
49
+ }
50
+ }
51
+ ```
52
+
53
+ `@jwtGuard()` reads `Authorization: Bearer <token>`, verifies it with `JwtConfig` (env-driven, see below), and calls `auth.assign(payload)`.
54
+
55
+ ## JWT — Socket
56
+
57
+ ```typescript
58
+ import { jwtHandshakeGuard, socketController } from '@wabot-dev/framework'
59
+
60
+ @socketController('private')
61
+ @jwtHandshakeGuard()
62
+ export class PrivateSocketController { /* ... */ }
63
+ ```
64
+
65
+ The token is read from `socket.handshake.auth.token` first, falling back to the `Authorization` header.
66
+
67
+ ## Issuing tokens
68
+
69
+ ```typescript
70
+ import { Auth, Jwt, JwtAccessAndRefreshTokenDto, injectable, onPost, restController } from '@wabot-dev/framework'
71
+
72
+ @restController('/auth')
73
+ export class AuthController {
74
+ constructor(private jwt: Jwt, private auth: Auth<SessionInfo>) {}
75
+
76
+ @onPost('/login')
77
+ async login(body: LoginRequest): Promise<JwtAccessAndRefreshTokenDto> {
78
+ const session = await this.userService.verify(body)
79
+ this.auth.assign(session)
80
+ return this.jwt.createToken({ device: body.device ?? 'unknown' })
81
+ }
82
+
83
+ @onPost('/refresh')
84
+ async refresh(body: RefreshRequest): Promise<JwtAccessAndRefreshTokenDto> {
85
+ const session = await this.jwt.findRefreshTokenAuthInfo(body.refreshToken)
86
+ this.auth.override(session)
87
+ return this.jwt.createToken()
88
+ }
89
+ }
90
+ ```
91
+
92
+ `Jwt.createToken(metadata?)`:
93
+ - Reads the current `auth.require()` info.
94
+ - Persists a `JwtRefreshToken` via `JwtRefreshTokenRepository` (the runner picks `PgJwtRefreshTokenRepository` under PG; you can register your own).
95
+ - Returns `{ access: { token, expiration }, refresh: { token, expiration } }`.
96
+
97
+ `Jwt.findRefreshTokenAuthInfo(secret)` validates the refresh secret (hash-compare + expiration + revocation) and returns the original auth payload. Throws 401 if any check fails.
98
+
99
+ ### Env vars
100
+
101
+ | Variable | Default | Description |
102
+ | --- | --- | --- |
103
+ | `JWT_SECRET` | (required) | Symmetric secret or asymmetric key used for sign/verify |
104
+ | `JWT_ALGORITHM` | `HS256` | Any `jsonwebtoken.Algorithm` |
105
+ | `JWT_ACCESS_EXPIRATION_SECONDS` | `600` (10 min) | Access token lifetime |
106
+ | `JWT_REFRESH_EXPIRATION_SECONDS` | `31536000` (1 yr) | Refresh token lifetime |
107
+
108
+ `JwtConfig` is `@singleton()` and reads these at boot.
109
+
110
+ ### Refresh-token metadata
111
+
112
+ `JwtRefreshToken<D>` exposes `metadata` (the arbitrary `Record<string, string>` you passed to `createToken`), `expirationTime`, `revoke()`, and `isValidToken(secret)`. The repository exposes `findByMetadata(metadata)` so you can revoke all tokens issued to a device.
113
+
114
+ ## API key
115
+
116
+ ```typescript
117
+ import { apiKeyGuard, apiKeyHandshakeGuard, onGet, restController, socketController } from '@wabot-dev/framework'
118
+
119
+ @restController('/internal')
120
+ export class InternalController {
121
+ @onGet('/health')
122
+ @apiKeyGuard()
123
+ async health() { return { ok: true } }
124
+ }
125
+
126
+ @socketController('admin')
127
+ @apiKeyHandshakeGuard()
128
+ export class AdminSocketController { /* ... */ }
129
+ ```
130
+
131
+ The guard reads the `X-Api-Key` header (REST) or `socket.handshake.auth.apiKey` (socket), validates via `ApiKeyRepository`, and assigns the lookup result to `Auth`.
132
+
133
+ `ApiKeyRepository` defaults to `PgApiKeyRepository` under Postgres. A `RemoteApiKeyRepository` is also exported for delegating validation to an external service:
134
+
135
+ ```typescript
136
+ import { container, ApiKeyRepository, RemoteApiKeyRepository } from '@wabot-dev/framework'
137
+
138
+ container.registerType(ApiKeyRepository, RemoteApiKeyRepository)
139
+ ```
140
+
141
+ Set `WABOT_API_KEY` and `WABOT_LLM_URL` to point `RemoteApiKeyRepository` at your auth service (it expects the same shape as the Wabot platform).
142
+
143
+ ## Rules
144
+
145
+ - Always type `Auth<D>` with your session shape. `auth.require()` returns the typed value.
146
+ - A guard runs once per request/connection. Don't reassign manually — use `override(...)` only on token refresh.
147
+ - `@jwtGuard()` and `@apiKeyGuard()` work on REST endpoints; their `*HandshakeGuard()` variants work on socket controllers. Don't mix them up.
148
+ - Never log `auth.require()` results — they may contain PII or token claims. Use a redacted view.
149
+ - If you need a custom guard, implement `IMiddleware` (REST) or `IHandshakeMiddleware` (socket) and wire it with `@middleware(...)` / `@handshakeMiddlewares([...])`.