@blod-nm/user-kit 0.1.4 → 0.1.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blod-nm/user-kit",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "CloudBase user-center kit: auth (wechat code / unionid / password / sms / email / signup) + crm_user_detail archive on @blod-nm/base-cb.",
@@ -0,0 +1,130 @@
1
+ /**
2
+ * access-key.js — apikey 签发 / verify(+30s 进程缓存)/ key 域能力(spec §3.1 · §3.4 · §3.5 · §3.7)
3
+ *
4
+ * 形态: key='a1_'+b64u(randomBytes(32))(只在签发响应出现一次)· key_hash=sha256hex(key)(档案表主键,库内不存明文)
5
+ * key_id='k_'+key_hash[0:12](展示/吊销句柄)· key_prefix=key[0:11](识别用,不含敏感信息)
6
+ * expires_at=unix 秒(now + (ttl ?? cfg.akDefaultKeyTtlSec))
7
+ * 两张表: __user_token_cache(token 托管,broker 读写)/ __user_access_key(key 档案,本文件读写)
8
+ * verify: 一条 SQL 查 key_hash → 四分支(malformed/revoked/expired/ok);ok 结果进程内 Map 缓存 30s;
9
+ * revoke 用 RETURNING key_hash 主动清缓存;DB 异常 → 503(fail-closed,查不到=拒绝)。
10
+ * key 域: create(当前 key 换 uid → 签发新 key)/ list(不含明文与 hash)/ revoke(他人 key → 404)/ verify。
11
+ * 禁止: 任何官方网关调用 / HMAC / masterSecret(查表派:档案表是唯一真相)。
12
+ */
13
+ import { createHash, randomBytes } from 'node:crypto'
14
+
15
+ const CACHE_TTL_MS = 30_000
16
+ const DEFAULT_TTL_SEC = 2592000
17
+
18
+ export const DDL_USER_TOKEN_CACHE = `CREATE TABLE IF NOT EXISTS __user_token_cache (
19
+ uid text PRIMARY KEY, access_token text NOT NULL, refresh_token text NOT NULL,
20
+ expires_at bigint NOT NULL, updated_at timestamptz DEFAULT now() NOT NULL)`
21
+
22
+ export const DDL_USER_ACCESS_KEY = `CREATE TABLE IF NOT EXISTS __user_access_key (
23
+ key_hash text PRIMARY KEY, key_id text NOT NULL UNIQUE, uid text NOT NULL,
24
+ scene text NOT NULL DEFAULT '', key_prefix text NOT NULL,
25
+ created_at timestamptz DEFAULT now() NOT NULL, expires_at bigint NOT NULL,
26
+ revoked_at timestamptz, updated_at timestamptz DEFAULT now() NOT NULL)`
27
+
28
+ export const DDL_USER_ACCESS_KEY_INDEX = 'CREATE INDEX IF NOT EXISTS idx_user_access_key_uid ON __user_access_key(uid)'
29
+
30
+ const SQL_SELECT_KEY = 'SELECT key_id, uid, scene, key_prefix, revoked_at, expires_at FROM __user_access_key WHERE key_hash=$1'
31
+ const SQL_INSERT_KEY = 'INSERT INTO __user_access_key (key_hash, key_id, uid, scene, key_prefix, expires_at) VALUES ($1, $2, $3, $4, $5, $6)'
32
+ const SQL_LIST_KEYS = 'SELECT key_id, key_prefix, scene, created_at, expires_at, revoked_at FROM __user_access_key WHERE uid=$1 ORDER BY created_at DESC'
33
+ const SQL_REVOKE_KEY = 'UPDATE __user_access_key SET revoked_at=now(), updated_at=now() WHERE key_id=$1 AND uid=$2 AND revoked_at IS NULL RETURNING key_hash'
34
+
35
+ const err = (code, message) => Object.assign(new Error(message), { code })
36
+ const nowSec = () => Math.floor(Date.now() / 1000)
37
+ export const sha256hex = s => createHash('sha256').update(String(s)).digest('hex')
38
+
39
+ /** 进程内 verify 缓存(key_hash → { result, until });只缓存 ok 结果,写路径不读 */
40
+ export const createVerifyCache = (ttlMs = CACHE_TTL_MS) => {
41
+ const store = new Map()
42
+ return {
43
+ get(hash) {
44
+ const hit = store.get(hash)
45
+ if (!hit) return null
46
+ if (hit.until <= Date.now()) { store.delete(hash); return null }
47
+ return hit.result
48
+ },
49
+ set(hash, result) { store.set(hash, { result, until: Date.now() + ttlMs }) },
50
+ clear(hash) { store.delete(hash) },
51
+ size: () => store.size,
52
+ }
53
+ }
54
+
55
+ /**
56
+ * key 域工厂:verify / issue 闭包共享缓存与 runSql 接缝;abilities 为 auth.key.* 四条注册项。
57
+ * runSql(sql, parameters) 是本文件唯一表读写入口(§3.7,pg 适配器由 index.js 注入)。
58
+ */
59
+ export function createKeyAbilities({ cfg, runSql }) {
60
+ const cache = createVerifyCache()
61
+ const ttlSec = () => Number(cfg?.akDefaultKeyTtlSec ?? DEFAULT_TTL_SEC)
62
+
63
+ // 签发:生成明文 + 落档(只返回 hash 供内部使用,明文仅由调用方取走一次性响应)
64
+ async function issue(uid, { scene = '', ttl } = {}) {
65
+ const apikey = 'a1_' + randomBytes(32).toString('base64url')
66
+ const keyHash = sha256hex(apikey)
67
+ const keyId = 'k_' + keyHash.slice(0, 12)
68
+ const keyPrefix = apikey.slice(0, 11)
69
+ const expiresAt = nowSec() + Number(ttl ?? ttlSec())
70
+ await runSql(SQL_INSERT_KEY, [keyHash, keyId, uid, scene ?? '', keyPrefix, expiresAt])
71
+ return { apikey, key_hash: keyHash, key_id: keyId, key_prefix: keyPrefix, expires_at: expiresAt }
72
+ }
73
+
74
+ // 唯一裁决路径:sha256(key) → 一行 SQL → 四分支;ok 结果 30s 缓存
75
+ async function verify(apikey, { useCache = true } = {}) {
76
+ const keyHash = sha256hex(apikey ?? '')
77
+ if (useCache) {
78
+ const hit = cache.get(keyHash)
79
+ if (hit) return hit
80
+ }
81
+ let rows
82
+ try { rows = await runSql(SQL_SELECT_KEY, [keyHash]) } catch { throw err(503, 'apikey 校验失败') }
83
+ const row = rows?.[0]
84
+ const result = !row ? { ok: false, reason: 'malformed' }
85
+ : row.revoked_at != null ? { ok: false, reason: 'revoked' }
86
+ : Number(row.expires_at) <= nowSec() ? { ok: false, reason: 'expired' }
87
+ : { ok: true, auth: { uidRef: row.uid, scene: row.scene ?? '', keyId: row.key_id, keyPrefix: row.key_prefix, expiresAt: Number(row.expires_at) } }
88
+ if (result.ok) cache.set(keyHash, result)
89
+ return result
90
+ }
91
+
92
+ // 凭证类能力共用:verify → 401 / 返回 uid(内部调用无缓存,避免读到过期判定)
93
+ async function uidOf(apikey) {
94
+ const v = await verify(apikey, { useCache: false })
95
+ if (!v.ok) throw err(401, 'apikey 无效')
96
+ return v.auth.uidRef
97
+ }
98
+
99
+ const abilities = [
100
+ ['auth.key.create', { describe: '签发新 apikey(当前 key 换 uid;scene 只存不用)', run: async (args = {}) => {
101
+ const uid = await uidOf(args.apikey)
102
+ const scene = typeof args.scene === 'string' ? args.scene : ''
103
+ let issued
104
+ try { issued = await issue(uid, { scene }) } catch { throw err(500, 'apikey 签发失败') }
105
+ return { apikey: issued.apikey, key_id: issued.key_id, key_prefix: issued.key_prefix, expires_at: issued.expires_at }
106
+ } }],
107
+
108
+ ['auth.key.list', { describe: '当前用户的 key 档案列表(不含明文/key_hash)', run: async (args = {}) => {
109
+ const uid = await uidOf(args.apikey)
110
+ let rows
111
+ try { rows = await runSql(SQL_LIST_KEYS, [uid]) } catch { throw err(503, '查询 key 失败') }
112
+ return (rows ?? []).map(r => ({ ...r, expires_at: Number(r.expires_at) }))
113
+ } }],
114
+
115
+ ['auth.key.revoke', { describe: '吊销 key(key_id 必须属于当前用户;0 行 → 404)', run: async (args = {}) => {
116
+ const uid = await uidOf(args.apikey)
117
+ if (!args.key_id) throw err(400, 'key_id 必填')
118
+ let rows
119
+ try { rows = await runSql(SQL_REVOKE_KEY, [args.key_id, uid]) } catch { throw err(503, '吊销 key 失败') }
120
+ const keyHash = rows?.[0]?.key_hash
121
+ if (!keyHash) throw err(404, 'key 不存在或不属于当前用户')
122
+ cache.clear(keyHash) // 写路径不读缓存;成功即失效该 key
123
+ return { revoked: true, key_id: args.key_id }
124
+ } }],
125
+
126
+ ['auth.key.verify', { describe: '校验 apikey(唯一裁决路径,返回 verify 结果对象)', run: async (args = {}) => verify(args.apikey) }],
127
+ ]
128
+
129
+ return { verify, issue, uidOf, abilities, cache }
130
+ }
package/src/auth.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * auth.js — auth 域能力目录(8 条)+ 编排(登录链路/错误翻译 · spec §3.1–3.5 · §3.7)
2
+ * auth.js — auth 域能力目录(13 条)+ 编排(登录链路/错误翻译 · spec §3.1–3.5 · §3.7)
3
3
  *
