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/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
+ ```
@@ -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
+ ```
@@ -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.
@@ -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。