@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.
- package/README.md +189 -0
- package/bin/create-wabot.mjs +7 -100
- package/lib/cli.mjs +50 -0
- package/lib/create-project.mjs +386 -0
- package/lib/i18n.mjs +167 -0
- package/lib/skills/install.mjs +238 -0
- package/lib/skills/registry.mjs +64 -0
- package/package.json +4 -5
- package/skills/wabot-async/SKILL.md +139 -0
- package/skills/wabot-auth/SKILL.md +149 -0
- package/skills/wabot-chat/SKILL.md +136 -0
- package/skills/wabot-di-config/SKILL.md +117 -0
- package/skills/wabot-framework/SKILL.md +79 -0
- package/skills/wabot-framework/references/quickstart.md +85 -0
- package/skills/wabot-mindset/SKILL.md +155 -0
- package/skills/wabot-ops/SKILL.md +151 -0
- package/skills/wabot-persistence/SKILL.md +155 -0
- package/skills/wabot-rest-socket/SKILL.md +163 -0
- package/skills/wabot-validation/SKILL.md +104 -0
|
@@ -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.
|
|
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([...])`.
|