@chatu-ai/app-sdk 0.8.7 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @chatu-ai/app-sdk
2
2
 
3
- Data + 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.
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,31 @@ 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
+
48
+ Billing: every auth call is metered as `auth_ops` (100 calls = 1 point by default) and each **actually sent** verification email as `auth_emails` (1 email = 1 point). Since `getSession()` runs on every request, the platform driver keeps a 30-second in-process session cache — tune it with `CHATU_AUTH_SESSION_CACHE` (seconds, `0` disables) or `configure({ authSessionCacheSeconds })`. The cache is dropped on `signOut()` and on any `users.update()` / `users.delete()`, so disabling a user takes effect within that window.
49
+
25
50
  ## AI (LLM relay)
26
51
 
27
52
  `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 +96,6 @@ Never expose `CHATU_APP_KEY` to the browser. MIT.
71
96
 
72
97
  ## Agent skills
73
98
 
74
- The package ships `skills/chatu-{kv,db,storage,ai}/SKILL.md` — task-focused manuals for coding agents
99
+ The package ships `skills/chatu-{kv,db,storage,ai,auth}/SKILL.md` — task-focused manuals for coding agents
75
100
  (standard rules, boilerplate, boundaries, common failure modes). The ChatU Builder sandbox copies them
76
101
  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,261 @@
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
+ /**
7
+ * 会话缓存:getSession() 会被每个请求调用,而每次调用都计费(auth_ops)。
8
+ * 缓存命中期内不再回源;停用/删除用户或退出登录时清掉,最长滞后 cfg.authSessionCacheSeconds。
9
+ */
10
+ const sessionCache = new Map();
11
+ const cacheTtl = cfg.authSessionCacheSeconds * 1000;
12
+ const dropCache = () => sessionCache.clear();
13
+ async function call(method, path, body, token) {
14
+ const res = await cfg.fetchImpl(`${cfg.baseUrl}/auth${path}`, {
15
+ method,
16
+ headers: token ? { ...headers, 'x-app-session': token } : headers,
17
+ body: body === undefined ? undefined : JSON.stringify(body),
18
+ });
19
+ let json = null;
20
+ try {
21
+ json = await res.json();
22
+ }
23
+ catch { /* ignore */ }
24
+ if (!res.ok || json?.ok === false) {
25
+ throw new AppSdkError(json?.error ?? `HTTP_${res.status}`, json?.message ?? `auth ${method} ${path} failed (${res.status})`, res.status);
26
+ }
27
+ return json;
28
+ }
29
+ return {
30
+ async sendCode(email) {
31
+ const r = await call('POST', '/code/send', { email });
32
+ return { sent: r.sent, devCode: r.devCode ?? null };
33
+ },
34
+ async verifyCode(email, code, opts) {
35
+ const r = await call('POST', '/code/verify', { email, code, name: opts?.name });
36
+ return { token: r.token, user: r.user, created: r.created };
37
+ },
38
+ async register(email, password, opts) {
39
+ const r = await call('POST', '/password/register', { email, password, name: opts?.name });
40
+ return { token: r.token, user: r.user, created: r.created };
41
+ },
42
+ async login(email, password) {
43
+ const r = await call('POST', '/password/login', { email, password });
44
+ return { token: r.token, user: r.user, created: false };
45
+ },
46
+ async getSession(token) {
47
+ if (!token)
48
+ return null;
49
+ if (cacheTtl > 0) {
50
+ const hit = sessionCache.get(token);
51
+ if (hit && hit.expiresAt > Date.now())
52
+ return hit.user;
53
+ }
54
+ const r = await call('GET', '/session', undefined, token);
55
+ const user = r.user ?? null;
56
+ if (cacheTtl > 0) {
57
+ // 只缓存有限条目,避免伪造 token 打满内存
58
+ if (sessionCache.size > 1000)
59
+ dropCache();
60
+ sessionCache.set(token, { user, expiresAt: Date.now() + cacheTtl });
61
+ }
62
+ return user;
63
+ },
64
+ async signOut(token) {
65
+ if (!token)
66
+ return false;
67
+ sessionCache.delete(token);
68
+ const r = await call('POST', '/logout', {}, token);
69
+ return r.removed;
70
+ },
71
+ users: {
72
+ async list(opts) {
73
+ const q = new URLSearchParams({ skip: String(opts?.skip ?? 0), limit: String(opts?.limit ?? 50) });
74
+ if (opts?.keyword)
75
+ q.set('keyword', opts.keyword);
76
+ const r = await call('GET', `/users?${q.toString()}`);
77
+ return { users: r.users, total: r.total, nextSkip: r.nextSkip ?? null };
78
+ },
79
+ async get(id) {
80
+ try {
81
+ const r = await call('GET', `/users/${encodeURIComponent(id)}`);
82
+ return r.user;
83
+ }
84
+ catch (err) {
85
+ if (err instanceof AppSdkError && err.code === 'USER_NOT_FOUND')
86
+ return null;
87
+ throw err;
88
+ }
89
+ },
90
+ async update(id, patch) {
91
+ const r = await call('PATCH', `/users/${encodeURIComponent(id)}`, patch);
92
+ dropCache(); // 可能刚刚停用了某个用户,缓存里的旧结果必须作废
93
+ return r.user;
94
+ },
95
+ async delete(id) {
96
+ const r = await call('DELETE', `/users/${encodeURIComponent(id)}`);
97
+ dropCache();
98
+ return r.removed;
99
+ },
100
+ },
101
+ };
102
+ }
103
+ // ---------- memory driver(本机开发 / 测试;进程退出即丢失) ----------
104
+ function memoryAuth() {
105
+ const users = new Map();
106
+ /** 注册序号:同一毫秒创建的用户也要有稳定的先后顺序 */
107
+ const seq = new Map();
108
+ let nextSeq = 0;
109
+ const byEmail = new Map();
110
+ const sessions = new Map();
111
+ const codes = new Map();
112
+ const norm = (email) => email.trim().toLowerCase();
113
+ const strip = (u) => { const { pwd: _pwd, ...rest } = u; return rest; };
114
+ const issue = (id) => { const token = `mem_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`; sessions.set(token, id); return token; };
115
+ const create = (email, name, pwd) => {
116
+ const now = Date.now();
117
+ 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 };
118
+ users.set(user.id, user);
119
+ seq.set(user.id, nextSeq++);
120
+ byEmail.set(email, user.id);
121
+ return user;
122
+ };
123
+ return {
124
+ async sendCode(email) { const code = String(Math.floor(100000 + Math.random() * 900000)); codes.set(norm(email), code); return { sent: true, devCode: code }; },
125
+ async verifyCode(email, code, opts) {
126
+ const key = norm(email);
127
+ if (codes.get(key) !== code.trim())
128
+ throw new AppSdkError('CODE_INVALID', '验证码不正确');
129
+ codes.delete(key);
130
+ const existingId = byEmail.get(key);
131
+ const user = existingId ? users.get(existingId) : create(key, opts?.name);
132
+ user.lastLoginAt = Date.now();
133
+ return { token: issue(user.id), user: strip(user), created: !existingId };
134
+ },
135
+ async register(email, password, opts) {
136
+ const key = norm(email);
137
+ if (byEmail.has(key))
138
+ throw new AppSdkError('EMAIL_TAKEN', '该邮箱已注册');
139
+ if (password.length < 6)
140
+ throw new AppSdkError('WEAK_PASSWORD', '密码至少 6 位');
141
+ const user = create(key, opts?.name, password);
142
+ return { token: issue(user.id), user: strip(user), created: true };
143
+ },
144
+ async login(email, password) {
145
+ const id = byEmail.get(norm(email));
146
+ const user = id ? users.get(id) : undefined;
147
+ if (!user || user.pwd !== password)
148
+ throw new AppSdkError('INVALID_CREDENTIALS', '邮箱或密码不正确');
149
+ if (user.disabled)
150
+ throw new AppSdkError('USER_DISABLED', '该账号已被停用');
151
+ user.lastLoginAt = Date.now();
152
+ return { token: issue(user.id), user: strip(user), created: false };
153
+ },
154
+ async getSession(token) {
155
+ if (!token)
156
+ return null;
157
+ const id = sessions.get(token);
158
+ const user = id ? users.get(id) : undefined;
159
+ return !user || user.disabled ? null : strip(user);
160
+ },
161
+ async signOut(token) { return token ? sessions.delete(token) : false; },
162
+ users: {
163
+ async list(opts) {
164
+ const kw = opts?.keyword?.trim().toLowerCase();
165
+ const all = [...users.values()]
166
+ .filter(u => !kw || u.email.includes(kw) || (u.name ?? '').toLowerCase().includes(kw))
167
+ .sort((a, b) => b.createdAt - a.createdAt || (seq.get(b.id) ?? 0) - (seq.get(a.id) ?? 0));
168
+ const skip = opts?.skip ?? 0;
169
+ const limit = opts?.limit ?? 50;
170
+ const page = all.slice(skip, skip + limit);
171
+ return { users: page.map(strip), total: all.length, nextSkip: skip + page.length < all.length ? skip + page.length : null };
172
+ },
173
+ async get(id) { const u = users.get(id); return u ? strip(u) : null; },
174
+ async update(id, patch) {
175
+ const u = users.get(id);
176
+ if (!u)
177
+ throw new AppSdkError('USER_NOT_FOUND', '用户不存在');
178
+ if (patch.name !== undefined)
179
+ u.name = patch.name;
180
+ if (patch.avatar !== undefined)
181
+ u.avatar = patch.avatar;
182
+ if (patch.meta !== undefined)
183
+ u.meta = patch.meta;
184
+ if (patch.password !== undefined)
185
+ u.pwd = patch.password;
186
+ if (patch.disabled !== undefined) {
187
+ u.disabled = patch.disabled;
188
+ if (patch.disabled)
189
+ for (const [t, uid] of [...sessions])
190
+ if (uid === id)
191
+ sessions.delete(t);
192
+ }
193
+ return strip(u);
194
+ },
195
+ async delete(id) {
196
+ const u = users.get(id);
197
+ if (!u)
198
+ return false;
199
+ for (const [t, uid] of [...sessions])
200
+ if (uid === id)
201
+ sessions.delete(t);
202
+ byEmail.delete(u.email);
203
+ seq.delete(id);
204
+ return users.delete(id);
205
+ },
206
+ },
207
+ };
208
+ }
209
+ /** byo / edgeone 等驱动没有用户存储:每个方法都以明确错误 reject,而不是在取客户端时同步抛出 */
210
+ function unsupportedAuth(kind) {
211
+ const fail = () => {
212
+ throw new AppSdkError('AUTH_UNSUPPORTED', `当前数据驱动(${kind})不支持应用用户体系;auth 需要平台数据服务(配置 CHATU_APP_KEY 使用 platform 驱动)`);
213
+ };
214
+ return {
215
+ async sendCode() { return fail(); },
216
+ async verifyCode() { return fail(); },
217
+ async register() { return fail(); },
218
+ async login() { return fail(); },
219
+ async getSession() { return fail(); },
220
+ async signOut() { return fail(); },
221
+ users: {
222
+ async list() { return fail(); },
223
+ async get() { return fail(); },
224
+ async update() { return fail(); },
225
+ async delete() { return fail(); },
226
+ },
227
+ };
228
+ }
229
+ let cached = null;
230
+ /** 按当前配置取 auth 客户端(惰性、缓存;configure() 后自动重建) */
231
+ export function getAuth() {
232
+ const cfg = resolveConfig();
233
+ // 两种驱动都带进程内状态(platform 的会话缓存 / memory 的用户表):并入 configure() 次数,重新配置即重建
234
+ const key = cfg.kind === 'platform'
235
+ ? `platform|${cfg.baseUrl}|${cfg.env}|${cfg.apiKey.slice(-4)}|${cfg.authSessionCacheSeconds}|${configVersion()}`
236
+ : `${cfg.kind}|${configVersion()}`;
237
+ if (!cached || cached.key !== key) {
238
+ cached = {
239
+ key,
240
+ client: cfg.kind === 'platform' ? platformAuth(cfg)
241
+ : cfg.kind === 'memory' ? memoryAuth()
242
+ : unsupportedAuth(cfg.kind),
243
+ };
244
+ }
245
+ return cached.client;
246
+ }
247
+ /** 便捷单例:`import { auth } from '@chatu-ai/app-sdk'` */
248
+ export const auth = {
249
+ sendCode: (email) => getAuth().sendCode(email),
250
+ verifyCode: (email, code, opts) => getAuth().verifyCode(email, code, opts),
251
+ register: (email, password, opts) => getAuth().register(email, password, opts),
252
+ login: (email, password) => getAuth().login(email, password),
253
+ getSession: (token) => getAuth().getSession(token),
254
+ signOut: (token) => getAuth().signOut(token),
255
+ users: {
256
+ list: (opts) => getAuth().users.list(opts),
257
+ get: (id) => getAuth().users.get(id),
258
+ update: (id, patch) => getAuth().users.update(id, patch),
259
+ delete: (id) => getAuth().users.delete(id),
260
+ },
261
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,131 @@
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('caches getSession to avoid paying for one auth call per request', async () => {
94
+ let sessionCalls = 0;
95
+ const user = { id: 'u1', email: 'a@b.com', name: 'A', avatar: null, createdAt: 1, lastLoginAt: 2, disabled: false, meta: {} };
96
+ const fetchImpl = (async (url) => {
97
+ if (url.includes('/auth/session'))
98
+ sessionCalls += 1;
99
+ if (url.includes('/auth/users/'))
100
+ return new Response(JSON.stringify({ ok: true, user }), { status: 200 });
101
+ return new Response(JSON.stringify({ ok: true, user, removed: true }), { status: 200 });
102
+ });
103
+ configure({ driver: 'platform', baseUrl: 'https://api.test/data/v1', apiKey: 'sk-conv-abc', env: 'prod', fetchImpl });
104
+ await auth.getSession('t1');
105
+ await auth.getSession('t1');
106
+ await auth.getSession('t1');
107
+ expect(sessionCalls).toBe(1);
108
+ await auth.getSession('t2');
109
+ expect(sessionCalls).toBe(2);
110
+ // 停用某个用户后缓存必须作废,否则被停用的人还能继续访问
111
+ await auth.users.update('u1', { disabled: true });
112
+ await auth.getSession('t1');
113
+ expect(sessionCalls).toBe(3);
114
+ // 退出登录只清掉自己那条
115
+ await auth.signOut('t1');
116
+ await auth.getSession('t1');
117
+ expect(sessionCalls).toBe(4);
118
+ await auth.getSession('t2');
119
+ expect(sessionCalls).toBe(5);
120
+ // 显式关闭缓存:每次都回源
121
+ configure({ driver: 'platform', baseUrl: 'https://api.test/data/v1', apiKey: 'sk-conv-abc', env: 'prod', fetchImpl, authSessionCacheSeconds: 0 });
122
+ sessionCalls = 0;
123
+ await auth.getSession('t1');
124
+ await auth.getSession('t1');
125
+ expect(sessionCalls).toBe(2);
126
+ });
127
+ it('refuses drivers without a platform data service', async () => {
128
+ configure({ driver: 'edgeone' });
129
+ await expect(auth.getSession('t')).rejects.toThrow(/不支持应用用户体系/);
130
+ });
131
+ });
package/dist/config.d.ts CHANGED
@@ -15,6 +15,12 @@ export interface PlatformConfig {
15
15
  aiBaseUrl: string;
16
16
  /** 默认模型:CHATU_AI_MODEL → PRIMARY_MODEL(沙箱注入的平台默认模型);都没有则不传,由服务端决定 */
17
17
  aiModel?: string;
18
+ /**
19
+ * auth.getSession() 的进程内缓存秒数(默认 30,0 关闭)。
20
+ * 会话校验每个请求都会发生,缓存能显著减少计费的 auth 调用;代价是"停用用户"最多延迟这么久生效。
21
+ * 覆盖:configure({ authSessionCacheSeconds }) 或环境变量 CHATU_AUTH_SESSION_CACHE。
22
+ */
23
+ authSessionCacheSeconds: number;
18
24
  }
19
25
  export interface MemoryConfig {
20
26
  kind: 'memory';
@@ -59,9 +65,13 @@ export interface ConfigureOptions {
59
65
  aiBaseUrl?: string;
60
66
  /** LLM 默认模型 */
61
67
  model?: string;
68
+ /** auth.getSession() 进程内缓存秒数(默认 30,0 关闭) */
69
+ authSessionCacheSeconds?: number;
62
70
  }
63
71
  /** 显式配置(测试或非 env 场景);不调用则完全由环境变量决定 */
64
72
  export declare function configure(options: ConfigureOptions): void;
73
+ /** configure() 调用次数:带内存状态的模块(如 auth 的 memory 驱动)用它判断是否该重建客户端 */
74
+ export declare function configVersion(): number;
65
75
  export declare function resolveConfig(): ResolvedConfig;
66
76
  /** `https://api.chatuapi.com/data/v1` → `https://api.chatuapi.com/v1`(Data API 与 LLM 中继同源) */