4
4
  * 端点(主场实测确认,{origin}=https://<envId>.api.tcloudbasegateway.com):
5
5
  * POST /auth/v1/verification { phone | email } → 发验证码,返回 verification_token(响应原样)
@@ -10,20 +10,22 @@
10
10
  * POST /auth/v1/user/reauthenticate(Bearer);GET /auth/v1/token/introspect(Bearer)
11
11
  *
12
12
  * 语义:
13
- * · 透传三种(password/sms/email):body 原样、响应原样返回(不裁剪不翻译,只补 uid)+ 成功后建档。
14
- * uid 提取:payload.user.id 优先,否则 decodeJWT(access_token).sub(不验签,token 刚由网关签发);
15
- * 都取不到 → 500「用户注册失败」。建档失败 → 同 500。
13
+ * · 透传三种(password/sms/email):body 原样发网关;uid 提取:payload.sub → payload.user.id →
14
+ * decodeJWT(access_token).sub(不验签,token 刚由网关签发);都取不到 → 500「用户注册失败」。建档失败 → 同 500。
15
+ * · 登录收尾(§3.2):建档 → 官方 token 托管(broker.upsert,失败 500)→ 签发 apikey(失败 500);
16
+ * 响应只含 { apikey, expires_at }(wxcode 另有 config),官方 token/uid 字段不出域。
16
17
  * · 网关非 2xx:原样透传 { code: 网关状态, payload },不改写错误码(INVALID_USERNAME_OR_PASSWORD 原样可见);
17
18
  * 网络异常(无 status)→ 502「登录请求失败」。
18
19
  * · 自定义链路:customSignin 非 2xx → 502「自定义登录请求失败」;code2Session errcode 非零 → 400{errcode,errmsg}。
19
20
  * · loginByWxcode:uid=唯一 id(wxResolveUid 缺省 identity:uid=openid),unionid 字段=微信原始值|null,不可互换。
20
21
  *
21
- * §3.12 补全: auth.signup(手机/邮箱验证码注册,响应原样+补 uid+建档)/ auth.signOut(不写档)。
22
+ * §3.12 补全: auth.signup(手机/邮箱验证码注册,建档+托管+签发 key)/ auth.signOut(不写档,apikey 入参)。
22
23
  * 自定义登录只有两个入口(钉死):loginByWxcode(配置的微信小程序 appid/secret,code→openid→唯一 id→ticket→signin/custom)
23
24
  * 与 loginByUnionid(外部传唯一 id→ticket→signin/custom);二者都必须建档;unionid=我们的唯一 id,与微信 openid/unionid 语义无关。
24
25
  *
25
26
  * §3.13 uid 提取顺序(统一所有登录/注册类): payload.sub → payload.user.id → decodeJWT(access_token).sub →
26
- * 无则登录/注册类 500「用户注册失败」(token 类走 uidFromToken 400「access_token 无效」)。
27
+ * 无则登录/注册类 500「用户注册失败」。凭证类能力(signOut/reauthenticate/introspect)入参 apikey:
28
+ * verify 不通过 → 401「apikey 无效」,通过后 broker.get(uid) 换官方 Bearer。
27
29
  *
28
30
  * §3.8 增补(4 条): auth.sendVerification(phone/email 二选一;sendSmsCode/sendEmailCode 保留薄别名)/
29
31
  * auth.verifyCode / auth.reauthenticate(verify_opt ∈ phone_code|email_code)/ auth.introspect ——
@@ -35,7 +37,7 @@
35
37
  */
36
38
  import cloudbase from '@cloudbase/node-sdk'
37
39
  import { code2Session as wxCode2Session } from './wechat.js'
38
- import { decodeJWT, throwGatewayError, makeGateway, uidFromToken } from './user.js'
40
+ import { decodeJWT, throwGatewayError, makeGateway } from './user.js'
39
41
  import { withSchemas } from './schemas.js'
40
42
 
41
43
  export { decodeJWT } // 供消费方/测试从包主入口取(真身在 user.js)
@@ -69,9 +71,14 @@ async function tcbCreateTicket(cfg, uid) {
69
71
  return tcbAppCache.auth().createTicket(uid, TICKET_TTL)
70
72
  }
71
73
 
72
- /* —— 能力目录 + 编排(ability 闭包持有 cfg/inject/kit)—— */
74
+ /* —— 能力目录 + 编排(ability 闭包持有 cfg/inject/kit/deps)—— */
73
75
 
74
- export function createAuthAbilities(cfg, kit) {
76
+ // 刷新请求器:broker 单飞刷新使用(inject.refreshTokenRequest 可替换)
77
+ export const makeRefreshTokenRequest = (cfg, kit) => (cfg.inject || {}).refreshTokenRequest
78
+ || (body => kit.req(`https://${cfg.envId}.${cfg.domain || DEFAULT_DOMAIN}/auth/v1/token`, { method: 'POST', body }))
79
+
80
+ // deps(index.js 注入): broker(token 托管)/ verify(apikey 裁决)/ issue(签发新 key)/ refreshTokenRequest
81
+ export function createAuthAbilities(cfg, kit, deps = {}) {
75
82
  const inject = cfg.inject || {}
76
83
  const origin = `https://${cfg.envId}.${cfg.domain || DEFAULT_DOMAIN}`
77
84
  const sendVerification = inject.sendVerification || (body => kit.req(`${origin}/auth/v1/verification`, { method: 'POST', body }))
@@ -80,7 +87,6 @@ export function createAuthAbilities(cfg, kit) {
80
87
  const code2Session = inject.code2Session || (code => wxCode2Session(cfg, code))
81
88
  const wxResolveUid = inject.wxResolveUid || (wx => wx.openid) // 缺省 identity:uid=openid(现网语义)
82
89
  const createTicket = inject.createTicket || (uid => tcbCreateTicket(cfg, uid))
83
- const refreshTokenRequest = inject.refreshTokenRequest || (body => kit.req(`${origin}/auth/v1/token`, { method: 'POST', body }))
84
90
  const fetchPrefixConfig = inject.fetchPrefixConfig || (() => kit.req(`${origin}/v1/storages/object/store/public/wx_mp_conf/prefix_config.json`, { headers: { 'Cache-Control': 'no-cache' } }))
85
91
  const gateway = makeGateway(cfg, kit)
86
92
  const gw = async (method, path, opts) => { try { return await gateway(method, path, opts) } catch (e) { throwGatewayError(e) } }
@@ -92,7 +98,8 @@ export function createAuthAbilities(cfg, kit) {
92
98
  const ticket = await createTicket(uid)
93
99
  let res
94
100
  try { res = await customSignin(ticket) } catch { throw err(502, '自定义登录请求失败') }
95
- return { access_token: res.access_token, refresh_token: res.refresh_token, expires_in: res.expires_in }
101
+ const { access_token: at, refresh_token: rt, expires_in } = res
102
+ return { at, rt, expires_in } // 仅内部传递;官方 token 不出域
96
103
  }
97
104
 
98
105
  // 建档统一翻 500「用户注册失败」
@@ -103,7 +110,27 @@ export function createAuthAbilities(cfg, kit) {
103
110
  // uid 提取四级顺序(§3.13):payload.sub → payload.user.id → decodeJWT(access_token).sub
104
111
  const uidOfPayload = p => p?.sub || p?.user?.id || decodeJWT(p?.access_token).sub || null
105
112
 
106
- // 透传三种共用:body 原样发网关,响应原样返回(只补 uid)+ 成功后建档
113
+ // 登录收尾(§3.2):官方 token 托管落库 → 签发 key(托管不落库就不发 key,fail-closed)
114
+ async function issueSession(uid, tokens) {
115
+ const expiresAt = Math.floor(Date.now() / 1000) + (Number(tokens?.expires_in) || 0)
116
+ const at = tokens?.at ?? tokens?.access_token
117
+ const rt = tokens?.rt ?? tokens?.refresh_token
118
+ try { await deps.broker.upsert(uid, at, rt, expiresAt) }
119
+ catch { throw err(500, '官方凭证托管失败') }
120
+ let issued
121
+ try { issued = await deps.issue(uid) } catch { throw err(500, 'apikey 签发失败') }
122
+ return { apikey: issued.apikey, expires_at: issued.expires_at }
123
+ }
124
+
125
+ // 凭证类统一序(§3.3):verify(apikey) → 401 → broker.get(uid) → 官方 Bearer 调用
126
+ async function sessionOf(apikey) {
127
+ const v = await deps.verify(apikey)
128
+ if (!v.ok) throw err(401, 'apikey 无效')
129
+ const { access_token: token } = await deps.broker.get(v.auth.uidRef)
130
+ return { uid: v.auth.uidRef, token } // 官方 token 只在内部/网关 Bearer 使用
131
+ }
132
+
133
+ // 透传三种共用:body 原样发网关 → uid → 建档 → 托管 + 签发 key
107
134
  async function passthroughLogin(body, required) {
108
135
  for (const field of required) {
109
136
  if (!body?.[field]) throw err(400, `${field} 必填`)
@@ -113,7 +140,7 @@ export function createAuthAbilities(cfg, kit) {
113
140
  const uid = uidOfPayload(payload)
114
141
  if (!uid) throw err(500, '用户注册失败')
115
142
  await upsertOr500({ uid, profile: {} })
116
- return { ...payload, uid }
143
+ return issueSession(uid, payload)
117
144
  }
118
145
 
119
146
  return withSchemas([
@@ -141,16 +168,15 @@ export function createAuthAbilities(cfg, kit) {
141
168
  } }],
142
169
 
143
170
  ['auth.reauthenticate', { describe: '重认证发码:POST /auth/v1/user/reauthenticate(Bearer;verify_opt ∈ phone_code|email_code)', run: async (args = {}) => {
144
- const { access_token, verify_opt } = args
145
- uidFromToken(access_token)
171
+ const { apikey, verify_opt } = args
146
172
  if (!['phone_code', 'email_code'].includes(verify_opt)) throw err(400, 'verify_opt 必填(phone_code|email_code)')
147
- const { access_token: _t, ...rest } = args
148
- return gw('POST', '/auth/v1/user/reauthenticate', { body: rest, token: access_token })
173
+ const { token } = await sessionOf(apikey)
174
+ return gw('POST', '/auth/v1/user/reauthenticate', { body: { verify_opt }, token })
149
175
  } }],
150
176
 
151
- ['auth.introspect', { describe: 'token 校验:GET /auth/v1/token/introspect(Bearer),网关原样(含 sub)', run: async ({ access_token } = {}) => {
152
- uidFromToken(access_token)
153
- return gw('GET', '/auth/v1/token/introspect', { token: access_token })
177
+ ['auth.introspect', { describe: 'token 校验:GET /auth/v1/token/introspect(Bearer),网关原样(含 sub)', run: async ({ apikey } = {}) => {
178
+ const { token } = await sessionOf(apikey)
179
+ return gw('GET', '/auth/v1/token/introspect', { token })
154
180
  } }],
155
181
 
156
182
  ['auth.loginByPassword', { describe: '账号密码登录(透传+建档):POST /auth/v1/signin { username, password },响应原样补 uid', run: async (args = {}) => passthroughLogin({ username: args.username, password: args.password }, ['username', 'password']) }],
@@ -167,14 +193,15 @@ export function createAuthAbilities(cfg, kit) {
167
193
  if (wx.errcode) throw err(400, wx.errmsg || 'code2Session 失败', { errcode: wx.errcode, errmsg: wx.errmsg || 'code2Session 失败' })
168
194
  const uid = await wxResolveUid(wx)
169
195
  await upsertOr500({ uid, profile: { openid: wx.openid, unionid: wx.unionid || null } })
170
- return { uid, openid: wx.openid, unionid: wx.unionid || null, ...(await finishLogin(uid)), config: await fetchPrefixConfig() }
196
+ const session = await issueSession(uid, await finishLogin(uid))
197
+ return { ...session, config: await fetchPrefixConfig() }
171
198
  } }],
172
199
 
173
- ['auth.loginByUnionid', { describe: '开放登录:唯一 id 注册登录(建档→ticket→signin/custom;5 字段,不含 config)', run: async (args = {}) => {
200
+ ['auth.loginByUnionid', { describe: '开放登录:唯一 id 注册登录(建档→ticket→signin/custom→托管+签发 key)', run: async (args = {}) => {
174
201
  const { unionid } = args
175
202
  if (!unionid) throw err(400, '缺少 unionid')
176
203
  await upsertOr500({ uid: unionid, profile: {} })
177
- return { uid: unionid, unionid, ...(await finishLogin(unionid)) }
204
+ return issueSession(unionid, await finishLogin(unionid))
178
205
  } }],
179
206
 
180
207
  ['auth.signup', { describe: '注册(手机/邮箱/provider_token 三形态):POST /auth/v1/signup,响应原样+补 uid+建档', run: async (args = {}) => {
@@ -190,21 +217,13 @@ export function createAuthAbilities(cfg, kit) {
190
217
  if (args.email !== undefined) profile.email = args.email
191
218
  if (args.phone_number !== undefined) profile.phone = args.phone_number
192
219
  await upsertOr500({ uid, profile })
193
- return { ...payload, uid }
220
+ return issueSession(uid, payload)
194
221
  } }],
195
222
 
196
223
  ['auth.signOut', { describe: '登出:POST /auth/v1/user/signout(Bearer),不写档', run: async (args = {}) => {
197
- const { access_token, ...rest } = args ?? {}
198
- uidFromToken(access_token)
199
- return gw('POST', '/auth/v1/user/signout', { body: rest, token: access_token })
200
- } }],
201
-
202
- ['auth.refresh', { describe: 'refresh_token 换新令牌(3 字段裸对象)', run: async (args = {}) => {
203
- const { refresh_token } = args
204
- if (!refresh_token) throw err(400, '缺少 refresh_token')
205
- let res
206
- try { res = await refreshTokenRequest({ grant_type: 'refresh_token', refresh_token, client_id: cfg.envId }) } catch { throw err(502, '令牌刷新请求失败') }
207
- return { access_token: res.access_token, refresh_token: res.refresh_token, expires_in: res.expires_in }
224
+ const { apikey, ...rest } = args ?? {}
225
+ const { token } = await sessionOf(apikey)
226
+ return gw('POST', '/auth/v1/user/signout', { body: rest, token })
208
227
  } }],
209
228
  ])
210
229
  }
package/src/index.js CHANGED
@@ -5,33 +5,66 @@
5
5
  * import { createKit } from '@blod-nm/user-kit'
6
6
  * const kit = createKit({ envId, apiKey, wxAppid, wxSecret, tcbAuthCredentials, env?, inject?, signinProviders?, …透传 base-cb })
7
7
  * await kit.ctx.run('pg.exec', { sql: 'select 1' }) // base-cb 网关 14 能力原样可用
8
- * await kit.ctx.run('auth.loginByWxcode', { code }) // user 域与网关能力同总线
9
- * kit.describe() // 能力元数据(14 + 21)
8
+ * await kit.ctx.run('auth.loginByPassword', { username, password }) // → { apikey, expires_at }
9
+ * await kit.ctx.run('user.me', { apikey }) // 凭证面统一 apikey
10
+ * kit.key.verify({ apikey }) / kit.broker.get(uid) // key 域 / token 托管便捷入口
11
+ * kit.describe() // 能力元数据
10
12
  *
11
- * 数据流: cfg fail-fast → createBaseKit(cfg)(base-cb req/gate/ctx)→ 同一 ctx register
12
- * auth 域 12 条(含 2 别名)+ user 域 9 条(闭包持有 cfg/inject/kit)→ kit { ctx, req, cfg, describe }
13
+ * 数据流: cfg fail-fast → createBaseKit(cfg)(base-cb req/gate/ctx)→ runSql 单接缝(pg.exec)
14
+ * → broker(token 托管)/ key 域(签发+verify+缓存)→ auth/user 域闭包持 deps → 注册 auth.cbToken.get(总线消费 broker)
15
+ * → kit { ctx, req, cfg, broker, key, describe }
13
16
  *
14
17
  * cfg 约定(spec §3.0):
15
18
  * 必填 envId / apiKey / wxAppid / wxSecret / tcbAuthCredentials(缺一即抛,fail-fast)
16
- * env 缺省 'prod'(建档 tag from_env);inject.* 九口(含 gateway)原样存 kit.cfg(缺省 = 真实现)
17
- * domain/proxy/proxyBase/timeoutMs/role 透传 base-cb
19
+ * env 缺省 'prod'(建档 tag from_env);akDefaultKeyTtlSec 缺省 2592000(30 天)
20
+ * inject.* 九口(含 gateway)原样存 kit.cfg(缺省 = 真实现);domain/proxy/proxyBase/timeoutMs/role 透传 base-cb
18
21
  */
19
22
  import { createKit as createBaseKit } from '@blod-nm/base-cb'
20
- import { createAuthAbilities } from './auth.js'
23
+ import { createAuthAbilities, makeRefreshTokenRequest } from './auth.js'
21
24
  import { createUserAbilities } from './user.js'
25
+ import { createKeyAbilities } from './access-key.js'
26
+ import { createCbTokenBroker, makeRunSql } from './token-broker.js'
27
+ import { withSchemas } from './schemas.js'
22
28
 
23
29
  const REQUIRED = ['envId', 'apiKey', 'wxAppid', 'wxSecret', 'tcbAuthCredentials']
30
+ const DEFAULT_KEY_TTL_SEC = 2592000
24
31
 
25
32
  export function createKit(cfg = {}) {
26
33
  for (const field of REQUIRED) {
27
34
  if (!cfg?.[field]) throw new Error(`user-kit: cfg 缺 ${field}`)
28
35
  }
29
- const finalCfg = cfg.env ? cfg : { ...cfg, env: 'prod' }
36
+ const finalCfg = { ...cfg, env: cfg.env || 'prod', akDefaultKeyTtlSec: cfg.akDefaultKeyTtlSec ?? DEFAULT_KEY_TTL_SEC }
30
37
  const base = createBaseKit(finalCfg)
31
- for (const [key, ability] of [...createAuthAbilities(finalCfg, base), ...createUserAbilities(finalCfg, base)]) {
38
+
39
+ // §3.7 存储接缝 + §3.6 token 托管 + §3.5 key 域(verify 唯一裁决路径)
40
+ const runSql = makeRunSql(base)
41
+ const refreshTokenRequest = makeRefreshTokenRequest(finalCfg, base)
42
+ const broker = createCbTokenBroker({ cfg: finalCfg, refreshTokenRequest, runSql })
43
+ const keyDomain = createKeyAbilities({ cfg: finalCfg, runSql })
44
+ const deps = { broker, runSql, verify: keyDomain.verify, issue: keyDomain.issue, refreshTokenRequest }
45
+
46
+ // 宿主经总线消费 broker 的唯一通道(透传前 uid → 托管官方 token)
47
+ const cbToken = {
48
+ describe: '托管官方 token 兑换:uid → { access_token, expires_at, from_cache }(宿主透传前取 Bearer)',
49
+ run: async (args = {}) => {
50
+ if (!args?.uid) throw Object.assign(new Error('缺少 uid'), { code: 400 })
51
+ return broker.get(args.uid)
52
+ },
53
+ }
54
+
55
+ for (const [key, ability] of [...createAuthAbilities(finalCfg, base, deps), ...createUserAbilities(finalCfg, base, deps), ...keyDomain.abilities, ...withSchemas([['auth.cbToken.get', cbToken]])]) {
32
56
  base.ctx.register(key, ability)
33
57
  }
34
- return { ctx: base.ctx, req: base.req, cfg: finalCfg, describe: () => base.ctx.describe() }
58
+
59
+ // key 域便捷入口(同 ctx 能力)
60
+ const key = {
61
+ create: args => base.ctx.run('auth.key.create', args),
62
+ list: args => base.ctx.run('auth.key.list', args),
63
+ revoke: args => base.ctx.run('auth.key.revoke', args),
64
+ verify: args => base.ctx.run('auth.key.verify', args),
65
+ }
66
+
67
+ return { ctx: base.ctx, req: base.req, cfg: finalCfg, broker, key, describe: () => base.ctx.describe() }
35
68
  }
36
69
 
37
70
  export default createKit
package/src/schemas.js CHANGED
@@ -1,18 +1,21 @@
1
1
  /**
2
2
  schemas.js — 能力面元数据(纯数据 · 纯对象 JSON Schema,不引 zod)
3
3
 
4
- 职责: 23 条 user-kit 能力的 title / inputSchema / outputSchema / MCP annotations。
5
- 能力实现(auth.js / user.js)只负责 run;约束集中在这里,挂 MCP 时原样透传。
4
+ 职责: 22 条在册能力(DC-06:旧 token 刷新能力已注销)的 title / description / auth / inputSchema / outputSchema / annotations。
5
+ 能力实现(auth.js / user.js)只负责 run;约束与开放策略集中在这里,挂 MCP 时原样透传。
6
6
 
7
- 取用: withSchemas([[key, ability], ...])
7
+ auth 分类(DC-06:对外凭证统一 apikey;官方令牌由服务端换托管凭证注入 Authorization: Bearer):
8
+ 'public' 开放接口,不校验凭证
9
+ 'user' 需要 apikey(schema 只暴露 apikey 入参,官方令牌字段不出域)
10
+ 'self' 同 user,且 uid 由 apikey 档案解析(只能操作自己的档案)
11
+ 内部能力标 mcp:false —— 不注册为 MCP 工具(登录自动建档 / 部署动作)
8
12
 
9
- 必填约定:
10
- · required 列出的字段缺一即 400(实现里已有同名校验)
11
- · 二选一用 anyOf(signup: phone_number/email;sendVerification: phone/email)
12
- · 可选字段在 description 里写明「可选」
13
- · 本地/自定义链路 additionalProperties:false(不接受多余参数);网关透传类保持开放
13
+ 取用: withSchemas([[key, ability], ...])
14
14
  */
15
15
  const str = (description, extra = {}) => ({ type: 'string', description, ...extra })
16
+ // 与 assertUid 运行时规则一致:≤128 字符、无控制字符
17
+ const uidField = { type: 'string', description: '唯一用户 id(≤128 字符,不含控制字符)', maxLength: 128, pattern: '^[^\\u0000-\\u001f\\u007f]+$' }
18
+ const freeObj = description => ({ type: 'object', description, additionalProperties: true })
16
19
  const obj = (properties = {}, { required = [], anyOf, additionalProperties = true } = {}) => ({
17
20
  type: 'object',
18
21
  properties,
@@ -20,65 +23,71 @@ const obj = (properties = {}, { required = [], anyOf, additionalProperties = tru
20
23
  ...(anyOf ? { anyOf } : {}),
21
24
  additionalProperties,
22
25
  })
23
- const tokens = {
24
- access_token: str('访问令牌(必返)'),
25
- refresh_token: str('刷新令牌(必返)'),
26
- expires_in: { type: 'number', description: '有效期秒数(必返)' },
26
+ // DC-06 契约:所有登录/注册只出 apikey(官方令牌不出域)
27
+ const apikeyField = str('apikey(必填;a1_ 前缀)')
28
+ const apikeyIn = { apikey: apikeyField }
29
+ const sessionProps = {
30
+ apikey: str('新签发的 apikey(必返;明文只出现这一次)'),
31
+ expires_at: { type: 'number', description: 'apikey 过期时间(unix 秒,必返)' },
27
32
  }
28
- const tokenOut = { type: 'object', properties: { ...tokens }, required: ['access_token', 'refresh_token', 'expires_in'] }
29
- const tokenIn = obj({ access_token: str('用户访问令牌(必填,Bearer)') }, { required: ['access_token'], additionalProperties: false })
33
+ const sessionOut = { type: 'object', properties: { ...sessionProps }, required: ['apikey', 'expires_at'], additionalProperties: false }
30
34
 
31
35
  export const SCHEMAS = {
32
- /* ---------- auth:自定义登录 ---------- */
36
+ /* ---------- auth:自定义登录(public) ---------- */
33
37
  'auth.loginByWxcode': {
38
+ auth: 'public',
34
39
  title: '微信小程序登录',
40
+ description: '用 wx.login 的 code 登录:自动建档并返回会话令牌与下发配置',
35
41
  inputSchema: obj({ code: str('wx.login 得到的 code(必填)') }, { required: ['code'], additionalProperties: false }),
36
42
  outputSchema: obj({
37
- uid: str('唯一用户 id(必返)'),
38
- openid: str('微信 openid(必返)'),
39
- unionid: { type: ['string', 'null'], description: '微信 unionid,可能为 null(必返)' },
40
- ...tokens,
43
+ ...sessionProps,
41
44
  config: { type: 'object', description: 'prefix_config(必返,客户端强依赖)' },
42
- }, { required: ['uid', 'openid', 'unionid', 'access_token', 'refresh_token', 'expires_in', 'config'], additionalProperties: false }),
45
+ }, { required: ['apikey', 'expires_at', 'config'], additionalProperties: false }),
43
46
  annotations: { openWorldHint: true },
44
47
  },
45
48
  'auth.loginByUnionid': {
49
+ auth: 'public',
46
50
  title: '唯一 id 登录',
51
+ description: '用业务唯一 id 直接登录:自动建档并返回会话令牌',
47
52
  inputSchema: obj({ unionid: str('调用方唯一用户 id(必填)') }, { required: ['unionid'], additionalProperties: false }),
48
- outputSchema: obj({
49
- uid: str('唯一用户 id(必返)'),
50
- unionid: str('唯一用户 id(必返)'),
51
- ...tokens,
52
- }, { required: ['uid', 'unionid', 'access_token', 'refresh_token', 'expires_in'], additionalProperties: false }),
53
+ outputSchema: sessionOut,
53
54
  annotations: { openWorldHint: true },
54
55
  },
55
56
 
56
- /* ---------- auth:透传登录 ---------- */
57
+ /* ---------- auth:透传登录(public) ---------- */
57
58
  'auth.loginByPassword': {
59
+ auth: 'public',
58
60
  title: '账号密码登录',
61
+ description: '账号密码登录:建档 + 托管官方令牌并签发 apikey',
59
62
  inputSchema: obj({ username: str('用户名(必填)'), password: str('密码(必填)') }, { required: ['username', 'password'] }),
60
- outputSchema: obj({ uid: str('唯一用户 id(必返)'), access_token: str('访问令牌(必返)') }, { required: ['uid', 'access_token'] }),
63
+ outputSchema: sessionOut,
61
64
  annotations: { openWorldHint: true },
62
65
  },
63
66
  'auth.loginBySms': {
67
+ auth: 'public',
64
68
  title: '短信验证码登录',
69
+ description: '用 verifyCode 换来的 verification_token 登录:建档 + 托管官方令牌并签发 apikey',
65
70
  inputSchema: obj({ verification_token: str('verifyCode 得到的令牌(必填)') }, { required: ['verification_token'] }),
66
- outputSchema: obj({ uid: str('唯一用户 id(必返)'), access_token: str('访问令牌(必返)') }, { required: ['uid', 'access_token'] }),
71
+ outputSchema: sessionOut,
67
72
  annotations: { openWorldHint: true },
68
73
  },
69
74
  'auth.loginByEmail': {
75
+ auth: 'public',
70
76
  title: '邮箱验证码登录',
77
+ description: '用 verifyCode 换来的 verification_token 登录(邮箱):建档 + 托管官方令牌并签发 apikey',
71
78
  inputSchema: obj({ verification_token: str('verifyCode 得到的令牌(必填)') }, { required: ['verification_token'] }),
72
- outputSchema: obj({ uid: str('唯一用户 id(必返)'), access_token: str('访问令牌(必返)') }, { required: ['uid', 'access_token'] }),
79
+ outputSchema: sessionOut,
73
80
  annotations: { openWorldHint: true },
74
81
  },
75
82
  'auth.signup': {
83
+ auth: 'public',
76
84
  title: '注册',
85
+ description: '注册新用户:手机号+验证码 / 邮箱+验证码 / 第三方 provider_token 三选一,自动建档',
77
86
  inputSchema: obj({
78
87
  phone_number: str('手机号(与 email / provider_token 三选一)'),
79
88
  email: str('邮箱(与 phone_number / provider_token 三选一)'),
80
89
  provider_token: str('第三方 provider_token(三选一;与验证码方式互斥)'),
81
- verification_token: str('验证码令牌(手机/邮箱注册时必填;provider_token 方式不需要)'),
90
+ verification_token: str('验证码令牌(手机/邮箱方式必填;provider_token 方式不需要)'),
82
91
  username: str('用户名(可选)'),
83
92
  password: str('密码(可选)'),
84
93
  }, {
@@ -88,13 +97,15 @@ export const SCHEMAS = {
88
97
  { required: ['provider_token'] },
89
98
  ],
90
99
  }),
91
- outputSchema: obj({ uid: str('唯一用户 id(必返)'), access_token: str('访问令牌(必返)') }, { required: ['uid', 'access_token'] }),
100
+ outputSchema: sessionOut,
92
101
  annotations: { openWorldHint: true },
93
102
  },
94
103
 
95
- /* ---------- auth:验证码 ---------- */
104
+ /* ---------- auth:验证码(public) ---------- */
96
105
  'auth.sendVerification': {
106
+ auth: 'public',
97
107
  title: '发送验证码',
108
+ description: '向手机号或邮箱发送验证码(二选一)',
98
109
  inputSchema: obj({
99
110
  phone: str('手机号(与 email 二选一)'),
100
111
  email: str('邮箱(与 phone 二选一)'),
@@ -103,17 +114,23 @@ export const SCHEMAS = {
103
114
  outputSchema: obj({ verification_id: str('验证码会话 id(必返)') }, { required: ['verification_id'] }),
104
115
  },
105
116
  'auth.sendSmsCode': {
117
+ auth: 'public',
106
118
  title: '发送短信验证码',
119
+ description: '向手机号发送短信验证码',
107
120
  inputSchema: obj({ phone: str('手机号(必填)') }, { required: ['phone'], additionalProperties: false }),
108
121
  outputSchema: obj({ verification_id: str('验证码会话 id(必返)') }, { required: ['verification_id'] }),
109
122
  },
110
123
  'auth.sendEmailCode': {
124
+ auth: 'public',
111
125
  title: '发送邮箱验证码',
126
+ description: '向邮箱发送验证码',
112
127
  inputSchema: obj({ email: str('邮箱(必填)') }, { required: ['email'], additionalProperties: false }),
113
128
  outputSchema: obj({ verification_id: str('验证码会话 id(必返)') }, { required: ['verification_id'] }),
114
129
  },
115
130
  'auth.verifyCode': {
131
+ auth: 'public',
116
132
  title: '校验验证码',
133
+ description: '校验用户输入的验证码,换取登录用的 verification_token',
117
134
  inputSchema: obj({
118
135
  verification_id: str('验证码会话 id(必填)'),
119
136
  verification_code: str('用户输入的验证码(必填)'),
@@ -123,50 +140,51 @@ export const SCHEMAS = {
123
140
 
124
141
  /* ---------- auth:会话 ---------- */
125
142
  'auth.reauthenticate': {
143
+ auth: 'user',
126
144
  title: '敏感操作二次验证',
145
+ description: '改密码/注销前的二次验证:向当前用户发送验证码',
127
146
  inputSchema: obj({
128
- access_token: str('用户访问令牌(必填)'),
147
+ ...apikeyIn,
129
148
  verify_opt: { type: 'string', enum: ['phone_code', 'email_code'], description: '验证方式(必填)' },
130
- }, { required: ['access_token', 'verify_opt'], additionalProperties: false }),
149
+ }, { required: ['apikey', 'verify_opt'], additionalProperties: false }),
131
150
  outputSchema: obj({ verification_id: str('验证码会话 id(必返)') }),
132
151
  },
133
152
  'auth.introspect': {
153
+ auth: 'user',
134
154
  title: '校验令牌',
135
- inputSchema: tokenIn,
155
+ description: '校验当前 apikey 对应的官方令牌是否有效,返回用户 sub',
156
+ inputSchema: obj({ ...apikeyIn }, { required: ['apikey'], additionalProperties: false }),
136
157
  outputSchema: obj({ sub: str('令牌对应的用户 id'), active: { type: 'boolean', description: '是否有效' } }),
137
158
  annotations: { readOnlyHint: true, openWorldHint: true },
138
159
  },
139
- 'auth.refresh': {
140
- title: '刷新令牌',
141
- inputSchema: obj({ refresh_token: str('刷新令牌(必填)') }, { required: ['refresh_token'], additionalProperties: false }),
142
- outputSchema: tokenOut,
143
- },
144
160
  'auth.signOut': {
161
+ auth: 'user',
145
162
  title: '退出登录',
163
+ description: '退出当前会话(令牌作废)',
146
164
  inputSchema: obj({
147
- access_token: str('用户访问令牌(必填)'),
165
+ ...apikeyIn,
148
166
  client_id: str('客户端 id(可选,默认环境 id)'),
149
167
  x_device_id: str('设备 id(可选)'),
150
- }, { required: ['access_token'] }),
168
+ }, { required: ['apikey'], additionalProperties: true }),
151
169
  outputSchema: { type: 'object', description: '网关响应(通常为空对象)' },
152
170
  annotations: { destructiveHint: true },
153
171
  },
154
172
 
155
173
  /* ---------- user:本地档案 ---------- */
156
174
  'user.upsert': {
175
+ mcp: false,
176
+ auth: 'user',
157
177
  title: '建档 / 合并档案',
158
- inputSchema: obj({
159
- uid: str('唯一用户 id(必填,≤128 字符)'),
160
- profile: { type: 'object', description: '要合并进档案 profile 段的字段(可选)' },
161
- env: str('建档环境标签(可选,缺省 prod)'),
162
- }, { required: ['uid'], additionalProperties: false }),
178
+ description: '建档或刷新 updated_at(登录链路内部步,登录时自动执行)',
179
+ inputSchema: obj({ uid: uidField, profile: freeObj('要合并进档案 profile 段的字段(可选)'), env: str('建档环境标签(可选,缺省 prod)') }, { required: ['uid'], additionalProperties: false }),
163
180
  outputSchema: obj({ id: str('档案行 id'), data: { type: 'object', description: '档案 data 段' } }, { additionalProperties: false }),
164
181
  },
165
182
  'user.get': {
166
- title: '读取档案',
167
- inputSchema: obj({ uid: str('唯一用户 id(必填)') }, { required: ['uid'], additionalProperties: false }),
183
+ auth: 'self',
184
+ title: '读取自己的档案',
185
+ description: '读取当前登录用户的业务档案(profile/settings),无档案时返回 null',
186
+ inputSchema: obj({}, { additionalProperties: false }),
168
187
  outputSchema: {
169
- // MCP SDK 要求 outputSchema 根类型字面 "object";无行时 content 文本为 null、structuredContent 省略
170
188
  type: 'object',
171
189
  description: '档案行(无行时 structuredContent 省略,content 文本为 null)',
172
190
  properties: { id: str('档案行 id'), data: { type: 'object' }, status: str('状态'), deleted_at: { type: ['string', 'null'] } },
@@ -175,15 +193,19 @@ export const SCHEMAS = {
175
193
  annotations: { readOnlyHint: true },
176
194
  },
177
195
  'user.patchProfile': {
178
- title: '局部更新档案',
196
+ auth: 'self',
197
+ title: '更新自己的档案',
198
+ description: '浅合并更新当前登录用户的业务档案 profile 段(本地字段,不走网关)',
179
199
  inputSchema: obj({
180
- uid: str('唯一用户 id(必填)'),
181
- patch: { type: 'object', description: '要浅合并进 profile 的字段(必填)' },
182
- }, { required: ['uid', 'patch'], additionalProperties: false }),
200
+ patch: freeObj('要浅合并进 profile 的字段(必填)'),
201
+ }, { required: ['patch'], additionalProperties: false }),
183
202
  outputSchema: obj({ section: { type: ['object', 'null'], description: '合并后的 profile 段' } }),
184
203
  },
185
204
  'user.schemaStatus': {
205
+ mcp: false,
206
+ auth: 'user',
186
207
  title: '档案表状态',
208
+ description: '探测建档表 / 主键 / 四索引的在位状态(运维面)',
187
209
  inputSchema: obj({}, { required: [], additionalProperties: false }),
188
210
  outputSchema: obj({
189
211
  table: { type: 'boolean', description: '表是否存在' },
@@ -193,7 +215,10 @@ export const SCHEMAS = {
193
215
  annotations: { readOnlyHint: true },
194
216
  },
195
217
  'user.ensureSchema': {
218
+ mcp: false,
219
+ auth: 'user',
196
220
  title: '初始化档案表',
221
+ description: '幂等初始化建档表与索引(部署动作,登录链路不执行)',
197
222
  inputSchema: obj({}, { required: [], additionalProperties: false }),
198
223
  outputSchema: obj({
199
224
  table: { type: 'string', enum: ['created', 'exists'], description: '本次建表结果' },
@@ -203,17 +228,21 @@ export const SCHEMAS = {
203
228
  annotations: { readOnlyHint: true, idempotentHint: true },
204
229
  },
205
230
 
206
- /* ---------- user:用户令牌面(网关透传) ---------- */
231
+ /* ---------- user:用户令牌面(user,token 走头) ---------- */
207
232
  'user.me': {
233
+ auth: 'user',
208
234
  title: '当前用户',
209
- inputSchema: tokenIn,
235
+ description: '读取网关侧当前用户信息(资料 / 绑定 / 角色)',
236
+ inputSchema: obj({ ...apikeyIn }, { required: ['apikey'], additionalProperties: false }),
210
237
  outputSchema: { type: 'object', description: '网关用户信息(原样)' },
211
238
  annotations: { readOnlyHint: true },
212
239
  },
213
240
  'user.updateBasic': {
241
+ auth: 'user',
214
242
  title: '修改资料(含手机号/邮箱)',
243
+ description: '修改网关侧用户资料;改手机号/邮箱需先 reauthenticate 拿验证码令牌;成功后自动同步本地档案',
215
244
  inputSchema: obj({
216
- access_token: str('用户访问令牌(必填)'),
245
+ ...apikeyIn,
217
246
  nickname: str('昵称(可选)'),
218
247
  username: str('用户名(可选)'),
219
248
  phone: str('手机号(可选,修改需验证码)'),
@@ -222,28 +251,82 @@ export const SCHEMAS = {
222
251
  gender: { type: 'string', enum: ['MALE', 'FEMALE'], description: '性别(可选)' },
223
252
  description: str('个人简介(可选)'),
224
253
  verification_token: str('手机号/邮箱变更的验证码令牌(可选,改手机/邮箱时需要)'),
225
- }, { required: ['access_token'] }),
254
+ }, { required: ['apikey'], additionalProperties: true }),
226
255
  outputSchema: { type: 'object', description: '网关响应(原样)' },
227
256
  },
228
257
  'user.changePassword': {
258
+ auth: 'user',
229
259
  title: '修改密码',
260
+ description: '修改当前用户密码(需先 reauthenticate 二次验证)',
230
261
  inputSchema: obj({
231
- access_token: str('用户访问令牌(必填)'),
262
+ ...apikeyIn,
232
263
  password: str('新密码(必填)'),
233
264
  verification_token: str('二次验证令牌(可选,按网关要求)'),
234
- }, { required: ['access_token', 'password'] }),
265
+ }, { required: ['apikey', 'password'] }),
235
266
  outputSchema: { type: 'object', description: '网关响应(通常为空对象)' },
236
267
  annotations: { destructiveHint: true },
237
268
  },
238
269
  'user.deleteMe': {
270
+ auth: 'user',
239
271
  title: '注销账号',
272
+ description: '注销当前用户(需先 reauthenticate 二次验证)',
240
273
  inputSchema: obj({
241
- access_token: str('用户访问令牌(必填)'),
274
+ ...apikeyIn,
242
275
  verification_token: str('二次验证令牌(可选,按网关要求)'),
243
- }, { required: ['access_token'] }),
276
+ }, { required: ['apikey'], additionalProperties: true }),
244
277
  outputSchema: { type: 'object', description: '网关响应(通常为空对象)' },
245
278
  annotations: { destructiveHint: true },
246
279
  },
280
+
281
+ /* ---------- key 域(DC-06)+ cbToken(mcp:false:宿主能力,挂 MCP = 可冒充任意 uid 兑官方 token) ---------- */
282
+ 'auth.key.create': {
283
+ auth: 'user',
284
+ title: '签发新 apikey',
285
+ description: '用当前 apikey 换 uid 再签发一个新 apikey(多设备/多场景);key 明文只在本次响应出现一次',
286
+ inputSchema: obj({ apikey: str('当前 apikey(必填)'), scene: str('场景标签(可选,缺省空串)') }, { required: ['apikey'], additionalProperties: false }),
287
+ outputSchema: obj({
288
+ apikey: str('新 apikey 明文(必返,仅此一次)'),
289
+ key_id: str('key id(吊销/list 句柄)'),
290
+ key_prefix: str('前 11 字符识别段(不含敏感信息)'),
291
+ expires_at: { type: 'number', description: '过期 unix 秒' },
292
+ }, { required: ['apikey', 'key_id', 'key_prefix', 'expires_at'], additionalProperties: false }),
293
+ },
294
+ 'auth.key.list': {
295
+ auth: 'user',
296
+ title: '列出我的 apikey',
297
+ description: '当前 apikey 所属用户的全部 key 档案(不含 key 明文)',
298
+ inputSchema: obj({ apikey: str('当前 apikey(必填)') }, { required: ['apikey'], additionalProperties: false }),
299
+ outputSchema: { type: 'array', description: 'key 档案行数组(key_id/key_prefix/scene/created_at/expires_at/revoked_at)' },
300
+ annotations: { readOnlyHint: true },
301
+ },
302
+ 'auth.key.revoke': {
303
+ auth: 'user',
304
+ title: '吊销 apikey',
305
+ description: '吊销本用户名下的指定 key(即时生效)',
306
+ inputSchema: obj({ apikey: str('当前 apikey(必填)'), key_id: str('要吊销的 key id(必填)') }, { required: ['apikey', 'key_id'], additionalProperties: false }),
307
+ outputSchema: obj({ revoked: { type: 'boolean', description: '是否成功' }, key_id: str('被吊销的 key id') }, { required: ['revoked', 'key_id'], additionalProperties: false }),
308
+ annotations: { destructiveHint: true },
309
+ },
310
+ 'auth.key.verify': {
311
+ auth: 'public',
312
+ title: '验证 apikey',
313
+ description: '验证 apikey 合法性:通过返回 { ok, auth },失败返回 { ok:false, reason }',
314
+ inputSchema: obj({ apikey: str('待验证的 apikey(必填)') }, { required: ['apikey'], additionalProperties: false }),
315
+ outputSchema: { type: 'object', description: 'verify 结果(ok/auth 或 ok/reason)' },
316
+ annotations: { readOnlyHint: true },
317
+ },
318
+ 'auth.cbToken.get': {
319
+ mcp: false,
320
+ auth: 'user',
321
+ title: '官方 token 托管兑换',
322
+ description: 'uid → 托管官方 access_token(宿主透传专用;不挂 MCP)',
323
+ inputSchema: obj({ uid: uidField }, { required: ['uid'], additionalProperties: false }),
324
+ outputSchema: obj({
325
+ access_token: str('官方 access_token'),
326
+ expires_at: { type: 'number', description: '过期 unix 秒' },
327
+ from_cache: { type: 'boolean', description: '是否命中托管缓存' },
328
+ }, { required: ['access_token', 'expires_at', 'from_cache'], additionalProperties: false }),
329
+ },
247
330
  }
248
331
 
249
332
  /** 把元数据合并进 [key, ability] 列表(能力对象原样保留 describe/run) */
@@ -0,0 +1,62 @@
1
+ /**
2
+ * token-broker.js — 官方 token 托管(单飞刷新 · spec §3.6 · §3.7)
3
+ *
4
+ * 职责: get(uid) 查 __user_token_cache → 未过期直返;过期则单飞刷新(实例内 Map<uid, Promise>)→ 回写;
5
+ * upsert(uid, access_token, refresh_token, expiresAt) 供登录收尾落库;markInvalid(uid) 删行。
6
+ * 失败语义: 刷新失败/无行 → 删行 + 401「官方凭证已失效,请重新登录」(fail-closed,refresh_token 不出域)。
7
+ * 接缝: runSql=kit.ctx.run('pg.exec',…) 封装(§3.7 唯一表读写入口;未来换后端只换适配器)。
8
+ * 禁止: 档案表(__user_access_key)读写——那是 access-key.js 的地盘。
9
+ */
10
+ const SQL_SELECT = 'SELECT uid, access_token, refresh_token, expires_at FROM __user_token_cache WHERE uid=$1'
11
+ const SQL_UPSERT = `INSERT INTO __user_token_cache (uid, access_token, refresh_token, expires_at)
12
+ VALUES ($1, $2, $3, $4)
13
+ ON CONFLICT (uid) DO UPDATE SET access_token = EXCLUDED.access_token,
14
+ refresh_token = EXCLUDED.refresh_token, expires_at = EXCLUDED.expires_at, updated_at = now()`
15
+ const SQL_DELETE = 'DELETE FROM __user_token_cache WHERE uid=$1'
16
+
17
+ const err = (code, message) => Object.assign(new Error(message), { code })
18
+ const nowSec = () => Math.floor(Date.now() / 1000)
19
+
20
+ /** pg 适配器:全包唯一表读写接缝(kit.ctx.run('pg.exec') + cloudbase_postgres 角色) */
21
+ export const makeRunSql = kit => (sql, parameters = []) =>
22
+ kit.ctx.run('pg.exec', { sql, parameters, role: 'cloudbase_postgres' })
23
+
24
+ export function createCbTokenBroker({ cfg, refreshTokenRequest, runSql }) {
25
+ const inflight = new Map() // uid → Promise(实例内单飞;跨实例靠 DB 幂等写兜底)
26
+
27
+ // 单飞刷新:失败删行 + 401;成功回写(expires_at=now+expires_in)
28
+ async function refresh(uid, row) {
29
+ let res
30
+ try {
31
+ res = await refreshTokenRequest({ grant_type: 'refresh_token', refresh_token: row.refresh_token, client_id: cfg.envId })
32
+ } catch {
33
+ await runSql(SQL_DELETE, [uid]).catch(() => {})
34
+ throw err(401, '官方凭证已失效,请重新登录')
35
+ }
36
+ const expiresAt = nowSec() + (Number(res?.expires_in) || 0)
37
+ await runSql(SQL_UPSERT, [uid, res.access_token, res.refresh_token, expiresAt])
38
+ return { access_token: res.access_token, expires_at: expiresAt, from_cache: false }
39
+ }
40
+
41
+ async function get(uid) {
42
+ const rows = await runSql(SQL_SELECT, [uid])
43
+ const row = rows?.[0]
44
+ if (row && Number(row.expires_at) > nowSec()) {
45
+ return { access_token: row.access_token, expires_at: Number(row.expires_at), from_cache: true }
46
+ }
47
+ if (!row) throw err(401, '官方凭证已失效,请重新登录') // 无行 = 从未托管/已失效,无可刷新
48
+ if (inflight.has(uid)) return inflight.get(uid)
49
+ const task = refresh(uid, row).finally(() => inflight.delete(uid))
50
+ inflight.set(uid, task)
51
+ return task
52
+ }
53
+
54
+ const upsert = async (uid, access_token, refresh_token, expiresAt) => {
55
+ await runSql(SQL_UPSERT, [uid, access_token, refresh_token, expiresAt])
56
+ return { uid, expires_at: expiresAt }
57
+ }
58
+
59
+ const markInvalid = async uid => { await runSql(SQL_DELETE, [uid]); return { uid } }
60
+
61
+ return { get, upsert, markInvalid }
62
+ }
package/src/user.js CHANGED
@@ -14,11 +14,11 @@
14
14
  *
15
15
  * token 面(4 条,网关透传不裁剪;inject.gateway(method, path, { body, token }) 统一覆盖):
16
16
  * user.me — GET;user.updateBasic — POST /auth/v1/user/basic/edit(body 只发传入字段,
17
- * 成功 → patchProfile({ uid: JWT.sub, patch: 可入档字段 }),verification_token 不入档);
18
- * user.changePassword PATCH /auth/v1/user/password;user.deleteMe DELETE /auth/v1/user/me;user.signOut 不写档。
17
+ * 成功 → patchProfile({ uid: verify.uidRef, patch: 可入档字段 }),verification_token 不入档);
18
+ * user.changePassword PATCH /auth/v1/user/password;user.deleteMe DELETE /auth/v1/user/me。
19
19
  *
20
- * 硬规则(§3.9/§3.10): token 类能力 uid 一律从 access_token 的 JWT.sub 取(不验签),**不接收调用方传 uid**
21
- * 防越权;解码失败 → 400「access_token 无效」。changePassword/deleteMe/signOut 不自动写档(身份面与业务档解耦)。
20
+ * 硬规则(§3.3): token 类能力入参 access_token → apikey;统一序 verify(apikey) → 401「apikey 无效」→
21
+ * broker.get(uid) → 官方 Bearer;**不接收调用方传 uid** 防越权。changePassword/deleteMe 不自动写档。
22
22
  *
23
23
  * 数据流: 入参(uid 校验/token 解码)→ gateway 网关原样 / build*Query → ctx.run('pg.exec',
24
24
  * { sql, parameters, role:'cloudbase_postgres' });网关非 2xx → { code: 网关状态, payload } 原样,
@@ -27,6 +27,7 @@
27
27
  */
28
28
 
29
29
  import { withSchemas } from './schemas.js'
30
+ import { DDL_USER_TOKEN_CACHE, DDL_USER_ACCESS_KEY, DDL_USER_ACCESS_KEY_INDEX } from './access-key.js'
30
31
 
31
32
  const DEFAULT_DOMAIN = 'api.tcloudbasegateway.com'
32
33
 
@@ -115,8 +116,8 @@ export function buildPatchProfileQuery(uid, patch) {
115
116
  }
116
117
  }
117
118
 
118
- /* —— schema 面(§3.11 · DDL 逐字来源 apps/wx-mp-fn/db/schema.full.sql L164/L536/L540,全 if not exists)—— */
119
- const PROBE_TABLE = `select to_regclass('public.crm_user_detail') is not null as "exists"`
119
+ /* —— schema 面(§3.11 建档表 + §3.1 两张新表;DDL 全 if not exists,先探后建)—— */
120
+ const PROBE_TABLE = `select to_regclass('public.crm_user_detail') is not null as "table", to_regclass('public.__user_token_cache') is not null as "token_cache", to_regclass('public.__user_access_key') is not null as "access_key"`
120
121
  const PROBE_INDEXES = `select indexname from pg_indexes where schemaname='public' and tablename='crm_user_detail'`
121
122
  const PROBE_PKEY = `select 1 from pg_constraint where conrelid='public.crm_user_detail'::regclass and contype='p'`
122
123
  const DDL_TABLE = `create table if not exists public.crm_user_detail (id uuid default gen_random_uuid() not null, ns text[] not null, data jsonb default '{}'::jsonb not null, ext jsonb, tag text[] default '{}'::text[], status text default 'active' not null, created_at timestamptz default now() not null, updated_at timestamptz default now() not null, deleted_at timestamptz)`
@@ -133,16 +134,27 @@ const INDEX_ORDER = ['idx_crm_user_detail_ns_unique', 'idx_crm_user_detail_ns',
133
134
  const EDIT_BODY = ['nickname', 'username', 'phone', 'email', 'avatar_url', 'gender', 'description', 'verification_token']
134
135
  const ARCHIVABLE = ['nickname', 'username', 'phone', 'email', 'avatar_url', 'gender', 'description']
135
136
 
136
- export function createUserAbilities(cfg, kit) {
137
+ // deps(index.js 注入): verify(apikey 裁决)/ broker(token 托管,官方 Bearer 来源)
138
+ export function createUserAbilities(cfg, kit, deps = {}) {
137
139
  const gateway = makeGateway(cfg, kit)
138
140
  // 网关调用统一翻错(非 2xx 原样 / 网络 502)
139
141
  const gw = async (method, path, opts) => { try { return await gateway(method, path, opts) } catch (e) { throwGatewayError(e) } }
142
+ // 凭证类统一序(§3.3):verify(apikey) → 401 → broker.get(uid) → 官方 Bearer
143
+ const sessionOf = async apikey => {
144
+ const v = await deps.verify(apikey)
145
+ if (!v.ok) throw Object.assign(new Error('apikey 无效'), { code: 401 })
146
+ const token = await deps.broker.get(v.auth.uidRef)
147
+ return { uid: v.auth.uidRef, access_token: token.access_token }
148
+ }
140
149
  const pg = q => kit.ctx.run('pg.exec', { sql: q.sql, parameters: q.parameters, role: 'cloudbase_postgres' })
141
150
  // 探测:表在不在 / 四索引在不在 / 主键在不在(只读,三次 pg.exec)
142
151
  const probe = async () => {
143
152
  const [t, i, k] = [await pg({ sql: PROBE_TABLE, parameters: [] }), await pg({ sql: PROBE_INDEXES, parameters: [] }), await pg({ sql: PROBE_PKEY, parameters: [] })]
144
153
  const names = new Set(i.map(r => r.indexname))
145
- return { table: !!t[0]?.exists, indexes: Object.fromEntries(INDEX_ORDER.map(n => [n, names.has(n)])), primaryKey: k.length > 0 }
154
+ return {
155
+ table: !!t[0]?.table, tokenCache: !!t[0]?.token_cache, accessKey: !!t[0]?.access_key,
156
+ indexes: Object.fromEntries(INDEX_ORDER.map(n => [n, names.has(n)])), primaryKey: k.length > 0,
157
+ }
146
158
  }
147
159
 
148
160
  return withSchemas([
@@ -171,17 +183,17 @@ export function createUserAbilities(cfg, kit) {
171
183
  throw Object.assign(new Error('用户不可用'), { code: 403 })
172
184
  } }],
173
185
 
174
- /* token 面(网关透传;uid 只认 JWT.sub) */
175
- ['user.me', { describe: '当前用户(资料/绑定/角色):GET /auth/v1/user/me,网关原样', run: async ({ access_token } = {}) => {
176
- uidFromToken(access_token)
186
+ /* token 面(网关透传;身份 = apikey → verify.uidRef → broker 托管 token) */
187
+ ['user.me', { describe: '当前用户(资料/绑定/角色):GET /auth/v1/user/me,网关原样', run: async ({ apikey } = {}) => {
188
+ const { access_token } = await sessionOf(apikey)
177
189
  return gw('GET', '/auth/v1/user/me', { token: access_token })
178
190
  } }],
179
191
 
180
192
  ['user.updateBasic', { describe: '资料/手机/邮箱变更:POST /auth/v1/user/basic/edit(body 只发传入字段)→ 成功 patchProfile', run: async (args = {}) => {
181
- const uid = uidFromToken(args.access_token)
193
+ const { uid, access_token } = await sessionOf(args.apikey)
182
194
  const body = {}
183
195
  for (const k of EDIT_BODY) if (args[k] !== undefined) body[k] = args[k]
184
- const payload = await gw('POST', '/auth/v1/user/basic/edit', { body, token: args.access_token })
196
+ const payload = await gw('POST', '/auth/v1/user/basic/edit', { body, token: access_token })
185
197
  const patch = {}
186
198
  for (const k of ARCHIVABLE) if (k in body) patch[k] = body[k] // verification_token 不入档
187
199
  await kit.ctx.run('user.patchProfile', { uid, patch })
@@ -189,14 +201,14 @@ export function createUserAbilities(cfg, kit) {
189
201
  } }],
190
202
 
191
203
  ['user.changePassword', { describe: '改密(前置 reauthenticate):PATCH /auth/v1/user/password,网关原样(不写档)', run: async (args = {}) => {
192
- const { access_token, ...rest } = args
193
- uidFromToken(access_token)
204
+ const { apikey, ...rest } = args
205
+ const { access_token } = await sessionOf(apikey)
194
206
  return gw('PATCH', '/auth/v1/user/password', { body: rest, token: access_token })
195
207
  } }],
196
208
 
197
209
  ['user.deleteMe', { describe: '删号(前置 reauthenticate):DELETE /auth/v1/user/me,网关原样(不自行标删/不写档)', run: async (args = {}) => {
198
- const { access_token, ...rest } = args
199
- uidFromToken(access_token)
210
+ const { apikey, ...rest } = args
211
+ const { access_token } = await sessionOf(apikey)
200
212
  return gw('DELETE', '/auth/v1/user/me', { body: rest, token: access_token })
201
213
  } }],
202
214
 
@@ -207,6 +219,8 @@ export function createUserAbilities(cfg, kit) {
207
219
  const before = await probe()
208
220
  const plan = []
209
221
  if (!before.table) plan.push(DDL_TABLE)
222
+ if (!before.tokenCache) plan.push(DDL_USER_TOKEN_CACHE) // §3.1 token 托管表
223
+ if (!before.accessKey) plan.push(DDL_USER_ACCESS_KEY, DDL_USER_ACCESS_KEY_INDEX) // §3.1 key 档案表 + uid 索引
210
224
  if (!before.primaryKey) plan.push(DDL_PKEY) // 主键仅缺失时补(alter 无 if not exists)
211
225
  const missing = INDEX_ORDER.filter(n => !before.indexes[n])
212
226
  for (const name of missing) plan.push(DDL_INDEXES[name])
@@ -215,7 +229,13 @@ export function createUserAbilities(cfg, kit) {
215
229
  throw e?.status ? Object.assign(new Error(e.message), { code: e.status, payload: e.payload }) : e // 原样透传
216
230
  }
217
231
  }
218
- return { table: before.table ? 'exists' : 'created', indexes: { created: missing, existing: INDEX_ORDER.filter(n => !missing.includes(n)) }, status: await probe() }
232
+ return {
233
+ table: before.table ? 'exists' : 'created',
234
+ tokenCache: before.tokenCache ? 'exists' : 'created',
235
+ accessKey: before.accessKey ? 'exists' : 'created',
236
+ indexes: { created: missing, existing: INDEX_ORDER.filter(n => !missing.includes(n)) },
237
+ status: await probe(),
238
+ }
219
239
  } }],
220
240
  ])
221
241
  }