@chatu-ai/app-sdk 0.9.0 → 0.9.2

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
@@ -45,6 +45,8 @@ In the Next.js template, `@/lib/platform` wraps this in HttpOnly-cookie helpers:
45
45
 
46
46
  Limits: 10k users per app/env, 200 codes per day, code valid 10 min / 5 tries, 60s per-email resend window.
47
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
+
48
50
  ## AI (LLM relay)
49
51
 
50
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**.
package/dist/auth.js CHANGED
@@ -3,6 +3,13 @@ import { AppSdkError } from './errors.js';
3
3
  // ---------- platform driver ----------
4
4
  function platformAuth(cfg) {
5
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();
6
13
  async function call(method, path, body, token) {
7
14
  const res = await cfg.fetchImpl(`${cfg.baseUrl}/auth${path}`, {
8
15
  method,
@@ -39,12 +46,25 @@ function platformAuth(cfg) {
39
46
  async getSession(token) {
40
47
  if (!token)
41
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
+ }
42
54
  const r = await call('GET', '/session', undefined, token);
43
- return r.user ?? null;
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;
44
63
  },
45
64
  async signOut(token) {
46
65
  if (!token)
47
66
  return false;
67
+ sessionCache.delete(token);
48
68
  const r = await call('POST', '/logout', {}, token);
49
69
  return r.removed;
50
70
  },
@@ -69,10 +89,12 @@ function platformAuth(cfg) {
69
89
  },
70
90
  async update(id, patch) {
71
91
  const r = await call('PATCH', `/users/${encodeURIComponent(id)}`, patch);
92
+ dropCache(); // 可能刚刚停用了某个用户,缓存里的旧结果必须作废
72
93
  return r.user;
73
94
  },
74
95
  async delete(id) {
75
96
  const r = await call('DELETE', `/users/${encodeURIComponent(id)}`);
97
+ dropCache();
76
98
  return r.removed;
77
99
  },
78
100
  },
@@ -208,8 +230,10 @@ let cached = null;
208
230
  /** 按当前配置取 auth 客户端(惰性、缓存;configure() 后自动重建) */
209
231
  export function getAuth() {
210
232
  const cfg = resolveConfig();
211
- // memory 驱动带进程内状态:把 configure() 次数并入缓存键,重新配置即换一套干净的用户表
212
- const key = cfg.kind === 'platform' ? `platform|${cfg.baseUrl}|${cfg.env}|${cfg.apiKey.slice(-4)}` : `${cfg.kind}|${configVersion()}`;
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()}`;
213
237
  if (!cached || cached.key !== key) {
214
238
  cached = {
215
239
  key,
package/dist/auth.test.js CHANGED
@@ -90,6 +90,40 @@ d('auth platform driver', () => {
90
90
  expect(sessionCall.init.headers['x-app-session']).toBe('t1');
91
91
  await expect(auth.login('a@b.com', 'secret1')).rejects.toMatchObject({ code: 'CODE_RATE_LIMITED', status: 429 });
92
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
+ });
93
127
  it('refuses drivers without a platform data service', async () => {
94
128
  configure({ driver: 'edgeone' });
95
129
  await expect(auth.getSession('t')).rejects.toThrow(/不支持应用用户体系/);
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,6 +65,8 @@ 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;
package/dist/config.js CHANGED
@@ -63,10 +63,20 @@ export function resolveConfig() {
63
63
  fetchImpl: override.fetchImpl ?? fetch,
64
64
  aiBaseUrl: (override.aiBaseUrl ?? env.CHATU_AI_URL ?? deriveAiBaseUrl(normalizedBase)).replace(/\/+$/, ''),
65
65
  aiModel: override.model ?? env.CHATU_AI_MODEL ?? env.PRIMARY_MODEL,
66
+ authSessionCacheSeconds: normalizeCacheSeconds(override.authSessionCacheSeconds ?? env.CHATU_AUTH_SESSION_CACHE),
66
67
  };
67
68
  }
68
69
  return { kind: 'memory' };
69
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
+ }
70
80
  /** `https://api.chatuapi.com/data/v1` → `https://api.chatuapi.com/v1`(Data API 与 LLM 中继同源) */
71
81
  export function deriveAiBaseUrl(dataBaseUrl) {
72
82
  const trimmed = dataBaseUrl.replace(/\/+$/, '');
@@ -112,6 +122,7 @@ export function resolveAiConfig() {
112
122
  fetchImpl: override.fetchImpl ?? fetch,
113
123
  aiBaseUrl: (override.aiBaseUrl ?? env.CHATU_AI_URL ?? deriveAiBaseUrl(normalizedBase)).replace(/\/+$/, ''),
114
124
  aiModel: override.model ?? env.CHATU_AI_MODEL ?? env.PRIMARY_MODEL,
125
+ authSessionCacheSeconds: normalizeCacheSeconds(override.authSessionCacheSeconds ?? env.CHATU_AUTH_SESSION_CACHE),
115
126
  };
116
127
  }
117
128
  /** 动态加载可选依赖(ioredis / @aws-sdk/* / @edgeone/pages-blob),不参与打包静态分析;缺失时给出可操作的错误 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatu-ai/app-sdk",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
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",
@@ -5,7 +5,7 @@ description: 应用自己的登录用户体系(@chatu-ai/app-sdk 的 auth)
5
5
 
6
6
  # 应用用户体系(auth)
7
7
 
8
- 给**生成出来的这个应用**一套自己的终端用户:注册、登录、会话、退出、用户管理。与 ChatU 平台账号完全无关,数据按"应用 + 环境(预览/线上)"隔离——预览环境注册的测试账号不会出现在线上。
8
+ 给**生成出来的这个应用**一套自己的终端用户:注册、登录、会话、退出、用户管理。**用量计入应用所有者的 ChatU 点数**(每次调用记 `auth_ops`,实际外发的验证码邮件另按封计价,见下方「计费与省钱写法」)。与 ChatU 平台账号完全无关,数据按"应用 + 环境(预览/线上)"隔离——预览环境注册的测试账号不会出现在线上。
9
9
 
10
10
  ## 什么时候需要它
11
11
 
@@ -144,13 +144,37 @@ export async function toggleTodo(id: string, done: boolean) {
144
144
 
145
145
  管理员:用 `meta.role === 'admin'` 判断(在平台「用户」面板或后台页给某个用户打上),不要硬编码邮箱白名单以外的复杂权限模型。
146
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
+
147
171
  ## 边界与禁忌
148
172
 
149
173
  - **禁止**引入 next-auth / auth.js / clerk / supabase-auth / firebase-auth / passport / bcrypt / jose / jsonwebtoken —— 平台已提供,装了也跑不通(沙箱与函数部署都没有对应后端)。
150
174
  - 不要自己生成 JWT、不要把用户信息写进普通 Cookie / localStorage,会话只用 `chatu_session`(HttpOnly)。
151
175
  - 不要在客户端组件里 import `@/lib/platform` 的 auth;通过 Server Action 或 Route Handler 拿 `currentUser()` 的结果传下去。
152
176
  - 需要登录的页面要么 `await requireUser()`,要么在 Server Action 里再校验一次——只在前端隐藏按钮不算保护。
153
- - 单应用单环境上限 1 万用户、每日验证码 200 封;验证码 10 分钟有效、错 5 次作废、同一邮箱 60 秒才能再发一次。
177
+ - 单应用单环境上限 1 万用户、每日验证码 200 封(超出报 `CODE_QUOTA_EXCEEDED`);验证码 10 分钟有效、错 5 次作废、同一邮箱 60 秒才能再发一次。
154
178
  - 只有邮箱登录;没有短信、没有微信/GitHub 第三方登录。用户要"手机号登录"时,如实说明当前只支持邮箱。
155
179
 
156
180
  ## 常见错误
@@ -162,4 +186,6 @@ export async function toggleTodo(id: string, done: boolean) {
162
186
  | `EMAIL_NOT_CONFIGURED`(线上) | 平台未配置邮件通道 | 线上改用邮箱密码登录,或让用户联系平台开通 |
163
187
  | `CODE_RATE_LIMITED` | 同一邮箱 60 秒内重复发码 | 前端按钮加倒计时 |
164
188
  | `AUTH_UNSUPPORTED` | 应用被部署在没有平台数据服务的驱动上(如 edgeone blob) | 部署时选择带平台数据服务的目标 |
189
+ | `READ_ONLY` / 无法注册新用户 | 应用所有者点数不足,数据已置只读 | 已登录用户仍可访问;充值后自动恢复 |
190
+ | 停用了用户但他还能访问 | 会话缓存最长 30 秒 | 等待缓存过期,或把 `CHATU_AUTH_SESSION_CACHE` 设为 0 |
165
191
  | 别人能看到我的数据 | 查询没带 `userId`,或改删时没做归属校验 | 每个 find/update/delete 都带上 `userId` 判断 |