67
77
  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
@@ -57,10 +63,20 @@ export function resolveConfig() {
57
63
  fetchImpl: override.fetchImpl ?? fetch,
58
64
  aiBaseUrl: (override.aiBaseUrl ?? env.CHATU_AI_URL ?? deriveAiBaseUrl(normalizedBase)).replace(/\/+$/, ''),
59
65
  aiModel: override.model ?? env.CHATU_AI_MODEL ?? env.PRIMARY_MODEL,
66
+ authSessionCacheSeconds: normalizeCacheSeconds(override.authSessionCacheSeconds ?? env.CHATU_AUTH_SESSION_CACHE),
60
67
  };
61
68
  }
62
69
  return { kind: 'memory' };
63
70
  }
71
+ /** 会话缓存秒数:非法值回落到默认 30,上限 300(避免停用用户长时间仍可用) */
72
+ function normalizeCacheSeconds(value) {
73
+ if (value === undefined || value === '')
74
+ return 30;
75
+ const n = typeof value === 'number' ? value : Number(value);
76
+ if (!Number.isFinite(n) || n < 0)
77
+ return 30;
78
+ return Math.min(Math.floor(n), 300);
79
+ }
64
80
  /** `https://api.chatuapi.com/data/v1` → `https://api.chatuapi.com/v1`(Data API 与 LLM 中继同源) */
