@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 +1 -1
- package/src/access-key.js +130 -0
- package/src/auth.js +54 -35
- package/src/index.js +43 -10
- package/src/schemas.js +145 -62
- package/src/token-broker.js +62 -0
- package/src/user.js +38 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blod-nm/user-kit",
|
|
3
|
-
"version": "0.1.
|
|
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 域能力目录(
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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 {
|
|
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 {
|
|
148
|
-
return gw('POST', '/auth/v1/user/reauthenticate', { body:
|
|
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 ({
|
|
152
|
-
|
|
153
|
-
return gw('GET', '/auth/v1/token/introspect', { 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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
198
|
-
|
|
199
|
-
return gw('POST', '/auth/v1/user/signout', { body: rest, 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.
|
|
9
|
-
* kit.
|
|
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)→
|
|
12
|
-
*
|
|
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);
|
|
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 =
|
|
36
|
+
const finalCfg = { ...cfg, env: cfg.env || 'prod', akDefaultKeyTtlSec: cfg.akDefaultKeyTtlSec ?? DEFAULT_KEY_TTL_SEC }
|
|
30
37
|
const base = createBaseKit(finalCfg)
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
职责:
|
|
5
|
-
能力实现(auth.js / user.js)只负责 run
|
|
4
|
+
职责: 22 条在册能力(DC-06:旧 token 刷新能力已注销)的 title / description / auth / inputSchema / outputSchema / annotations。
|
|
5
|
+
能力实现(auth.js / user.js)只负责 run;约束与开放策略集中在这里,挂 MCP 时原样透传。
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
-
|
|
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: ['
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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('
|
|
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:
|
|
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
|
-
|
|
147
|
+
...apikeyIn,
|
|
129
148
|
verify_opt: { type: 'string', enum: ['phone_code', 'email_code'], description: '验证方式(必填)' },
|
|
130
|
-
}, { required: ['
|
|
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
|
-
|
|
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
|
-
|
|
165
|
+
...apikeyIn,
|
|
148
166
|
client_id: str('客户端 id(可选,默认环境 id)'),
|
|
149
167
|
x_device_id: str('设备 id(可选)'),
|
|
150
|
-
}, { required: ['
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
196
|
+
auth: 'self',
|
|
197
|
+
title: '更新自己的档案',
|
|
198
|
+
description: '浅合并更新当前登录用户的业务档案 profile 段(本地字段,不走网关)',
|
|
179
199
|
inputSchema: obj({
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: ['
|
|
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
|
-
|
|
262
|
+
...apikeyIn,
|
|
232
263
|
password: str('新密码(必填)'),
|
|
233
264
|
verification_token: str('二次验证令牌(可选,按网关要求)'),
|
|
234
|
-
}, { required: ['
|
|
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
|
-
|
|
274
|
+
...apikeyIn,
|
|
242
275
|
verification_token: str('二次验证令牌(可选,按网关要求)'),
|
|
243
|
-
}, { required: ['
|
|
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:
|
|
18
|
-
* user.changePassword PATCH /auth/v1/user/password;user.deleteMe DELETE /auth/v1/user/me
|
|
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.
|
|
21
|
-
*
|
|
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
|
|
119
|
-
const PROBE_TABLE = `select to_regclass('public.crm_user_detail') is not null as "
|
|
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
|
-
|
|
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 {
|
|
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
|
|
175
|
-
['user.me', { describe: '当前用户(资料/绑定/角色):GET /auth/v1/user/me,网关原样', run: async ({
|
|
176
|
-
|
|
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 =
|
|
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:
|
|
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 {
|
|
193
|
-
|
|
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 {
|
|
199
|
-
|
|
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 {
|
|
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
|
}
|