weifuwu 0.12.0 → 0.13.1
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 +95 -1427
- package/cli.ts +88 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +75 -0
- package/dist/env.d.ts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +86 -57
- package/docs/agent.md +44 -0
- package/docs/ai.md +93 -0
- package/docs/extra.md +67 -0
- package/docs/graphql.md +61 -0
- package/docs/messager.md +48 -0
- package/docs/middleware.md +131 -0
- package/docs/opencode.md +252 -0
- package/docs/postgres.md +162 -0
- package/docs/router.md +80 -0
- package/docs/tenant.md +174 -0
- package/docs/tsx.md +199 -0
- package/docs/user.md +167 -0
- package/package.json +7 -2
package/docs/ai.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# AI: Streaming & Workflow
|
|
2
|
+
|
|
3
|
+
> [Home](../README.md) → AI
|
|
4
|
+
|
|
5
|
+
## AI streaming
|
|
6
|
+
|
|
7
|
+
Server-sent event streaming via the Vercel AI SDK:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { serve, Router, aiStream } from 'weifuwu'
|
|
11
|
+
import { openai } from '@ai-sdk/openai'
|
|
12
|
+
|
|
13
|
+
const app = new Router()
|
|
14
|
+
const chat = await aiStream(async (req, ctx) => {
|
|
15
|
+
const { messages } = await req.json()
|
|
16
|
+
return { model: openai('gpt-4o'), messages }
|
|
17
|
+
})
|
|
18
|
+
app.use('/chat', chat.router())
|
|
19
|
+
|
|
20
|
+
serve(app.handler(), { port: 3000 })
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## runWorkflow
|
|
24
|
+
|
|
25
|
+
Multi-step DAG execution engine — packaged as a single AI SDK `Tool`. Use it with `streamText()` or `generateText()` when the LLM needs conditional logic, loops, or multi-step tool orchestration.
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { tool, streamText } from 'ai'
|
|
29
|
+
import { runWorkflow } from 'weifuwu'
|
|
30
|
+
import { z } from 'zod'
|
|
31
|
+
|
|
32
|
+
const tools = {
|
|
33
|
+
queryUser: tool({
|
|
34
|
+
description: 'Query user info',
|
|
35
|
+
inputSchema: z.object({ userId: z.string() }),
|
|
36
|
+
execute: async ({ userId }) => ({ id: userId, email: 'user@test.com', name: 'Test' }),
|
|
37
|
+
}),
|
|
38
|
+
sendEmail: tool({
|
|
39
|
+
description: 'Send an email',
|
|
40
|
+
inputSchema: z.object({ to: z.string(), subject: z.string() }),
|
|
41
|
+
execute: async ({ to, subject }) => ({ sent: true }),
|
|
42
|
+
}),
|
|
43
|
+
runWF: runWorkflow({ tools: { queryUser, sendEmail } }),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Use in any streamText call — the LLM can decide when to trigger a workflow
|
|
47
|
+
const result = await streamText({
|
|
48
|
+
model,
|
|
49
|
+
tools,
|
|
50
|
+
messages: [{ role: 'user', content: '查询用户123,如果存在则发送欢迎邮件' }],
|
|
51
|
+
})
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Node types
|
|
55
|
+
|
|
56
|
+
7 built-in node types for defining the execution graph:
|
|
57
|
+
|
|
58
|
+
| Node | Purpose | Input |
|
|
59
|
+
|------|---------|-------|
|
|
60
|
+
| `call` | Call a registered AI SDK Tool | `{ tool: "name", args: {...} }` |
|
|
61
|
+
| `set` | Assign a variable | `{ name: "x", value: 42 }` |
|
|
62
|
+
| `get` | Read a variable | `{ name: "x" }` |
|
|
63
|
+
| `eval` | Evaluate an expression | `{ expression: "$var.x + 1" }` |
|
|
64
|
+
| `if` | Conditional branch | `{ conditions: [{ test: ..., body: [nodes] }] }` |
|
|
65
|
+
| `while` | Loop | `{ condition: "$var.i < 5" }, body: [nodes]` |
|
|
66
|
+
| `http` | HTTP request | `{ url: "https://...", method: "GET" }` |
|
|
67
|
+
|
|
68
|
+
### Reference syntax
|
|
69
|
+
|
|
70
|
+
| Pattern | Meaning | Example |
|
|
71
|
+
|---------|---------|---------|
|
|
72
|
+
| `$var.x` | Variable `x` | `$var.counter` |
|
|
73
|
+
| `$nodes.u.output` | Full output of node `u` | `$nodes.u.output` |
|
|
74
|
+
| `$nodes.u.output.field` | Specific field | `$nodes.u.output.email` |
|
|
75
|
+
| `$input.userId` | Input param | `$input.userId` |
|
|
76
|
+
|
|
77
|
+
### LLM generation
|
|
78
|
+
|
|
79
|
+
Pass a `model` to `runWorkflow` — the LLM generates the workflow JSON from a goal:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
const runWF = runWorkflow({
|
|
83
|
+
tools: { queryUser, sendEmail },
|
|
84
|
+
model: openai('gpt-4o'),
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const result = await streamText({
|
|
88
|
+
model,
|
|
89
|
+
tools: { runWF },
|
|
90
|
+
})
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The LLM calls `runWF` with a goal, and `runWorkflow` internally calls `generateText` to produce the workflow nodes, then executes them.
|
package/docs/extra.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Health, i18n, Email & Testing
|
|
2
|
+
|
|
3
|
+
> [Home](../README.md) → Extra
|
|
4
|
+
|
|
5
|
+
## Health check
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { serve, Router, health } from 'weifuwu'
|
|
9
|
+
|
|
10
|
+
const app = new Router()
|
|
11
|
+
app.use(health()) // GET /health → 200
|
|
12
|
+
app.use(health({ path: '/healthz' })) // custom path
|
|
13
|
+
app.use(health({
|
|
14
|
+
check: async () => { await db.sql`SELECT 1` }, // fail → 503
|
|
15
|
+
}))
|
|
16
|
+
serve(app.handler(), { port: 3000 })
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Returns a `Router` — mount with `app.use()`.
|
|
20
|
+
|
|
21
|
+
## Internationalization
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { i18n } from 'weifuwu'
|
|
25
|
+
|
|
26
|
+
app.use(i18n({ dir: './locales', defaultLocale: 'en' }))
|
|
27
|
+
|
|
28
|
+
// In any handler after i18n middleware:
|
|
29
|
+
app.get('/hello', (req, ctx) => {
|
|
30
|
+
const msg = ctx.t('greeting', { name: 'World' })
|
|
31
|
+
return Response.json({ message: msg, locale: ctx.locale })
|
|
32
|
+
})
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Locale detection: `Cookie: locale=zh` → `Accept-Language: zh-CN` → `defaultLocale`.
|
|
36
|
+
|
|
37
|
+
## Email
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { mailer } from 'weifuwu'
|
|
41
|
+
|
|
42
|
+
// SMTP transport
|
|
43
|
+
const mail = mailer({
|
|
44
|
+
transport: 'smtp://user:pass@smtp.example.com',
|
|
45
|
+
from: 'noreply@example.com',
|
|
46
|
+
})
|
|
47
|
+
await mail.send({ to: 'user@example.com', subject: 'Welcome', html: '<h1>Hi!</h1>' })
|
|
48
|
+
await mail.close()
|
|
49
|
+
|
|
50
|
+
// Custom transport (Resend, SES, SendGrid, etc.)
|
|
51
|
+
const mail2 = mailer({
|
|
52
|
+
send: async (msg) => { await resend.emails.send(msg) },
|
|
53
|
+
})
|
|
54
|
+
await mail2.send({ to: 'user@example.com', subject: 'Hi', text: 'Hello' })
|
|
55
|
+
await mail2.close()
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Test utilities
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { createTestServer } from 'weifuwu'
|
|
62
|
+
|
|
63
|
+
const { server, url } = await createTestServer(app.handler())
|
|
64
|
+
const res = await fetch(`${url}/api/users`)
|
|
65
|
+
assert.equal(res.status, 200)
|
|
66
|
+
server.stop()
|
|
67
|
+
```
|
package/docs/graphql.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# GraphQL
|
|
2
|
+
|
|
3
|
+
> [Home](../README.md) → GraphQL
|
|
4
|
+
|
|
5
|
+
## GraphQL
|
|
6
|
+
|
|
7
|
+
Dynamic GraphQL schema generated per-request based on the authenticated tenant's tables:
|
|
8
|
+
|
|
9
|
+
```graphql
|
|
10
|
+
type Article {
|
|
11
|
+
id: ID!
|
|
12
|
+
title: String!
|
|
13
|
+
content: String
|
|
14
|
+
status: String
|
|
15
|
+
comments(limit: Int, offset: Int): [Comment!]!
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type Query {
|
|
19
|
+
articles(limit: Int, offset: Int): [Article!]!
|
|
20
|
+
getArticle(id: ID!): Article
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type Mutation {
|
|
24
|
+
createArticle(data: CreateArticleInput!): Article!
|
|
25
|
+
updateArticle(id: ID!, data: PatchArticleInput!): Article!
|
|
26
|
+
deleteArticle(id: ID!): Boolean!
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Built with `graphql-js` native constructors (`GraphQLObjectType`), no SDL generation, no `makeExecutableSchema`.
|
|
31
|
+
|
|
32
|
+
### Middleware
|
|
33
|
+
|
|
34
|
+
`t.middleware()` extracts the tenant context:
|
|
35
|
+
|
|
36
|
+
1. Requires `ctx.user` (from `u.middleware()`)
|
|
37
|
+
2. Looks up user's tenant memberships
|
|
38
|
+
3. Single tenant → automatically set `ctx.tenant`
|
|
39
|
+
4. Multiple tenants → require `X-Tenant-ID` header, return 300 with tenant list if missing
|
|
40
|
+
5. No tenants → 403
|
|
41
|
+
|
|
42
|
+
### Tenant lifecycle
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
const t = tenant({ pg, usersTable: '_users' })
|
|
46
|
+
|
|
47
|
+
// Create a tenant — the caller becomes admin
|
|
48
|
+
const tenant = await (await fetch('http://localhost/api/sys/tenants', {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer <jwt>' },
|
|
51
|
+
body: JSON.stringify({ name: 'Acme Corp' }),
|
|
52
|
+
})).json()
|
|
53
|
+
// → { id: "uuid", name: "Acme Corp", created_at: "..." }
|
|
54
|
+
|
|
55
|
+
// Invite a member
|
|
56
|
+
await fetch('http://localhost/api/sys/tenants/invite', {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer <jwt>' },
|
|
59
|
+
body: JSON.stringify({ email: 'colleague@acme.com', role: 'member' }),
|
|
60
|
+
})
|
|
61
|
+
```
|
package/docs/messager.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Messager
|
|
2
|
+
|
|
3
|
+
> [Home](../README.md) → Messager
|
|
4
|
+
|
|
5
|
+
## Messager
|
|
6
|
+
|
|
7
|
+
Real-time chat with channels, WebSocket, and agent routing.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { messager, agent } from 'weifuwu'
|
|
11
|
+
|
|
12
|
+
const agents = agent({ pg })
|
|
13
|
+
const msg = messager({ pg, agents })
|
|
14
|
+
|
|
15
|
+
await msg.migrate()
|
|
16
|
+
app.use('/api', msg.router())
|
|
17
|
+
app.ws('/ws', u.middleware(), msg.wsHandler())
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### Channels
|
|
21
|
+
|
|
22
|
+
```http
|
|
23
|
+
POST /channels name, type (channel|dm), members
|
|
24
|
+
GET /channels
|
|
25
|
+
GET /channels/:id
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Messages
|
|
29
|
+
|
|
30
|
+
```http
|
|
31
|
+
GET /channels/:id/messages ?limit=50&before={id}
|
|
32
|
+
POST /channels/:id/messages content, sender_type, type
|
|
33
|
+
POST /channels/:id/read last_message_id
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### WebSocket
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{ "type": "message", "channel_id": 1, "content": "Hi" }
|
|
40
|
+
{ "type": "typing", "channel_id": 1, "is_typing": true }
|
|
41
|
+
{ "type": "read", "channel_id": 1, "last_message_id": 42 }
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Programmatic send
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
await msg.send(channelId, 'System message', { sender_type: 'system' })
|
|
48
|
+
```
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Middleware
|
|
2
|
+
|
|
3
|
+
> [Home](../README.md) → Middleware
|
|
4
|
+
|
|
5
|
+
## Built-in middleware
|
|
6
|
+
|
|
7
|
+
### Auth
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { auth } from 'weifuwu'
|
|
11
|
+
|
|
12
|
+
// Static bearer token
|
|
13
|
+
app.use(auth({ token: 'sk-123' }))
|
|
14
|
+
|
|
15
|
+
// Custom verify (JWT, DB, etc.) — return object to set ctx.user
|
|
16
|
+
app.use(auth({
|
|
17
|
+
verify: async (token) => {
|
|
18
|
+
const user = await db.findUserByToken(token)
|
|
19
|
+
return user ? { sub: user.id, role: user.role } : null
|
|
20
|
+
},
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
// Proxy validation to external auth service
|
|
24
|
+
app.get('/protected', auth({ proxy: 'http://auth:3000/validate' }), handler)
|
|
25
|
+
|
|
26
|
+
// Custom header
|
|
27
|
+
app.use(auth({ header: 'X-API-Key', token: 'my-key' }))
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### CORS
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { cors } from 'weifuwu'
|
|
34
|
+
|
|
35
|
+
app.use(cors()) // allow all
|
|
36
|
+
app.use(cors({ origin: ['https://example.com'] })) // whitelist
|
|
37
|
+
app.use(cors({ origin: (o) => o.endsWith('.trusted.com') ? o : false }))
|
|
38
|
+
app.use(cors({ credentials: true, maxAge: 3600 }))
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Logger
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { logger } from 'weifuwu'
|
|
45
|
+
|
|
46
|
+
app.use(logger()) // GET /hello 200 5ms
|
|
47
|
+
app.use(logger({ format: 'combined' })) // with query params
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Rate limit
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { rateLimit } from 'weifuwu'
|
|
54
|
+
|
|
55
|
+
app.use(rateLimit({ max: 100, window: 60_000 })) // 100 req/min
|
|
56
|
+
app.get('/api', rateLimit({ max: 10 }), handler) // per-route
|
|
57
|
+
|
|
58
|
+
// Custom key (by API key, user ID, etc.)
|
|
59
|
+
app.use(rateLimit({
|
|
60
|
+
max: 1000,
|
|
61
|
+
key: (req) => req.headers.get('x-api-key') ?? 'anonymous',
|
|
62
|
+
}))
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Compression
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { compress } from 'weifuwu'
|
|
69
|
+
|
|
70
|
+
app.use(compress()) // brotli > gzip > deflate
|
|
71
|
+
app.use(compress({ threshold: 2048 })) // only compress > 2KB
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Validation
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { z } from 'zod'
|
|
78
|
+
import { validate } from 'weifuwu'
|
|
79
|
+
|
|
80
|
+
const CreateUser = z.object({
|
|
81
|
+
name: z.string().min(1),
|
|
82
|
+
email: z.string().email(),
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
router.post('/users',
|
|
86
|
+
validate({ body: CreateUser }),
|
|
87
|
+
(req, ctx) => {
|
|
88
|
+
// ctx.parsed.body — typed & validated
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## File upload
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { upload } from 'weifuwu'
|
|
97
|
+
|
|
98
|
+
router.post('/upload',
|
|
99
|
+
upload({ dir: './uploads', maxFileSize: 10_485_760 }),
|
|
100
|
+
(req, ctx) => {
|
|
101
|
+
// ctx.parsed.files.avatar → { name, type, size, path }
|
|
102
|
+
// ctx.parsed.fields.title → 'hello'
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Cookie
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
import { getCookies, setCookie, deleteCookie } from 'weifuwu'
|
|
111
|
+
|
|
112
|
+
// Read
|
|
113
|
+
const cookies = getCookies(req) // { session: 'abc' }
|
|
114
|
+
|
|
115
|
+
// Set (immutable — returns new Response)
|
|
116
|
+
let res = new Response('ok')
|
|
117
|
+
res = setCookie(res, 'session', 'token', { httpOnly: true, secure: true, maxAge: 3600 })
|
|
118
|
+
|
|
119
|
+
// Delete
|
|
120
|
+
res = deleteCookie(res, 'session')
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Static files
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import { serveStatic } from 'weifuwu'
|
|
127
|
+
|
|
128
|
+
router.get('/static/*', serveStatic('./public'))
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Features: MIME type detection (20+ types), ETag + If-None-Match (304), directory index (index.html), path traversal protection, Cache-Control.
|
package/docs/opencode.md
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# Opencode
|
|
2
|
+
|
|
3
|
+
> [Home](../README.md) → Opencode
|
|
4
|
+
|
|
5
|
+
## Opencode
|
|
6
|
+
|
|
7
|
+
AI programming assistant — chat with LLM agents that have access to filesystem tools, skills, and isolated session workspaces.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { serve, Router, postgres, opencode } from 'weifuwu'
|
|
11
|
+
|
|
12
|
+
const app = new Router()
|
|
13
|
+
const pg = postgres()
|
|
14
|
+
const oc = await opencode({ pg, permissions: { ... } })
|
|
15
|
+
|
|
16
|
+
await oc.migrate()
|
|
17
|
+
app.use('/opencode', await oc.router())
|
|
18
|
+
app.ws('/opencode', oc.wsHandler())
|
|
19
|
+
|
|
20
|
+
serve(app.handler(), { port: 3000, websocket: app.websocketHandler() })
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Session-isolated workspaces
|
|
24
|
+
|
|
25
|
+
Each session gets its own sandbox directory — tools operate within it, files cannot escape:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
cwd/.sessions/opencode/1/ ← session 1's workspace
|
|
29
|
+
cwd/.sessions/opencode/2/ ← session 2's workspace
|
|
30
|
+
cwd/.sessions/chat/3/ ← different mount point
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Workspaces are computed from `cwd { ctx.mountPath } { sessionId }`. The system prompt shows the session's workspace so the LLM knows where it is.
|
|
34
|
+
|
|
35
|
+
### Tools
|
|
36
|
+
|
|
37
|
+
| Tool | Description |
|
|
38
|
+
|------|-------------|
|
|
39
|
+
| `bash` | Execute shell commands in the workspace |
|
|
40
|
+
| `read` | Read files with offset/limit |
|
|
41
|
+
| `write` | Create or overwrite files |
|
|
42
|
+
| `edit` | Exact string replacements |
|
|
43
|
+
| `grep` | Regex content search |
|
|
44
|
+
| `glob` | Glob pattern file search |
|
|
45
|
+
| `web` | Fetch URL content |
|
|
46
|
+
| `question` | Ask the user for input |
|
|
47
|
+
| `skill` | Load a skill on demand |
|
|
48
|
+
|
|
49
|
+
### Skills
|
|
50
|
+
|
|
51
|
+
Skills are discovered from filesystem and loaded on demand via the `skill` tool — no system prompt bloat:
|
|
52
|
+
|
|
53
|
+
- Project: `.opencode/skills/{name}/SKILL.md`
|
|
54
|
+
- Global: `~/.config/opencode/skills/{name}/SKILL.md`
|
|
55
|
+
- Also reads: `.claude/skills/`, `.agents/skills/` (project + global)
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
const oc = await opencode({
|
|
59
|
+
pg,
|
|
60
|
+
skills: [{ name: 'git', description: 'Git workflow', content: '...' }],
|
|
61
|
+
})
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Permissions
|
|
65
|
+
|
|
66
|
+
Control tool access per conversation:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
const oc = await opencode({
|
|
70
|
+
pg,
|
|
71
|
+
permissions: {
|
|
72
|
+
bash: { allow: true },
|
|
73
|
+
read: { allow: true },
|
|
74
|
+
write: { allow: false },
|
|
75
|
+
edit: { allow: false },
|
|
76
|
+
skill: { '*': { allow: true }, 'internal-*': { allow: false } },
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Workspace isolation
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
const oc = await opencode({ pg, permissions })
|
|
85
|
+
// All sessions inherit the instance's workspace (default: process.cwd())
|
|
86
|
+
// Sessions cannot override their workspace
|
|
87
|
+
// Different mount points = different opencode() instances = isolated workspaces
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import { serve, Router, postgres, user } from 'weifuwu'
|
|
92
|
+
|
|
93
|
+
const app = new Router()
|
|
94
|
+
const pg = postgres()
|
|
95
|
+
await pg.migrate()
|
|
96
|
+
|
|
97
|
+
const auth = user({ pg, jwtSecret: process.env.JWT_SECRET! })
|
|
98
|
+
|
|
99
|
+
// POST /auth/register { email, password, name }
|
|
100
|
+
// POST /auth/login { email, password }
|
|
101
|
+
// GET /auth/oauth/authorize?client_id=...&redirect_uri=...&response_type=code
|
|
102
|
+
// POST /auth/oauth/consent
|
|
103
|
+
// POST /auth/oauth/token (grant_type=authorization_code|client_credentials)
|
|
104
|
+
app.use('/auth', auth.router())
|
|
105
|
+
|
|
106
|
+
// Protected routes — verifies JWT, sets ctx.user
|
|
107
|
+
app.get('/me', auth.middleware(), async (req, ctx) => {
|
|
108
|
+
return Response.json(ctx.user)
|
|
109
|
+
// { id, email, name, role }
|
|
110
|
+
})
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Password hashing uses `crypto.scryptSync` + `timingSafeEqual` (Node.js built-in, zero deps). JWT tokens use the `jsonwebtoken` package. The users table (`_users` by default) is auto-created on first `migrate()`.
|
|
114
|
+
|
|
115
|
+
### OAuth2 Server
|
|
116
|
+
|
|
117
|
+
Enable OAuth2 Server to let third-party apps (SPA, mobile, microservices) authenticate users through your app.
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
const auth = user({
|
|
121
|
+
pg,
|
|
122
|
+
jwtSecret: process.env.JWT_SECRET!,
|
|
123
|
+
oauth2: { server: true },
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
await auth.migrate() // creates _users + _oauth2_clients + _oauth2_codes + _oauth2_tokens
|
|
127
|
+
|
|
128
|
+
// Register a client app (programmatic — CLI, admin UI, seed script)
|
|
129
|
+
const client = await auth.registerClient({
|
|
130
|
+
name: 'My SPA',
|
|
131
|
+
redirectUris: ['https://myapp.com/callback'],
|
|
132
|
+
})
|
|
133
|
+
// → { clientId, clientSecret, name, redirectUris }
|
|
134
|
+
|
|
135
|
+
// Use auth middleware to protect routes — OAuth2 JWT tokens work seamlessly
|
|
136
|
+
app.get('/api/data', auth.middleware(), handler)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
#### Supported Grant Types
|
|
140
|
+
|
|
141
|
+
| Grant | Use Case | PKCE |
|
|
142
|
+
|-------|----------|------|
|
|
143
|
+
| `authorization_code` (with client_secret) | Server-side apps | Optional |
|
|
144
|
+
| `authorization_code` (with `code_challenge`/`code_verifier`) | SPA / Mobile apps | Required |
|
|
145
|
+
| `client_credentials` | Machine-to-machine | — |
|
|
146
|
+
|
|
147
|
+
#### Flow (Authorization Code + PKCE)
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
1. 第三方 App 引导用户:
|
|
151
|
+
GET /oauth/authorize?client_id=xxx&redirect_uri=https://app.com/cb
|
|
152
|
+
&response_type=code&code_challenge=S256&state=yyy
|
|
153
|
+
|
|
154
|
+
2. 用户未登录 → 302 到 /login?redirect=... → 登录后自动回到授权页
|
|
155
|
+
|
|
156
|
+
3. 用户确认授权 → POST /oauth/consent { approve: true, client_id, ... }
|
|
157
|
+
302 redirect_uri?code=xxx&state=yyy
|
|
158
|
+
|
|
159
|
+
4. 第三方 App POST /oauth/token
|
|
160
|
+
{ grant_type: authorization_code, code, client_id, client_secret,
|
|
161
|
+
redirect_uri, code_verifier }
|
|
162
|
+
→ { access_token, token_type: "Bearer", expires_in, refresh_token }
|
|
163
|
+
|
|
164
|
+
5. access_token 是标准 JWT,auth.middleware() 和 auth.verify() 直接可用
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
#### Client Management
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
const client = await auth.registerClient({ name, redirectUris })
|
|
171
|
+
const found = await auth.getClient(client.clientId)
|
|
172
|
+
await auth.revokeClient(client.clientId)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
#### Using OAuth2 Tokens with the Built-in Auth Middleware
|
|
176
|
+
|
|
177
|
+
OAuth2 Server 签发的 `access_token` 与密码登录的 JWT 使用同一 `jwtSecret`,payload 向下兼容(`sub`、`email`、`role`),所以 `auth()` 无需任何修改即可验证 OAuth2 签发的 token:
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
import { auth } from 'weifuwu'
|
|
181
|
+
|
|
182
|
+
// 同一个 auth() 中间件同时支持密码登录 JWT 和 OAuth2 JWT
|
|
183
|
+
app.get('/api', auth({ verify: (token) => auth.verify(token) }), handler)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
For `client_credentials` tokens (machine-to-machine), `verify()` returns `null` since no user is associated.
|
|
187
|
+
|
|
188
|
+
### Social Login (GitHub) — Cookbook
|
|
189
|
+
|
|
190
|
+
`user()` 不内置 social login(避免绑定第三方平台),但用底层 API 加一个 GitHub 登录只需 ~30 行:
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
import { user } from 'weifuwu'
|
|
194
|
+
import jwt from 'jsonwebtoken'
|
|
195
|
+
|
|
196
|
+
const auth = user({ pg, jwtSecret })
|
|
197
|
+
|
|
198
|
+
// 1. 跳转 GitHub 授权
|
|
199
|
+
app.get('/auth/github', () => {
|
|
200
|
+
const url = new URL('https://github.com/login/oauth/authorize')
|
|
201
|
+
url.searchParams.set('client_id', process.env.GH_CLIENT_ID!)
|
|
202
|
+
url.searchParams.set('redirect_uri', 'http://localhost:3000/auth/github/callback')
|
|
203
|
+
url.searchParams.set('scope', 'user:email')
|
|
204
|
+
return Response.redirect(url.href)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
// 2. GitHub 回调 → 获取用户信息 → 注册/登录
|
|
208
|
+
app.get('/auth/github/callback', async (req) => {
|
|
209
|
+
const { code } = Object.fromEntries(new URL(req.url).searchParams)
|
|
210
|
+
if (!code) return new Response('Missing code', { status: 400 })
|
|
211
|
+
|
|
212
|
+
// 交换 token
|
|
213
|
+
const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
|
|
214
|
+
method: 'POST',
|
|
215
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
216
|
+
body: JSON.stringify({
|
|
217
|
+
client_id: process.env.GH_CLIENT_ID,
|
|
218
|
+
client_secret: process.env.GH_CLIENT_SECRET,
|
|
219
|
+
code,
|
|
220
|
+
}),
|
|
221
|
+
})
|
|
222
|
+
const { access_token } = await tokenRes.json() as any
|
|
223
|
+
|
|
224
|
+
// 获取用户信息
|
|
225
|
+
const userRes = await fetch('https://api.github.com/user', {
|
|
226
|
+
headers: { Authorization: `Bearer ${access_token}` },
|
|
227
|
+
})
|
|
228
|
+
const ghUser = await userRes.json() as any
|
|
229
|
+
|
|
230
|
+
// 查找或创建本地用户
|
|
231
|
+
const existing = await pg.sql`SELECT * FROM "_users" WHERE email = ${ghUser.email}`
|
|
232
|
+
let localUser = existing[0]
|
|
233
|
+
|
|
234
|
+
if (!localUser) {
|
|
235
|
+
localUser = await auth.register({
|
|
236
|
+
email: ghUser.email,
|
|
237
|
+
password: crypto.randomUUID(), // 随机密码,用户只能用 GitHub 登录
|
|
238
|
+
name: ghUser.name ?? ghUser.login,
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 签发 JWT(与 user() 同一格式)
|
|
243
|
+
const token = jwt.sign(
|
|
244
|
+
{ sub: localUser.id, email: localUser.email, role: localUser.role ?? 'user' },
|
|
245
|
+
process.env.JWT_SECRET!,
|
|
246
|
+
{ expiresIn: '24h' },
|
|
247
|
+
)
|
|
248
|
+
return Response.json({ token })
|
|
249
|
+
})
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
同样的模式适配 Google、微信、任何 OAuth2 provider。
|