65
81
  export function deriveAiBaseUrl(dataBaseUrl) {
66
82
  const trimmed = dataBaseUrl.replace(/\/+$/, '');
@@ -106,6 +122,7 @@ export function resolveAiConfig() {
106
122
  fetchImpl: override.fetchImpl ?? fetch,
107
123
  aiBaseUrl: (override.aiBaseUrl ?? env.CHATU_AI_URL ?? deriveAiBaseUrl(normalizedBase)).replace(/\/+$/, ''),
108
124
  aiModel: override.model ?? env.CHATU_AI_MODEL ?? env.PRIMARY_MODEL,
125
+ authSessionCacheSeconds: normalizeCacheSeconds(override.authSessionCacheSeconds ?? env.CHATU_AUTH_SESSION_CACHE),
109
126
  };
110
127
  }
111
128
  /** 动态加载可选依赖(ioredis / @aws-sdk/* / @edgeone/pages-blob),不参与打包静态分析;缺失时给出可操作的错误 */
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.8.7",
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.1",
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,191 @@
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 点数**(每次调用记 `auth_ops`,实际外发的验证码邮件另按封计价,见下方「计费与省钱写法」)。与 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
+ | 计量项 | 何时累加 | 默认折算 |
150
+ | --- | --- | --- |
151
+ | `auth_ops` | 每次 auth 接口调用(发码、校验、登录、`currentUser()`、用户管理…) | 100 次 = 1 点 |
152
+ | `auth_emails` | **真正发出**的验证码邮件(预览环境未配邮件返回 devCode 时不计) | 1 封 = 1 点 |
153
+
154
+ `currentUser()` / `requireUser()` 每次请求都会打一次会话校验,是最容易放量的一项。SDK 已内置 **30 秒进程内会话缓存**(`CHATU_AUTH_SESSION_CACHE` 秒数可调,0 关闭),但代码写法仍决定实际调用量:
155
+
156
+ ```ts
157
+ // ✅ 一个页面/一次 Server Action 只取一次,往下传
158
+ export default async function Page() {
159
+ const me = await requireUser();
160
+ return <><Header user={me} /><TodoList user={me} /></>; // 不要在每个子组件里再 currentUser()
161
+ }
162
+
163
+ // ❌ 循环里逐条校验
164
+ for (const item of items) { const me = await currentUser(); /* … */ }
165
+
166
+ // ✅ 公开页面不要强行登录:能匿名浏览的内容别加 requireUser()
167
+ ```
168
+
169
+ 发码按钮务必加 60 秒倒计时(服务端也有 60s 频控),既省邮件钱也避免 `CODE_RATE_LIMITED`。批量导入用户时用 `auth.users.list({ limit: 200 })` 一次多取,别逐个 `users.get()`。
170
+
171
+ ## 边界与禁忌
172
+
173
+ - **禁止**引入 next-auth / auth.js / clerk / supabase-auth / firebase-auth / passport / bcrypt / jose / jsonwebtoken —— 平台已提供,装了也跑不通(沙箱与函数部署都没有对应后端)。
174
+ - 不要自己生成 JWT、不要把用户信息写进普通 Cookie / localStorage,会话只用 `chatu_session`(HttpOnly)。
175
+ - 不要在客户端组件里 import `@/lib/platform` 的 auth;通过 Server Action 或 Route Handler 拿 `currentUser()` 的结果传下去。
176
+ - 需要登录的页面要么 `await requireUser()`,要么在 Server Action 里再校验一次——只在前端隐藏按钮不算保护。
177
+ - 单应用单环境上限 1 万用户、每日验证码 200 封(超出报 `CODE_QUOTA_EXCEEDED`);验证码 10 分钟有效、错 5 次作废、同一邮箱 60 秒才能再发一次。
178
+ - 只有邮箱登录;没有短信、没有微信/GitHub 第三方登录。用户要"手机号登录"时,如实说明当前只支持邮箱。
179
+
180
+ ## 常见错误
181
+
182
+ | 现象 | 原因 | 修法 |
183
+ | --- | --- | --- |
184
+ | `Cookies can only be modified in a Server Action or Route Handler` | 在 Server Component 里调用了 `signIn*` / `endSession` | 挪进 `'use server'` 的 action 或 `route.ts` |
185
+ | 登录后刷新又变未登录 | 页面被静态预渲染 | 保留 layout 里的 `export const dynamic = "force-dynamic"` |
186
+ | `EMAIL_NOT_CONFIGURED`(线上) | 平台未配置邮件通道 | 线上改用邮箱密码登录,或让用户联系平台开通 |
187
+ | `CODE_RATE_LIMITED` | 同一邮箱 60 秒内重复发码 | 前端按钮加倒计时 |
188
+ | `AUTH_UNSUPPORTED` | 应用被部署在没有平台数据服务的驱动上(如 edgeone blob) | 部署时选择带平台数据服务的目标 |
189
+ | `READ_ONLY` / 无法注册新用户 | 应用所有者点数不足,数据已置只读 | 已登录用户仍可访问;充值后自动恢复 |
190
+ | 停用了用户但他还能访问 | 会话缓存最长 30 秒 | 等待缓存过期,或把 `CHATU_AUTH_SESSION_CACHE` 设为 0 |
191
+ | 别人能看到我的数据 | 查询没带 `userId`,或改删时没做归属校验 | 每个 find/update/delete 都带上 `userId` 判断 |