@chatu-ai/app-sdk 0.8.7 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -2
- package/dist/auth.d.ts +64 -0
- package/dist/auth.js +237 -0
- package/dist/auth.test.d.ts +1 -0
- package/dist/auth.test.js +97 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +6 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/package.json +3 -2
- package/skills/chatu-auth/SKILL.md +165 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @chatu-ai/app-sdk
|
|
2
2
|
|
|
3
|
-
Data
|
|
3
|
+
Data, auth and AI SDK for apps generated by **ChatU Builder**. One API, driver picked from environment variables — works in the Builder preview, on your own server, or with no config at all.
|
|
4
4
|
|
|
5
5
|
```ts
|
|
6
6
|
import { kv, storage } from '@chatu-ai/app-sdk' // server-side only (Route Handlers / Server Components / Server Actions)
|
|
@@ -22,6 +22,29 @@ const src = await storage.url('avatars/u1.png', { expiresIn: 3600 }) //
|
|
|
22
22
|
| `CHATU_DATA_DRIVER=edgeone` (+ optional `CHATU_EDGEONE_KV_STORE` / `CHATU_EDGEONE_STORAGE_STORE`, external access: `EDGEONE_BLOB_PROJECT_ID` + `EDGEONE_BLOB_TOKEN`) | **edgeone** — Tencent EdgeOne Pages Blob for both `kv` (JSON envelope, TTL emulated) and `storage` (presigned PUT via `createUploadUrl`; `storage.url()` returns the in-app proxy path `/_chatu/blob/<key>` served by the template route) | `npm i @edgeone/pages-blob` (preinstalled in the Builder template); credential-free inside Pages Functions. `ai` keeps using `CHATU_DATA_URL` + `CHATU_APP_KEY` |
|
|
23
23
|
| none | **memory** — in-process, lost on restart | local dev / fallback |
|
|
24
24
|
|
|
25
|
+
## Auth (app users)
|
|
26
|
+
|
|
27
|
+
`auth` gives the generated app **its own end users** (separate from ChatU platform accounts), scoped per app + environment (`dev`/`prod`). Email code or email + password; sessions are opaque tokens (stored server-side as SHA-256, sliding 30-day expiry). Requires the **platform** driver.
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { auth } from '@chatu-ai/app-sdk'
|
|
31
|
+
|
|
32
|
+
const { devCode } = await auth.sendCode('a@b.com') // devCode only in dev when no mail channel is configured
|
|
33
|
+
const { token, user } = await auth.verifyCode('a@b.com', code) // first login signs the user up
|
|
34
|
+
const me = await auth.getSession(token) // null when expired / disabled
|
|
35
|
+
await auth.signOut(token)
|
|
36
|
+
|
|
37
|
+
await auth.register('a@b.com', 'secret1') // password route (>= 6 chars)
|
|
38
|
+
await auth.login('a@b.com', 'secret1')
|
|
39
|
+
|
|
40
|
+
const { users, total } = await auth.users.list({ keyword: 'a@' })
|
|
41
|
+
await auth.users.update(id, { disabled: true }) // also revokes that user's sessions
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
In the Next.js template, `@/lib/platform` wraps this in HttpOnly-cookie helpers: `currentUser()`, `requireUser()`, `signInWithCode()`, `endSession()`.
|
|
45
|
+
|
|
46
|
+
Limits: 10k users per app/env, 200 codes per day, code valid 10 min / 5 tries, 60s per-email resend window.
|
|
47
|
+
|
|
25
48
|
## AI (LLM relay)
|
|
26
49
|
|
|
27
50
|
`ai` calls the platform's OpenAI-compatible endpoint (`{origin}/v1/chat/completions`) with the same app key used by the Data API. **Server-side only** — call it from a Route Handler / Server Action and let the browser `fetch` your own API; never ship the key to the client. Usage is metered and **billed to the app owner's ChatU points**.
|
|
@@ -71,6 +94,6 @@ Never expose `CHATU_APP_KEY` to the browser. MIT.
|
|
|
71
94
|
|
|
72
95
|
## Agent skills
|
|
73
96
|
|
|
74
|
-
The package ships `skills/chatu-{kv,db,storage,ai}/SKILL.md` — task-focused manuals for coding agents
|
|
97
|
+
The package ships `skills/chatu-{kv,db,storage,ai,auth}/SKILL.md` — task-focused manuals for coding agents
|
|
75
98
|
(standard rules, boilerplate, boundaries, common failure modes). The ChatU Builder sandbox copies them
|
|
76
99
|
into the workspace `.claude/skills/` so Claude Code loads them on demand; they are versioned with the SDK.
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** 应用自己的终端用户(与 ChatU 平台账号无关) */
|
|
2
|
+
export interface AppUser {
|
|
3
|
+
id: string;
|
|
4
|
+
email: string;
|
|
5
|
+
name: string | null;
|
|
6
|
+
avatar: string | null;
|
|
7
|
+
createdAt: number;
|
|
8
|
+
lastLoginAt: number;
|
|
9
|
+
disabled: boolean;
|
|
10
|
+
meta: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
export interface SignInResult {
|
|
13
|
+
token: string;
|
|
14
|
+
user: AppUser;
|
|
15
|
+
created: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface SendCodeResult {
|
|
18
|
+
sent: boolean; /** 仅预览环境且平台未配置邮件通道时返回,便于调试 */
|
|
19
|
+
devCode?: string | null;
|
|
20
|
+
}
|
|
21
|
+
export interface UserListResult {
|
|
22
|
+
users: AppUser[];
|
|
23
|
+
total: number;
|
|
24
|
+
nextSkip: number | null;
|
|
25
|
+
}
|
|
26
|
+
export interface UserPatch {
|
|
27
|
+
name?: string | null;
|
|
28
|
+
avatar?: string | null;
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
meta?: Record<string, unknown>;
|
|
31
|
+
password?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface AuthClient {
|
|
34
|
+
/** 发送邮箱登录验证码 */
|
|
35
|
+
sendCode(email: string): Promise<SendCodeResult>;
|
|
36
|
+
/** 校验验证码;邮箱首次登录自动注册 */
|
|
37
|
+
verifyCode(email: string, code: string, opts?: {
|
|
38
|
+
name?: string;
|
|
39
|
+
}): Promise<SignInResult>;
|
|
40
|
+
/** 邮箱 + 密码注册 */
|
|
41
|
+
register(email: string, password: string, opts?: {
|
|
42
|
+
name?: string;
|
|
43
|
+
}): Promise<SignInResult>;
|
|
44
|
+
/** 邮箱 + 密码登录 */
|
|
45
|
+
login(email: string, password: string): Promise<SignInResult>;
|
|
46
|
+
/** 用会话 token 换当前用户;无效/过期/被禁用返回 null */
|
|
47
|
+
getSession(token: string | null | undefined): Promise<AppUser | null>;
|
|
48
|
+
/** 退出登录(吊销该 token) */
|
|
49
|
+
signOut(token: string | null | undefined): Promise<boolean>;
|
|
50
|
+
users: {
|
|
51
|
+
list(opts?: {
|
|
52
|
+
skip?: number;
|
|
53
|
+
limit?: number;
|
|
54
|
+
keyword?: string;
|
|
55
|
+
}): Promise<UserListResult>;
|
|
56
|
+
get(id: string): Promise<AppUser | null>;
|
|
57
|
+
update(id: string, patch: UserPatch): Promise<AppUser>;
|
|
58
|
+
delete(id: string): Promise<boolean>;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** 按当前配置取 auth 客户端(惰性、缓存;configure() 后自动重建) */
|
|
62
|
+
export declare function getAuth(): AuthClient;
|
|
63
|
+
/** 便捷单例:`import { auth } from '@chatu-ai/app-sdk'` */
|
|
64
|
+
export declare const auth: AuthClient;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { configVersion, resolveConfig } from './config.js';
|
|
2
|
+
import { AppSdkError } from './errors.js';
|
|
3
|
+
// ---------- platform driver ----------
|
|
4
|
+
function platformAuth(cfg) {
|
|
5
|
+
const headers = { 'x-api-key': cfg.apiKey, 'x-chatu-env': cfg.env, 'content-type': 'application/json' };
|
|
6
|
+
async function call(method, path, body, token) {
|
|
7
|
+
const res = await cfg.fetchImpl(`${cfg.baseUrl}/auth${path}`, {
|
|
8
|
+
method,
|
|
9
|
+
headers: token ? { ...headers, 'x-app-session': token } : headers,
|
|
10
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
11
|
+
});
|
|
12
|
+
let json = null;
|
|
13
|
+
try {
|
|
14
|
+
json = await res.json();
|
|
15
|
+
}
|
|
16
|
+
catch { /* ignore */ }
|
|
17
|
+
if (!res.ok || json?.ok === false) {
|
|
18
|
+
throw new AppSdkError(json?.error ?? `HTTP_${res.status}`, json?.message ?? `auth ${method} ${path} failed (${res.status})`, res.status);
|
|
19
|
+
}
|
|
20
|
+
return json;
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
async sendCode(email) {
|
|
24
|
+
const r = await call('POST', '/code/send', { email });
|
|
25
|
+
return { sent: r.sent, devCode: r.devCode ?? null };
|
|
26
|
+
},
|
|
27
|
+
async verifyCode(email, code, opts) {
|
|
28
|
+
const r = await call('POST', '/code/verify', { email, code, name: opts?.name });
|
|
29
|
+
return { token: r.token, user: r.user, created: r.created };
|
|
30
|
+
},
|
|
31
|
+
async register(email, password, opts) {
|
|
32
|
+
const r = await call('POST', '/password/register', { email, password, name: opts?.name });
|
|
33
|
+
return { token: r.token, user: r.user, created: r.created };
|
|
34
|
+
},
|
|
35
|
+
async login(email, password) {
|
|
36
|
+
const r = await call('POST', '/password/login', { email, password });
|
|
37
|
+
return { token: r.token, user: r.user, created: false };
|
|
38
|
+
},
|
|
39
|
+
async getSession(token) {
|
|
40
|
+
if (!token)
|
|
41
|
+
return null;
|
|
42
|
+
const r = await call('GET', '/session', undefined, token);
|
|
43
|
+
return r.user ?? null;
|
|
44
|
+
},
|
|
45
|
+
async signOut(token) {
|
|
46
|
+
if (!token)
|
|
47
|
+
return false;
|
|
48
|
+
const r = await call('POST', '/logout', {}, token);
|
|
49
|
+
return r.removed;
|
|
50
|
+
},
|
|
51
|
+
users: {
|
|
52
|
+
async list(opts) {
|
|
53
|
+
const q = new URLSearchParams({ skip: String(opts?.skip ?? 0), limit: String(opts?.limit ?? 50) });
|
|
54
|
+
if (opts?.keyword)
|
|
55
|
+
q.set('keyword', opts.keyword);
|
|
56
|
+
const r = await call('GET', `/users?${q.toString()}`);
|
|
57
|
+
return { users: r.users, total: r.total, nextSkip: r.nextSkip ?? null };
|
|
58
|
+
},
|
|
59
|
+
async get(id) {
|
|
60
|
+
try {
|
|
61
|
+
const r = await call('GET', `/users/${encodeURIComponent(id)}`);
|
|
62
|
+
return r.user;
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
if (err instanceof AppSdkError && err.code === 'USER_NOT_FOUND')
|
|
66
|
+
return null;
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
async update(id, patch) {
|
|
71
|
+
const r = await call('PATCH', `/users/${encodeURIComponent(id)}`, patch);
|
|
72
|
+
return r.user;
|
|
73
|
+
},
|
|
74
|
+
async delete(id) {
|
|
75
|
+
const r = await call('DELETE', `/users/${encodeURIComponent(id)}`);
|
|
76
|
+
return r.removed;
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// ---------- memory driver(本机开发 / 测试;进程退出即丢失) ----------
|
|
82
|
+
function memoryAuth() {
|
|
83
|
+
const users = new Map();
|
|
84
|
+
/** 注册序号:同一毫秒创建的用户也要有稳定的先后顺序 */
|
|
85
|
+
const seq = new Map();
|
|
86
|
+
let nextSeq = 0;
|
|
87
|
+
const byEmail = new Map();
|
|
88
|
+
const sessions = new Map();
|
|
89
|
+
const codes = new Map();
|
|
90
|
+
const norm = (email) => email.trim().toLowerCase();
|
|
91
|
+
const strip = (u) => { const { pwd: _pwd, ...rest } = u; return rest; };
|
|
92
|
+
const issue = (id) => { const token = `mem_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`; sessions.set(token, id); return token; };
|
|
93
|
+
const create = (email, name, pwd) => {
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
const user = { id: `u${now.toString(36)}${Math.random().toString(36).slice(2, 8)}`, email, name: name ?? email.split('@')[0], avatar: null, createdAt: now, lastLoginAt: now, disabled: false, meta: {}, pwd };
|
|
96
|
+
users.set(user.id, user);
|
|
97
|
+
seq.set(user.id, nextSeq++);
|
|
98
|
+
byEmail.set(email, user.id);
|
|
99
|
+
return user;
|
|
100
|
+
};
|
|
101
|
+
return {
|
|
102
|
+
async sendCode(email) { const code = String(Math.floor(100000 + Math.random() * 900000)); codes.set(norm(email), code); return { sent: true, devCode: code }; },
|
|
103
|
+
async verifyCode(email, code, opts) {
|
|
104
|
+
const key = norm(email);
|
|
105
|
+
if (codes.get(key) !== code.trim())
|
|
106
|
+
throw new AppSdkError('CODE_INVALID', '验证码不正确');
|
|
107
|
+
codes.delete(key);
|
|
108
|
+
const existingId = byEmail.get(key);
|
|
109
|
+
const user = existingId ? users.get(existingId) : create(key, opts?.name);
|
|
110
|
+
user.lastLoginAt = Date.now();
|
|
111
|
+
return { token: issue(user.id), user: strip(user), created: !existingId };
|
|
112
|
+
},
|
|
113
|
+
async register(email, password, opts) {
|
|
114
|
+
const key = norm(email);
|
|
115
|
+
if (byEmail.has(key))
|
|
116
|
+
throw new AppSdkError('EMAIL_TAKEN', '该邮箱已注册');
|
|
117
|
+
if (password.length < 6)
|
|
118
|
+
throw new AppSdkError('WEAK_PASSWORD', '密码至少 6 位');
|
|
119
|
+
const user = create(key, opts?.name, password);
|
|
120
|
+
return { token: issue(user.id), user: strip(user), created: true };
|
|
121
|
+
},
|
|
122
|
+
async login(email, password) {
|
|
123
|
+
const id = byEmail.get(norm(email));
|
|
124
|
+
const user = id ? users.get(id) : undefined;
|
|
125
|
+
if (!user || user.pwd !== password)
|
|
126
|
+
throw new AppSdkError('INVALID_CREDENTIALS', '邮箱或密码不正确');
|
|
127
|
+
if (user.disabled)
|
|
128
|
+
throw new AppSdkError('USER_DISABLED', '该账号已被停用');
|
|
129
|
+
user.lastLoginAt = Date.now();
|
|
130
|
+
return { token: issue(user.id), user: strip(user), created: false };
|
|
131
|
+
},
|
|
132
|
+
async getSession(token) {
|
|
133
|
+
if (!token)
|
|
134
|
+
return null;
|
|
135
|
+
const id = sessions.get(token);
|
|
136
|
+
const user = id ? users.get(id) : undefined;
|
|
137
|
+
return !user || user.disabled ? null : strip(user);
|
|
138
|
+
},
|
|
139
|
+
async signOut(token) { return token ? sessions.delete(token) : false; },
|
|
140
|
+
users: {
|
|
141
|
+
async list(opts) {
|
|
142
|
+
const kw = opts?.keyword?.trim().toLowerCase();
|
|
143
|
+
const all = [...users.values()]
|
|
144
|
+
.filter(u => !kw || u.email.includes(kw) || (u.name ?? '').toLowerCase().includes(kw))
|
|
145
|
+
.sort((a, b) => b.createdAt - a.createdAt || (seq.get(b.id) ?? 0) - (seq.get(a.id) ?? 0));
|
|
146
|
+
const skip = opts?.skip ?? 0;
|
|
147
|
+
const limit = opts?.limit ?? 50;
|
|
148
|
+
const page = all.slice(skip, skip + limit);
|
|
149
|
+
return { users: page.map(strip), total: all.length, nextSkip: skip + page.length < all.length ? skip + page.length : null };
|
|
150
|
+
},
|
|
151
|
+
async get(id) { const u = users.get(id); return u ? strip(u) : null; },
|
|
152
|
+
async update(id, patch) {
|
|
153
|
+
const u = users.get(id);
|
|
154
|
+
if (!u)
|
|
155
|
+
throw new AppSdkError('USER_NOT_FOUND', '用户不存在');
|
|
156
|
+
if (patch.name !== undefined)
|
|
157
|
+
u.name = patch.name;
|
|
158
|
+
if (patch.avatar !== undefined)
|
|
159
|
+
u.avatar = patch.avatar;
|
|
160
|
+
if (patch.meta !== undefined)
|
|
161
|
+
u.meta = patch.meta;
|
|
162
|
+
if (patch.password !== undefined)
|
|
163
|
+
u.pwd = patch.password;
|
|
164
|
+
if (patch.disabled !== undefined) {
|
|
165
|
+
u.disabled = patch.disabled;
|
|
166
|
+
if (patch.disabled)
|
|
167
|
+
for (const [t, uid] of [...sessions])
|
|
168
|
+
if (uid === id)
|
|
169
|
+
sessions.delete(t);
|
|
170
|
+
}
|
|
171
|
+
return strip(u);
|
|
172
|
+
},
|
|
173
|
+
async delete(id) {
|
|
174
|
+
const u = users.get(id);
|
|
175
|
+
if (!u)
|
|
176
|
+
return false;
|
|
177
|
+
for (const [t, uid] of [...sessions])
|
|
178
|
+
if (uid === id)
|
|
179
|
+
sessions.delete(t);
|
|
180
|
+
byEmail.delete(u.email);
|
|
181
|
+
seq.delete(id);
|
|
182
|
+
return users.delete(id);
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/** byo / edgeone 等驱动没有用户存储:每个方法都以明确错误 reject,而不是在取客户端时同步抛出 */
|
|
188
|
+
function unsupportedAuth(kind) {
|
|
189
|
+
const fail = () => {
|
|
190
|
+
throw new AppSdkError('AUTH_UNSUPPORTED', `当前数据驱动(${kind})不支持应用用户体系;auth 需要平台数据服务(配置 CHATU_APP_KEY 使用 platform 驱动)`);
|
|
191
|
+
};
|
|
192
|
+
return {
|
|
193
|
+
async sendCode() { return fail(); },
|
|
194
|
+
async verifyCode() { return fail(); },
|
|
195
|
+
async register() { return fail(); },
|
|
196
|
+
async login() { return fail(); },
|
|
197
|
+
async getSession() { return fail(); },
|
|
198
|
+
async signOut() { return fail(); },
|
|
199
|
+
users: {
|
|
200
|
+
async list() { return fail(); },
|
|
201
|
+
async get() { return fail(); },
|
|
202
|
+
async update() { return fail(); },
|
|
203
|
+
async delete() { return fail(); },
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
let cached = null;
|
|
208
|
+
/** 按当前配置取 auth 客户端(惰性、缓存;configure() 后自动重建) */
|
|
209
|
+
export function getAuth() {
|
|
210
|
+
const cfg = resolveConfig();
|
|
211
|
+
// memory 驱动带进程内状态:把 configure() 次数并入缓存键,重新配置即换一套干净的用户表
|
|
212
|
+
const key = cfg.kind === 'platform' ? `platform|${cfg.baseUrl}|${cfg.env}|${cfg.apiKey.slice(-4)}` : `${cfg.kind}|${configVersion()}`;
|
|
213
|
+
if (!cached || cached.key !== key) {
|
|
214
|
+
cached = {
|
|
215
|
+
key,
|
|
216
|
+
client: cfg.kind === 'platform' ? platformAuth(cfg)
|
|
217
|
+
: cfg.kind === 'memory' ? memoryAuth()
|
|
218
|
+
: unsupportedAuth(cfg.kind),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
return cached.client;
|
|
222
|
+
}
|
|
223
|
+
/** 便捷单例:`import { auth } from '@chatu-ai/app-sdk'` */
|
|
224
|
+
export const auth = {
|
|
225
|
+
sendCode: (email) => getAuth().sendCode(email),
|
|
226
|
+
verifyCode: (email, code, opts) => getAuth().verifyCode(email, code, opts),
|
|
227
|
+
register: (email, password, opts) => getAuth().register(email, password, opts),
|
|
228
|
+
login: (email, password) => getAuth().login(email, password),
|
|
229
|
+
getSession: (token) => getAuth().getSession(token),
|
|
230
|
+
signOut: (token) => getAuth().signOut(token),
|
|
231
|
+
users: {
|
|
232
|
+
list: (opts) => getAuth().users.list(opts),
|
|
233
|
+
get: (id) => getAuth().users.get(id),
|
|
234
|
+
update: (id, patch) => getAuth().users.update(id, patch),
|
|
235
|
+
delete: (id) => getAuth().users.delete(id),
|
|
236
|
+
},
|
|
237
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { beforeEach, describe as d, expect, it } from 'vitest';
|
|
2
|
+
import { auth, configure, AppSdkError } from './index';
|
|
3
|
+
d('auth memory driver', () => {
|
|
4
|
+
beforeEach(() => configure({ driver: 'memory' }));
|
|
5
|
+
it('signs up on first code verify and keeps the same user on second login', async () => {
|
|
6
|
+
const { devCode } = await auth.sendCode('Alice@Example.com ');
|
|
7
|
+
expect(devCode).toMatch(/^\d{6}$/);
|
|
8
|
+
const first = await auth.verifyCode('alice@example.com', devCode, { name: '爱丽丝' });
|
|
9
|
+
expect(first.created).toBe(true);
|
|
10
|
+
expect(first.user.email).toBe('alice@example.com');
|
|
11
|
+
expect(first.user.name).toBe('爱丽丝');
|
|
12
|
+
expect(await auth.getSession(first.token)).toMatchObject({ id: first.user.id });
|
|
13
|
+
const second = await auth.verifyCode('alice@example.com', (await auth.sendCode('alice@example.com')).devCode);
|
|
14
|
+
expect(second.created).toBe(false);
|
|
15
|
+
expect(second.user.id).toBe(first.user.id);
|
|
16
|
+
});
|
|
17
|
+
it('rejects a wrong code and consumes a used one', async () => {
|
|
18
|
+
const { devCode } = await auth.sendCode('bob@example.com');
|
|
19
|
+
await expect(auth.verifyCode('bob@example.com', '000000')).rejects.toThrow(AppSdkError);
|
|
20
|
+
await auth.verifyCode('bob@example.com', devCode);
|
|
21
|
+
await expect(auth.verifyCode('bob@example.com', devCode)).rejects.toThrow(/验证码/);
|
|
22
|
+
});
|
|
23
|
+
it('password register / login / duplicate email', async () => {
|
|
24
|
+
const reg = await auth.register('carol@example.com', 'secret1', { name: 'Carol' });
|
|
25
|
+
expect(reg.created).toBe(true);
|
|
26
|
+
await expect(auth.register('carol@example.com', 'secret1')).rejects.toThrow(/已注册/);
|
|
27
|
+
await expect(auth.register('dave@example.com', 'x')).rejects.toThrow(/6 位/);
|
|
28
|
+
await expect(auth.login('carol@example.com', 'wrong')).rejects.toThrow(/不正确/);
|
|
29
|
+
const login = await auth.login('carol@example.com', 'secret1');
|
|
30
|
+
expect(login.user.id).toBe(reg.user.id);
|
|
31
|
+
});
|
|
32
|
+
it('never exposes the password and revokes sessions when disabled', async () => {
|
|
33
|
+
const { token, user } = await auth.register('erin@example.com', 'secret1');
|
|
34
|
+
expect(user.pwd).toBeUndefined();
|
|
35
|
+
await auth.users.update(user.id, { disabled: true });
|
|
36
|
+
expect(await auth.getSession(token)).toBeNull();
|
|
37
|
+
await expect(auth.login('erin@example.com', 'secret1')).rejects.toThrow(/停用/);
|
|
38
|
+
});
|
|
39
|
+
it('lists, searches, updates and deletes users', async () => {
|
|
40
|
+
const a = await auth.register('frank@example.com', 'secret1', { name: 'Frank' });
|
|
41
|
+
await auth.register('grace@example.com', 'secret1', { name: 'Grace' });
|
|
42
|
+
const list = await auth.users.list();
|
|
43
|
+
expect(list.total).toBe(2);
|
|
44
|
+
expect(list.users[0].email).toBe('grace@example.com'); // 最新注册在前
|
|
45
|
+
expect((await auth.users.list({ keyword: 'frank' })).total).toBe(1);
|
|
46
|
+
expect((await auth.users.list({ limit: 1 })).nextSkip).toBe(1);
|
|
47
|
+
const updated = await auth.users.update(a.user.id, { name: '弗兰克', meta: { role: 'admin' } });
|
|
48
|
+
expect(updated.name).toBe('弗兰克');
|
|
49
|
+
expect(updated.meta).toEqual({ role: 'admin' });
|
|
50
|
+
expect(await auth.users.get(a.user.id)).toMatchObject({ name: '弗兰克' });
|
|
51
|
+
expect(await auth.users.delete(a.user.id)).toBe(true);
|
|
52
|
+
expect(await auth.users.get(a.user.id)).toBeNull();
|
|
53
|
+
expect(await auth.getSession(a.token)).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
it('getSession tolerates missing tokens', async () => {
|
|
56
|
+
expect(await auth.getSession(null)).toBeNull();
|
|
57
|
+
expect(await auth.getSession(undefined)).toBeNull();
|
|
58
|
+
expect(await auth.signOut(null)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
d('auth platform driver', () => {
|
|
62
|
+
it('sends app key / env / session headers and unwraps responses', async () => {
|
|
63
|
+
const calls = [];
|
|
64
|
+
const user = { id: 'u1', email: 'a@b.com', name: 'A', avatar: null, createdAt: 1, lastLoginAt: 2, disabled: false, meta: {} };
|
|
65
|
+
const fetchImpl = (async (url, init) => {
|
|
66
|
+
calls.push({ url, init });
|
|
67
|
+
if (url.endsWith('/auth/code/send'))
|
|
68
|
+
return new Response(JSON.stringify({ ok: true, sent: true }), { status: 200 });
|
|
69
|
+
if (url.endsWith('/auth/code/verify'))
|
|
70
|
+
return new Response(JSON.stringify({ ok: true, token: 't1', user, created: true }), { status: 200 });
|
|
71
|
+
if (url.includes('/auth/session'))
|
|
72
|
+
return new Response(JSON.stringify({ ok: true, user }), { status: 200 });
|
|
73
|
+
if (url.includes('/auth/users?'))
|
|
74
|
+
return new Response(JSON.stringify({ ok: true, users: [user], total: 1, nextSkip: null }), { status: 200 });
|
|
75
|
+
if (url.endsWith('/auth/users/u404'))
|
|
76
|
+
return new Response(JSON.stringify({ ok: false, error: 'USER_NOT_FOUND' }), { status: 404 });
|
|
77
|
+
return new Response(JSON.stringify({ ok: false, error: 'CODE_RATE_LIMITED', message: '发送过于频繁' }), { status: 429 });
|
|
78
|
+
});
|
|
79
|
+
configure({ driver: 'platform', baseUrl: 'https://api.test/data/v1/', apiKey: 'sk-conv-abc', env: 'prod', fetchImpl });
|
|
80
|
+
expect(await auth.sendCode('a@b.com')).toEqual({ sent: true, devCode: null });
|
|
81
|
+
const signed = await auth.verifyCode('a@b.com', '123456');
|
|
82
|
+
expect(signed.token).toBe('t1');
|
|
83
|
+
expect(await auth.getSession('t1')).toMatchObject({ id: 'u1' });
|
|
84
|
+
expect((await auth.users.list({ keyword: 'a' })).total).toBe(1);
|
|
85
|
+
expect(await auth.users.get('u404')).toBeNull();
|
|
86
|
+
const headers = calls[0].init.headers;
|
|
87
|
+
expect(headers['x-api-key']).toBe('sk-conv-abc');
|
|
88
|
+
expect(headers['x-chatu-env']).toBe('prod');
|
|
89
|
+
const sessionCall = calls.find(c => c.url.includes('/auth/session'));
|
|
90
|
+
expect(sessionCall.init.headers['x-app-session']).toBe('t1');
|
|
91
|
+
await expect(auth.login('a@b.com', 'secret1')).rejects.toMatchObject({ code: 'CODE_RATE_LIMITED', status: 429 });
|
|
92
|
+
});
|
|
93
|
+
it('refuses drivers without a platform data service', async () => {
|
|
94
|
+
configure({ driver: 'edgeone' });
|
|
95
|
+
await expect(auth.getSession('t')).rejects.toThrow(/不支持应用用户体系/);
|
|
96
|
+
});
|
|
97
|
+
});
|
package/dist/config.d.ts
CHANGED
|
@@ -62,6 +62,8 @@ export interface ConfigureOptions {
|
|
|
62
62
|
}
|
|
63
63
|
/** 显式配置(测试或非 env 场景);不调用则完全由环境变量决定 */
|
|
64
64
|
export declare function configure(options: ConfigureOptions): void;
|
|
65
|
+
/** configure() 调用次数:带内存状态的模块(如 auth 的 memory 驱动)用它判断是否该重建客户端 */
|
|
66
|
+
export declare function configVersion(): number;
|
|
65
67
|
export declare function resolveConfig(): ResolvedConfig;
|
|
66
68
|
/** `https://api.chatuapi.com/data/v1` → `https://api.chatuapi.com/v1`(Data API 与 LLM 中继同源) */
|
|
67
69
|
export declare function deriveAiBaseUrl(dataBaseUrl: string): string;
|
package/dist/config.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
let override = {};
|
|
2
|
+
let version = 0;
|
|
2
3
|
/** 显式配置(测试或非 env 场景);不调用则完全由环境变量决定 */
|
|
3
4
|
export function configure(options) {
|
|
4
5
|
override = { ...options };
|
|
6
|
+
version += 1;
|
|
7
|
+
}
|
|
8
|
+
/** configure() 调用次数:带内存状态的模块(如 auth 的 memory 驱动)用它判断是否该重建客户端 */
|
|
9
|
+
export function configVersion() {
|
|
10
|
+
return version;
|
|
5
11
|
}
|
|
6
12
|
export function resolveConfig() {
|
|
7
13
|
// 不依赖 @types/node:通过 globalThis 读取 process.env
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ export { db, getDb, matchesFilter, applySort, queryDocs, newDocId } from './db.j
|
|
|
7
7
|
export type { DbClient, Collection, Doc, Filter, FilterOp, Sort, FindOptions, FindResult, UpdateInput } from './db.js';
|
|
8
8
|
export { storage, getStorage } from './storage.js';
|
|
9
9
|
export type { StorageClient, StorageObject, StorageListResult, UploadUrlResult } from './storage.js';
|
|
10
|
+
export { auth, getAuth } from './auth.js';
|
|
11
|
+
export type { AuthClient, AppUser, SignInResult, SendCodeResult, UserListResult, UserPatch } from './auth.js';
|
|
10
12
|
export { ai, getAi } from './ai.js';
|
|
11
13
|
export type { AiClient, AiMessage, AiChatOptions, AiChatResult, AiUsage } from './ai.js';
|
|
12
14
|
export { encodeKvKey, decodeKvKey } from './edgeone.js';
|
package/dist/index.js
CHANGED
|
@@ -3,5 +3,6 @@ export { configure, describe, registerOptionalModule } from './config.js';
|
|
|
3
3
|
export { AppSdkError } from './errors.js';
|
|
4
4
|
export { db, getDb, matchesFilter, applySort, queryDocs, newDocId } from './db.js';
|
|
5
5
|
export { storage, getStorage } from './storage.js';
|
|
6
|
+
export { auth, getAuth } from './auth.js';
|
|
6
7
|
export { ai, getAi } from './ai.js';
|
|
7
8
|
export { encodeKvKey, decodeKvKey } from './edgeone.js';
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatu-ai/app-sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Runtime SDK for apps generated by ChatU Builder: kv, storage and ai (OpenAI-compatible LLM relay) with platform / byo / memory drivers selected by environment variables",
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "Runtime SDK for apps generated by ChatU Builder: kv, db, storage, auth (app users) and ai (OpenAI-compatible LLM relay) with platform / byo / edgeone / memory drivers selected by environment variables",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./dist/index.js",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"kv",
|
|
32
32
|
"storage",
|
|
33
33
|
"app-sdk",
|
|
34
|
+
"auth",
|
|
34
35
|
"ai",
|
|
35
36
|
"llm",
|
|
36
37
|
"nextjs"
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: chatu-auth
|
|
3
|
+
description: 应用自己的登录用户体系(@chatu-ai/app-sdk 的 auth)。当应用需要"登录后才能用""每个人只看到自己的数据""会员/后台/多人协作"时使用;提供邮箱验证码登录、邮箱密码登录、会话 Cookie、用户管理。禁止引入 next-auth/clerk/supabase-auth/firebase-auth/bcrypt/jose 等第三方登录库。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 应用用户体系(auth)
|
|
7
|
+
|
|
8
|
+
给**生成出来的这个应用**一套自己的终端用户:注册、登录、会话、退出、用户管理。与 ChatU 平台账号完全无关,数据按"应用 + 环境(预览/线上)"隔离——预览环境注册的测试账号不会出现在线上。
|
|
9
|
+
|
|
10
|
+
## 什么时候需要它
|
|
11
|
+
|
|
12
|
+
| 需求 | 是否需要 auth |
|
|
13
|
+
| --- | --- |
|
|
14
|
+
| "我的待办/我的收藏/我的订单"——每人只看自己的数据 | ✅ |
|
|
15
|
+
| 会员中心、后台管理页、只有登录用户能发帖/评论 | ✅ |
|
|
16
|
+
| 多人协作(谁创建的、谁修改的) | ✅ |
|
|
17
|
+
| 纯展示页、工具页、所有人看到同样内容 | ❌ 不要加登录,直接做 |
|
|
18
|
+
|
|
19
|
+
## API
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import {
|
|
23
|
+
currentUser, requireUser, sendLoginCode, signInWithCode,
|
|
24
|
+
signUpWithPassword, signInWithPassword, endSession, auth,
|
|
25
|
+
} from '@/lib/platform';
|
|
26
|
+
|
|
27
|
+
const user = await currentUser(); // AppUser | null,Server Component 里可直接用
|
|
28
|
+
const me = await requireUser(); // 未登录自动 redirect('/login')
|
|
29
|
+
|
|
30
|
+
// 邮箱验证码(推荐:不用记密码)
|
|
31
|
+
const { devCode } = await sendLoginCode(email); // 预览环境未配邮件时返回 devCode,方便自测
|
|
32
|
+
const user = await signInWithCode(email, code, { name }); // 首次登录自动注册
|
|
33
|
+
|
|
34
|
+
// 邮箱 + 密码(可选路线)
|
|
35
|
+
await signUpWithPassword(email, password, { name }); // 密码 ≥ 6 位
|
|
36
|
+
await signInWithPassword(email, password);
|
|
37
|
+
|
|
38
|
+
await endSession(); // 退出登录
|
|
39
|
+
|
|
40
|
+
// 用户管理(后台页用)
|
|
41
|
+
const { users, total, nextSkip } = await auth.users.list({ skip: 0, limit: 50, keyword: '张' });
|
|
42
|
+
await auth.users.update(id, { name: '新名字', disabled: true, meta: { role: 'admin' } });
|
|
43
|
+
await auth.users.delete(id);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`AppUser`:`{ id, email, name, avatar, createdAt, lastLoginAt, disabled, meta }`。密码永远不会回传。
|
|
47
|
+
|
|
48
|
+
**登录态存在 HttpOnly Cookie 里**,`signIn*` / `endSession` 会写/删 Cookie —— 因此**只能在 Server Action 或 Route Handler 中调用**(Server Component 只能 `currentUser()` 读)。
|
|
49
|
+
|
|
50
|
+
## 标准登录页(验证码,两步)
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
// src/app/login/page.tsx
|
|
54
|
+
import { redirect } from 'next/navigation';
|
|
55
|
+
import { sendLoginCode, signInWithCode, currentUser } from '@/lib/platform';
|
|
56
|
+
|
|
57
|
+
export default async function LoginPage({ searchParams }: { searchParams: Promise<{ email?: string; error?: string }> }) {
|
|
58
|
+
if (await currentUser()) redirect('/');
|
|
59
|
+
const { email, error } = await searchParams;
|
|
60
|
+
|
|
61
|
+
async function send(formData: FormData) {
|
|
62
|
+
'use server';
|
|
63
|
+
const value = String(formData.get('email') ?? '').trim();
|
|
64
|
+
if (!value) return;
|
|
65
|
+
await sendLoginCode(value);
|
|
66
|
+
redirect(`/login?email=${encodeURIComponent(value)}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function verify(formData: FormData) {
|
|
70
|
+
'use server';
|
|
71
|
+
try {
|
|
72
|
+
await signInWithCode(String(formData.get('email')), String(formData.get('code')));
|
|
73
|
+
} catch {
|
|
74
|
+
redirect(`/login?email=${encodeURIComponent(String(formData.get('email')))}&error=1`);
|
|
75
|
+
}
|
|
76
|
+
redirect('/');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-4 p-6">
|
|
81
|
+
{!email ? (
|
|
82
|
+
<form action={send} className="space-y-3">
|
|
83
|
+
<input name="email" type="email" required placeholder="邮箱" className="w-full rounded-md border px-3 py-2" />
|
|
84
|
+
<button className="w-full rounded-md bg-primary px-3 py-2 text-primary-foreground">发送验证码</button>
|
|
85
|
+
</form>
|
|
86
|
+
) : (
|
|
87
|
+
<form action={verify} className="space-y-3">
|
|
88
|
+
<input type="hidden" name="email" value={email} />
|
|
89
|
+
<p className="text-sm text-muted-foreground">验证码已发送至 {email}</p>
|
|
90
|
+
{error ? <p className="text-sm text-destructive">验证码不正确或已过期</p> : null}
|
|
91
|
+
<input name="code" inputMode="numeric" required placeholder="6 位验证码" className="w-full rounded-md border px-3 py-2" />
|
|
92
|
+
<button className="w-full rounded-md bg-primary px-3 py-2 text-primary-foreground">登录</button>
|
|
93
|
+
</form>
|
|
94
|
+
)}
|
|
95
|
+
</main>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
退出登录:
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
import { endSession } from '@/lib/platform';
|
|
104
|
+
import { redirect } from 'next/navigation';
|
|
105
|
+
|
|
106
|
+
export function SignOutButton() {
|
|
107
|
+
async function out() {
|
|
108
|
+
'use server';
|
|
109
|
+
await endSession();
|
|
110
|
+
redirect('/login');
|
|
111
|
+
}
|
|
112
|
+
return <form action={out}><button className="text-sm underline">退出登录</button></form>;
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## 每个用户自己的数据
|
|
117
|
+
|
|
118
|
+
约定:在 db 文档里存 `userId`,**每次查询都带上它**(见 `chatu-db`)。
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
// src/lib/todos.ts
|
|
122
|
+
import { db, requireUser } from '@/lib/platform';
|
|
123
|
+
|
|
124
|
+
interface Todo { userId: string; title: string; done: boolean }
|
|
125
|
+
const todos = db.collection<Todo>('todos');
|
|
126
|
+
|
|
127
|
+
export async function myTodos() {
|
|
128
|
+
const me = await requireUser();
|
|
129
|
+
return todos.find({ filter: { userId: me.id }, sort: { _createdAt: -1 }, limit: 100 });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function addTodo(title: string) {
|
|
133
|
+
const me = await requireUser();
|
|
134
|
+
return todos.insert({ userId: me.id, title, done: false });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function toggleTodo(id: string, done: boolean) {
|
|
138
|
+
const me = await requireUser();
|
|
139
|
+
const doc = await todos.get(id);
|
|
140
|
+
if (!doc || doc.userId !== me.id) throw new Error('无权操作'); // 越权检查不能省
|
|
141
|
+
return todos.update(id, { set: { done } });
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
管理员:用 `meta.role === 'admin'` 判断(在平台「用户」面板或后台页给某个用户打上),不要硬编码邮箱白名单以外的复杂权限模型。
|
|
146
|
+
|
|
147
|
+
## 边界与禁忌
|
|
148
|
+
|
|
149
|
+
- **禁止**引入 next-auth / auth.js / clerk / supabase-auth / firebase-auth / passport / bcrypt / jose / jsonwebtoken —— 平台已提供,装了也跑不通(沙箱与函数部署都没有对应后端)。
|
|
150
|
+
- 不要自己生成 JWT、不要把用户信息写进普通 Cookie / localStorage,会话只用 `chatu_session`(HttpOnly)。
|
|
151
|
+
- 不要在客户端组件里 import `@/lib/platform` 的 auth;通过 Server Action 或 Route Handler 拿 `currentUser()` 的结果传下去。
|
|
152
|
+
- 需要登录的页面要么 `await requireUser()`,要么在 Server Action 里再校验一次——只在前端隐藏按钮不算保护。
|
|
153
|
+
- 单应用单环境上限 1 万用户、每日验证码 200 封;验证码 10 分钟有效、错 5 次作废、同一邮箱 60 秒才能再发一次。
|
|
154
|
+
- 只有邮箱登录;没有短信、没有微信/GitHub 第三方登录。用户要"手机号登录"时,如实说明当前只支持邮箱。
|
|
155
|
+
|
|
156
|
+
## 常见错误
|
|
157
|
+
|
|
158
|
+
| 现象 | 原因 | 修法 |
|
|
159
|
+
| --- | --- | --- |
|
|
160
|
+
| `Cookies can only be modified in a Server Action or Route Handler` | 在 Server Component 里调用了 `signIn*` / `endSession` | 挪进 `'use server'` 的 action 或 `route.ts` |
|
|
161
|
+
| 登录后刷新又变未登录 | 页面被静态预渲染 | 保留 layout 里的 `export const dynamic = "force-dynamic"` |
|
|
162
|
+
| `EMAIL_NOT_CONFIGURED`(线上) | 平台未配置邮件通道 | 线上改用邮箱密码登录,或让用户联系平台开通 |
|
|
163
|
+
| `CODE_RATE_LIMITED` | 同一邮箱 60 秒内重复发码 | 前端按钮加倒计时 |
|
|
164
|
+
| `AUTH_UNSUPPORTED` | 应用被部署在没有平台数据服务的驱动上(如 edgeone blob) | 部署时选择带平台数据服务的目标 |
|
|
165
|
+
| 别人能看到我的数据 | 查询没带 `userId`,或改删时没做归属校验 | 每个 find/update/delete 都带上 `userId` 判断 |
|