loginbase 1.3.0 → 1.4.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/dist/config.d.ts CHANGED
@@ -100,6 +100,18 @@ export interface LoginConfig {
100
100
  /** refresh 会话滑动过期(每次轮换重新起算);null/缺省 = 不过期 */
101
101
  refreshTtlMs?: number | null;
102
102
  };
103
+ /**
104
+ * 登录统计(方案见 docs/stats-design.md)。事件写入 `db` 的 `auth_events` 表,
105
+ * 不引入新 binding、不加依赖。
106
+ *
107
+ * **默认开启**——数据不能补录,忘配开关就是白丢一段时期的数据;代价(升级后
108
+ * 未跑 migration 0002)由 stats.ts 的三条 fail-safe 兜住:异常吞掉、异步写、
109
+ * 首次失败告警一次。
110
+ */
111
+ stats?: {
112
+ /** 默认 true;置 false 则一条统计都不写 */
113
+ enabled?: boolean;
114
+ };
103
115
  onVerified: OnVerified;
104
116
  /** 提供后才启用 link 端点(未提供 → 404 not_configured,默认关闭、显式启用) */
105
117
  onLinked?: OnLinked;
package/dist/handler.js CHANGED
@@ -9,6 +9,7 @@ import { createSession, hashRefreshToken, findSession, rotateSession, revokeFami
9
9
  import { signAccessToken, ACCESS_TTL_SECONDS } from "./token.js";
10
10
  import { createAuthMiddleware } from "./middleware.js";
11
11
  import { logEvent } from "./log.js";
12
+ import { createTracker } from "./stats.js";
12
13
  import { trimmedField } from "./body.js";
13
14
  import { registerGithubOauth } from "./plugins/github.js";
14
15
  export function createAuthApp(getConfig, basePath) {
@@ -16,6 +17,8 @@ export function createAuthApp(getConfig, basePath) {
16
17
  const auth = new Hono().basePath(basePath);
17
18
  const cfg = (c) => getConfig(c.env);
18
19
  const emit = (c) => cfg(c).onEvent ?? logEvent;
20
+ // 统计事件统一出口:内部先喂 onEvent(形态与 1.3.0 一致),再异步落 auth_events
21
+ const track = createTracker(getConfig);
19
22
  const accessTtl = (c) => cfg(c).jwt.accessTtlSeconds ?? ACCESS_TTL_SECONDS;
20
23
  const sessionExpiry = (c) => {
21
24
  const ttl = cfg(c).session?.refreshTtlMs ?? null;
@@ -33,6 +36,9 @@ export function createAuthApp(getConfig, basePath) {
33
36
  const ip = c.req.header("CF-Connecting-IP") ?? "unknown";
34
37
  const rl = await checkSendRateLimit(cfg(c).kv, raw, ip);
35
38
  if (!rl.allowed) {
39
+ // 命中限流不只是滥用信号,更是「用户发不出码」的体验问题——分层记录才知道
40
+ // 是自己手快(cooldown)还是真被挡住(email / ip 窗口)
41
+ track(c, { event: "rate_limited", outcome: rl.layer, meta: { endpoint: "code_send" } });
36
42
  return c.json({ error: "too_many_requests", retryAfterSeconds: rl.retryAfterSeconds }, 429);
37
43
  }
38
44
  const code = generateCode();
@@ -42,16 +48,21 @@ export function createAuthApp(getConfig, basePath) {
42
48
  try {
43
49
  await sendCodeEmail(cfg(c).email, raw, code, locale.locale);
44
50
  }
45
- catch {
51
+ catch (err) {
52
+ // 发信失败此前不留任何痕迹;邮件是邮箱登录的命脉,断了整条链就没了。
53
+ // Resend 的 HTTP 码在错误串里,聚合时再解析(不为此改造 email.ts 的抛错形态)。
54
+ track(c, { event: "code_send_failed", meta: { message: String(err) } });
46
55
  return c.json({ error: "internal" }, 500);
47
56
  }
48
57
  // 静默回落意味着「为什么收到英文邮件」在别处查不出来,故选中语言必须留痕
49
- emitEvent({
58
+ track(c, {
50
59
  event: "code_sent",
51
- locale: {
52
- resolved: locale.locale,
53
- ...(locale.requested ? { requested: locale.requested } : {}),
54
- ...(locale.fallback ? { fallback: true } : {}),
60
+ meta: {
61
+ locale: {
62
+ resolved: locale.locale,
63
+ ...(locale.requested ? { requested: locale.requested } : {}),
64
+ ...(locale.fallback ? { fallback: true } : {}),
65
+ },
55
66
  },
56
67
  });
57
68
  await storeCode(cfg(c).kv, raw, code);
@@ -65,14 +76,19 @@ export function createAuthApp(getConfig, basePath) {
65
76
  const email = trimmedField(body.email).toLowerCase();
66
77
  const code = trimmedField(body.code);
67
78
  const stored = await readCode(cfg(c).kv, email);
68
- if (!stored)
79
+ if (!stored) {
80
+ // 过期、已焚、从未发过、已用过——在 KV 层都表现为读不到,无法再分(C3 口径)
81
+ track(c, { event: "code_verify", outcome: "code_not_found" });
69
82
  return c.json({ error: "code_expired" }, 400);
83
+ }
70
84
  if (stored.code !== code) {
71
85
  const attempts = await incrementAttempts(cfg(c).kv, email, stored);
72
86
  if (attempts >= MAX_ATTEMPTS) {
73
87
  await deleteCode(cfg(c).kv, email);
88
+ track(c, { event: "code_verify", outcome: "too_many_attempts" });
74
89
  return c.json({ error: "too_many_attempts" }, 429);
75
90
  }
91
+ track(c, { event: "code_verify", outcome: "invalid_code" });
76
92
  return c.json({ error: "invalid_code" }, 400);
77
93
  }
78
94
  await deleteCode(cfg(c).kv, email);
@@ -89,6 +105,7 @@ export function createAuthApp(getConfig, basePath) {
89
105
  });
90
106
  }
91
107
  catch {
108
+ track(c, { event: "code_verify", outcome: "internal" });
92
109
  return c.json({ error: "internal" }, 500);
93
110
  }
94
111
  const { sessionId, refreshToken } = await createSession(cfg(c).db, {
@@ -98,6 +115,15 @@ export function createAuthApp(getConfig, basePath) {
98
115
  expiresAt: sessionExpiry(c),
99
116
  });
100
117
  const accessToken = await signAccessToken(cfg(c).jwt.secret, verified.userId, sessionId, accessTtl(c));
118
+ track(c, { event: "code_verify", outcome: "ok" });
119
+ // 「登录成功」的唯一口径落点:客户端拿到 token 对。GitHub 轨的同名事件在
120
+ // /oauth/exchange 发(那里才是客户端真正拿到 token 的时刻)。
121
+ track(c, {
122
+ event: "login",
123
+ provider: "email",
124
+ userId: verified.userId,
125
+ ...(verified.isNewUser !== undefined ? { isNewUser: verified.isNewUser } : {}),
126
+ });
101
127
  return c.json({
102
128
  accessToken,
103
129
  refreshToken,
@@ -111,11 +137,17 @@ export function createAuthApp(getConfig, basePath) {
111
137
  .catch(() => ({}));
112
138
  const token = trimmedField(body.refreshToken);
113
139
  if (!token) {
140
+ track(c, { event: "refresh", outcome: "invalid", meta: { reason: "missing_token" } });
114
141
  return c.json({ error: "invalid_refresh_token", reason: "missing_token" }, 401);
115
142
  }
116
143
  const sessionId = await hashRefreshToken(token);
117
144
  const row = await findSession(cfg(c).db, sessionId);
118
145
  if (!row) {
146
+ track(c, {
147
+ event: "refresh",
148
+ outcome: "invalid",
149
+ meta: { reason: "session_not_found" },
150
+ });
119
151
  return c.json({ error: "invalid_refresh_token", reason: "session_not_found" }, 401);
120
152
  }
121
153
  if (row.revoked_at !== null) {
@@ -124,37 +156,56 @@ export function createAuthApp(getConfig, basePath) {
124
156
  const ip = c.req.header("CF-Connecting-IP") ?? undefined;
125
157
  const rescue = await tryRescueSession(cfg(c).db, row, { userAgent, ip }, sessionExpiry(c));
126
158
  if (rescue.status === "rescued") {
127
- emit(c)({
159
+ track(c, {
128
160
  event: "refresh",
129
161
  outcome: "rescued",
130
162
  userId: row.user_id,
131
- familyId: row.family_id,
132
- ip: ip ?? null,
163
+ meta: { familyId: row.family_id },
164
+ hookOnly: { ip: ip ?? null },
133
165
  });
134
166
  const accessToken = await signAccessToken(cfg(c).jwt.secret, row.user_id, rescue.session.sessionId, accessTtl(c));
135
167
  return c.json({ accessToken, refreshToken: rescue.session.refreshToken });
136
168
  }
137
169
  // 救活不成立 → 判定真重用,撤销整条会话链。guardrail 与 not_eligible 分开记录。
138
170
  await revokeFamily(cfg(c).db, row.family_id);
139
- emit(c)({
171
+ track(c, {
140
172
  event: "refresh",
141
173
  outcome: rescue.status === "guardrail" ? "guardrail_revoked" : "reuse_revoked",
142
174
  userId: row.user_id,
143
- familyId: row.family_id,
144
- ip: ip ?? null,
175
+ meta: { familyId: row.family_id },
176
+ hookOnly: { ip: ip ?? null },
145
177
  });
146
178
  return c.json({ error: "invalid_refresh_token", reason: "session_revoked" }, 401);
147
179
  }
148
180
  if (row.expires_at !== null && row.expires_at <= Date.now()) {
181
+ track(c, {
182
+ event: "refresh",
183
+ outcome: "invalid",
184
+ userId: row.user_id,
185
+ meta: { reason: "session_expired" },
186
+ });
149
187
  return c.json({ error: "invalid_refresh_token", reason: "session_expired" }, 401);
150
188
  }
151
189
  const userAgent = c.req.header("User-Agent") ?? undefined;
152
190
  const ip = c.req.header("CF-Connecting-IP") ?? undefined;
153
191
  const next = await rotateSession(cfg(c).db, sessionId, { userAgent, ip }, sessionExpiry(c));
154
192
  if (!next) {
193
+ track(c, {
194
+ event: "refresh",
195
+ outcome: "invalid",
196
+ userId: row.user_id,
197
+ meta: { reason: "rotate_failed" },
198
+ });
155
199
  return c.json({ error: "invalid_refresh_token", reason: "rotate_failed" }, 401);
156
200
  }
157
201
  const accessToken = await signAccessToken(cfg(c).jwt.secret, row.user_id, next.sessionId, accessTtl(c));
202
+ // 成功续期此前不发事件,救活率(F2)因此一直没有分母
203
+ track(c, {
204
+ event: "refresh",
205
+ outcome: "ok",
206
+ userId: row.user_id,
207
+ meta: { familyId: row.family_id },
208
+ });
158
209
  return c.json({ accessToken, refreshToken: next.refreshToken });
159
210
  });
160
211
  auth.delete("/sessions", authMiddleware, async (c) => {
@@ -162,11 +213,14 @@ export function createAuthApp(getConfig, basePath) {
162
213
  if (!sessionId)
163
214
  return c.json({ error: "unauthorized" }, 401);
164
215
  await revokeSession(cfg(c).db, sessionId);
216
+ // 主动登出与「被轮换而作废」在 sessions 表里长得很像,只有事件能干净区分
217
+ track(c, { event: "session_revoked", outcome: "current", userId: c.get("userId") });
165
218
  return c.body(null, 204);
166
219
  });
167
220
  auth.delete("/sessions/all", authMiddleware, async (c) => {
168
221
  const userId = c.get("userId");
169
222
  await revokeAllForUser(cfg(c).db, userId);
223
+ track(c, { event: "session_revoked", outcome: "all", userId });
170
224
  return c.body(null, 204);
171
225
  });
172
226
  registerGithubOauth(auth, getConfig, basePath);
package/dist/index.d.ts CHANGED
@@ -24,3 +24,4 @@ export * from "./rate_limit.js";
24
24
  export * from "./session.js";
25
25
  export * from "./token.js";
26
26
  export { logEvent } from "./log.js";
27
+ export { flushStats, type StatEvent } from "./stats.js";
package/dist/index.js CHANGED
@@ -19,3 +19,5 @@ export * from "./rate_limit.js";
19
19
  export * from "./session.js";
20
20
  export * from "./token.js";
21
21
  export { logEvent } from "./log.js";
22
+ // flushStats 供消费方测试等待异步写入落定;生产路径走 waitUntil,无需调用
23
+ export { flushStats } from "./stats.js";
@@ -2,6 +2,7 @@ import { trimmedField } from "../body.js";
2
2
  import { createAuthMiddleware } from "../middleware.js";
3
3
  import { generateRefreshToken as randomToken } from "../session.js";
4
4
  import { createSession } from "../session.js";
5
+ import { createTracker } from "../stats.js";
5
6
  import { signAccessToken, ACCESS_TTL_SECONDS } from "../token.js";
6
7
  const GITHUB_AUTHORIZE = "https://github.com/login/oauth/authorize";
7
8
  const GITHUB_TOKEN = "https://github.com/login/oauth/access_token";
@@ -122,6 +123,7 @@ export function registerGithubOauth(auth, getConfig, basePath) {
122
123
  const cfg = (c) => getConfig(c.env);
123
124
  const github = (c) => cfg(c).socials?.github;
124
125
  const authMiddleware = createAuthMiddleware(getConfig);
126
+ const track = createTracker(getConfig);
125
127
  const callbackUrlFor = (c, gh) => gh.callbackUrl ??
126
128
  `${new URL(c.req.url).origin}${basePath}/oauth/github/callback`;
127
129
  auth.get("/oauth/github/start", async (c) => {
@@ -130,13 +132,20 @@ export function registerGithubOauth(auth, getConfig, basePath) {
130
132
  return c.json({ error: "not_configured" }, 404);
131
133
  const redirect = c.req.query("redirect") ?? "";
132
134
  if (!redirect || !redirectAllowed(redirect, gh)) {
135
+ track(c, {
136
+ event: "oauth_start",
137
+ outcome: "invalid_redirect",
138
+ provider: "github",
139
+ });
133
140
  return c.json({ error: "invalid_redirect" }, 400);
134
141
  }
135
142
  const state = randomToken();
136
- const record = { redirect };
143
+ const flowId = crypto.randomUUID();
144
+ const record = { redirect, flowId };
137
145
  await cfg(c).kv.put(`oauth:state:${state}`, JSON.stringify(record), {
138
146
  expirationTtl: STATE_TTL_SECONDS,
139
147
  });
148
+ track(c, { event: "oauth_start", outcome: "ok", provider: "github", flowId });
140
149
  return c.redirect(buildAuthorizeUrl(gh, callbackUrlFor(c, gh), state), 302);
141
150
  });
142
151
  // 已登录用户绑定第二身份。**必须是 POST**:浏览器导航带不了 Authorization 头,
@@ -151,13 +160,33 @@ export function registerGithubOauth(auth, getConfig, basePath) {
151
160
  .catch(() => ({}));
152
161
  const redirect = trimmedField(body.redirect);
153
162
  if (!redirect || !redirectAllowed(redirect, gh)) {
163
+ track(c, {
164
+ event: "oauth_start",
165
+ outcome: "invalid_redirect",
166
+ provider: "github",
167
+ meta: { mode: "link" },
168
+ });
154
169
  return c.json({ error: "invalid_redirect" }, 400);
155
170
  }
156
171
  const state = randomToken();
157
- const record = { redirect, mode: "link", userId: c.get("userId") };
172
+ const flowId = crypto.randomUUID();
173
+ const record = {
174
+ redirect,
175
+ mode: "link",
176
+ userId: c.get("userId"),
177
+ flowId,
178
+ };
158
179
  await cfg(c).kv.put(`oauth:state:${state}`, JSON.stringify(record), {
159
180
  expirationTtl: STATE_TTL_SECONDS,
160
181
  });
182
+ track(c, {
183
+ event: "oauth_start",
184
+ outcome: "ok",
185
+ provider: "github",
186
+ userId: c.get("userId"),
187
+ flowId,
188
+ meta: { mode: "link" },
189
+ });
161
190
  return c.json({ authorizeUrl: buildAuthorizeUrl(gh, callbackUrlFor(c, gh), state) }, 200);
162
191
  });
163
192
  auth.get("/oauth/github/callback", async (c) => {
@@ -169,14 +198,30 @@ export function registerGithubOauth(auth, getConfig, basePath) {
169
198
  const stateKey = `oauth:state:${state}`;
170
199
  const rawState = state ? await cfg(c).kv.get(stateKey) : null;
171
200
  if (!code || !rawState) {
172
- // state 无效即回跳地址不可信,只能就地报错
201
+ // state 无效即回跳地址不可信,只能就地报错。
202
+ // 此分支拿不到 flowId——它就存在读不出来的那条 state 记录里。
203
+ track(c, {
204
+ event: "oauth_callback",
205
+ outcome: "invalid_state",
206
+ provider: "github",
207
+ });
173
208
  return c.json({ error: "invalid_state" }, 400);
174
209
  }
175
210
  await cfg(c).kv.delete(stateKey); // 单次使用,验证即焚
176
- const { redirect, mode, userId } = JSON.parse(rawState);
211
+ const { redirect, mode, userId, flowId } = JSON.parse(rawState);
212
+ const trackCallback = (outcome, meta) => track(c, {
213
+ event: "oauth_callback",
214
+ outcome,
215
+ provider: "github",
216
+ ...(flowId ? { flowId } : {}),
217
+ ...(userId ? { userId } : {}),
218
+ meta: { mode: mode ?? "login", ...meta },
219
+ });
177
220
  const identity = await fetchGithubIdentity(gh, code);
178
- if (!identity)
221
+ if (!identity) {
222
+ trackCallback("oauth_failed");
179
223
  return c.redirect(withParam(redirect, "error", "oauth_failed"), 302);
224
+ }
180
225
  const { ghUser, ghToken, email, verifiedEmails } = identity;
181
226
  const userAgent = c.req.header("User-Agent") ?? undefined;
182
227
  const ip = c.req.header("CF-Connecting-IP") ?? undefined;
@@ -193,6 +238,7 @@ export function registerGithubOauth(auth, getConfig, basePath) {
193
238
  const onLinked = cfg(c).onLinked;
194
239
  // userId 由 link/start 写入,缺失只可能是载荷被篡改/降级,保守失败
195
240
  if (!onLinked || !userId) {
241
+ trackCallback("internal");
196
242
  return c.redirect(withParam(redirect, "error", "internal"), 302);
197
243
  }
198
244
  let result;
@@ -201,22 +247,29 @@ export function registerGithubOauth(auth, getConfig, basePath) {
201
247
  result = await onLinked({ userId, ...common, ...(email ? { email } : {}) });
202
248
  }
203
249
  catch {
250
+ trackCallback("internal");
204
251
  return c.redirect(withParam(redirect, "error", "internal"), 302);
205
252
  }
206
253
  if (!result.ok) {
207
254
  const reason = REASON_PATTERN.test(result.reason) ? result.reason : "internal";
255
+ // 冲突原因分布是消费方业务规则的体检表(E2)
256
+ trackCallback("link_conflict", { reason });
208
257
  return c.redirect(withParam(redirect, "error", reason), 302);
209
258
  }
259
+ trackCallback("linked");
210
260
  return c.redirect(withParam(redirect, "linked", "github"), 302);
211
261
  }
212
262
  // ---- login 分支:email 是账号锚点,缺了就无法找号建号 ----
213
- if (!email)
263
+ if (!email) {
264
+ trackCallback("no_email");
214
265
  return c.redirect(withParam(redirect, "error", "oauth_no_email"), 302);
266
+ }
215
267
  let verified;
216
268
  try {
217
269
  verified = await cfg(c).onVerified({ email, ...common });
218
270
  }
219
271
  catch {
272
+ trackCallback("internal");
220
273
  return c.redirect(withParam(redirect, "error", "internal"), 302);
221
274
  }
222
275
  const refreshTtlMs = cfg(c).session?.refreshTtlMs ?? null;
@@ -233,10 +286,22 @@ export function registerGithubOauth(auth, getConfig, basePath) {
233
286
  refreshToken,
234
287
  ...(verified.isNewUser !== undefined ? { isNewUser: verified.isNewUser } : {}),
235
288
  ...(verified.user !== undefined ? { user: verified.user } : {}),
289
+ userId: verified.userId,
290
+ ...(flowId ? { flowId } : {}),
236
291
  };
237
292
  await cfg(c).kv.put(`oauth:otc:${otc}`, JSON.stringify(payload), {
238
293
  expirationTtl: OTC_TTL_SECONDS,
239
294
  });
295
+ // 服务端签发成功 ≠ 登录成功:客户端可能永远收不到这次回跳(回跳丢失 = D11)
296
+ track(c, {
297
+ event: "oauth_callback",
298
+ outcome: "issued",
299
+ provider: "github",
300
+ userId: verified.userId,
301
+ ...(flowId ? { flowId } : {}),
302
+ ...(verified.isNewUser !== undefined ? { isNewUser: verified.isNewUser } : {}),
303
+ meta: { mode: "login" },
304
+ });
240
305
  return c.redirect(withParam(redirect, "otc", otc), 302);
241
306
  });
242
307
  auth.post("/oauth/exchange", async (c) => {
@@ -249,9 +314,37 @@ export function registerGithubOauth(auth, getConfig, basePath) {
249
314
  const otc = trimmedField(body.otc);
250
315
  const key = `oauth:otc:${otc}`;
251
316
  const raw = otc ? await cfg(c).kv.get(key) : null;
252
- if (!raw)
317
+ if (!raw) {
318
+ // otc 过期(60s)或已被兑换;与「回跳压根没到达」是两回事,故分开记
319
+ track(c, {
320
+ event: "oauth_exchange",
321
+ outcome: "invalid_otc",
322
+ provider: "github",
323
+ });
253
324
  return c.json({ error: "invalid_otc" }, 400);
325
+ }
254
326
  await cfg(c).kv.delete(key); // 单次使用,兑换即焚
255
- return c.json(JSON.parse(raw), 200);
327
+ // 解构即剥离:payload 的类型收窄回 OtcPayload,统计字段进不了响应体
328
+ const { userId, flowId, ...payload } = JSON.parse(raw);
329
+ const trace = {
330
+ ...(userId ? { userId } : {}),
331
+ ...(flowId ? { flowId } : {}),
332
+ };
333
+ track(c, {
334
+ event: "oauth_exchange",
335
+ outcome: "ok",
336
+ provider: "github",
337
+ ...trace,
338
+ });
339
+ // 「登录成功」的口径落点在此而非 callback:这里才是客户端真正拿到 token 对的时刻。
340
+ // 顺带一个好处:此处的 country 来自客户端自己的网络,而非浏览器(授权页往往
341
+ // 挂着代理),比 callback 的国家更接近用户真实位置。
342
+ track(c, {
343
+ event: "login",
344
+ provider: "github",
345
+ ...trace,
346
+ ...(payload.isNewUser !== undefined ? { isNewUser: payload.isNewUser } : {}),
347
+ });
348
+ return c.json(payload, 200);
256
349
  });
257
350
  }
@@ -1,6 +1,11 @@
1
1
  export interface RateLimitResult {
2
2
  allowed: boolean;
3
3
  retryAfterSeconds: number;
4
+ /**
5
+ * 被哪一层挡下(1.4.0 起,统计用);allowed 时缺省。
6
+ * 显式给出而非由 retryAfterSeconds 反推——那三个值改一次,反推就错一次。
7
+ */
8
+ layer?: "cooldown" | "email" | "ip";
4
9
  }
5
10
  export declare function checkSendRateLimit(kv: KVNamespace, email: string, ip: string): Promise<RateLimitResult>;
6
11
  export declare function recordSend(kv: KVNamespace, email: string, ip: string): Promise<void>;
@@ -6,15 +6,15 @@ const IP_MAX_IN_WINDOW = 10;
6
6
  export async function checkSendRateLimit(kv, email, ip) {
7
7
  const cooldown = await kv.get(`cooldown:${email}`);
8
8
  if (cooldown) {
9
- return { allowed: false, retryAfterSeconds: COOLDOWN_SECONDS };
9
+ return { allowed: false, retryAfterSeconds: COOLDOWN_SECONDS, layer: "cooldown" };
10
10
  }
11
11
  const emailCount = parseInt((await kv.get(`rl:email:${email}`)) ?? "0", 10);
12
12
  if (emailCount >= EMAIL_MAX_IN_WINDOW) {
13
- return { allowed: false, retryAfterSeconds: EMAIL_WINDOW_SECONDS };
13
+ return { allowed: false, retryAfterSeconds: EMAIL_WINDOW_SECONDS, layer: "email" };
14
14
  }
15
15
  const ipCount = parseInt((await kv.get(`rl:ip:${ip}`)) ?? "0", 10);
16
16
  if (ipCount >= IP_MAX_IN_WINDOW) {
17
- return { allowed: false, retryAfterSeconds: IP_WINDOW_SECONDS };
17
+ return { allowed: false, retryAfterSeconds: IP_WINDOW_SECONDS, layer: "ip" };
18
18
  }
19
19
  return { allowed: true, retryAfterSeconds: 0 };
20
20
  }
@@ -0,0 +1,28 @@
1
+ import type { LoginConfig } from "./config.js";
2
+ export interface StatEvent {
3
+ event: string;
4
+ outcome?: string;
5
+ provider?: "email" | "github";
6
+ userId?: string;
7
+ /** 串联 OAuth 三段的标识;绝不可用 state / otc 充当(单次凭证不进长期表) */
8
+ flowId?: string;
9
+ isNewUser?: boolean;
10
+ /** 落 meta 列(JSON),并摊平进 onEvent */
11
+ meta?: Record<string, unknown>;
12
+ /** 只摊平进 onEvent,**不落表**(ip 等 v1 判定不入库的字段走这里) */
13
+ hookOnly?: Record<string, unknown>;
14
+ }
15
+ export declare function flushStats(): Promise<void>;
16
+ export interface TrackContext {
17
+ env: unknown;
18
+ req: {
19
+ raw: Request;
20
+ };
21
+ /** Hono 在无 ExecutionContext 时访问此属性会抛,故所有读取都包在 try 内 */
22
+ executionCtx?: ExecutionContext;
23
+ }
24
+ /**
25
+ * 事件出口:先照原样喂 onEvent(消费方钩子,形态与 1.3.0 保持一致),
26
+ * 再异步写自己的表。两条路径并行——onEvent 是给消费方的,不被库劫持去写库表。
27
+ */
28
+ export declare function createTracker<TEnv>(getConfig: (env: TEnv) => LoginConfig): (c: TrackContext, e: StatEvent) => void;
package/dist/stats.js ADDED
@@ -0,0 +1,94 @@
1
+ import { logEvent } from "./log.js";
2
+ /** 供测试等待异步写入完成;生产路径走 waitUntil,不依赖它 */
3
+ const pending = new Set();
4
+ export async function flushStats() {
5
+ await Promise.allSettled([...pending]);
6
+ }
7
+ // 按 config 对象记忆化(同 email.ts 的 warnEmailConfigOnce):生产上 config 由
8
+ // memoizeResolver 按 env 缓存,等价于每个 Worker 一次告警。
9
+ const warnedConfigs = new WeakSet();
10
+ const GEO_ABSENT = {
11
+ country: "unknown",
12
+ asn: null,
13
+ colo: null,
14
+ timezone: null,
15
+ city: null,
16
+ region: null,
17
+ };
18
+ /**
19
+ * 全部取自 Cloudflare 边缘的 `request.cf`——它在请求到达 Worker 前就已填好,
20
+ * 零外部依赖、无额外请求。本地 wrangler dev 与测试环境没有 cf,故整体兜底。
21
+ * 注意这是 **IP 归属地**,不是用户声明的位置:代理会显示出口所在地。
22
+ */
23
+ function geoOf(c) {
24
+ try {
25
+ const cf = c.req.raw.cf;
26
+ if (!cf)
27
+ return GEO_ABSENT;
28
+ const str = (v) => (typeof v === "string" && v ? v : null);
29
+ return {
30
+ country: str(cf.country) ?? "unknown",
31
+ asn: typeof cf.asn === "number" ? cf.asn : null,
32
+ colo: str(cf.colo),
33
+ timezone: str(cf.timezone),
34
+ city: str(cf.city),
35
+ region: str(cf.region),
36
+ };
37
+ }
38
+ catch {
39
+ return GEO_ABSENT;
40
+ }
41
+ }
42
+ function defer(c, p) {
43
+ const tracked = p.finally(() => pending.delete(tracked));
44
+ pending.add(tracked);
45
+ try {
46
+ c.executionCtx?.waitUntil(tracked);
47
+ }
48
+ catch {
49
+ // 无 ExecutionContext(如 app.request() 直调):写入照常进行,只是不被延长生命周期
50
+ }
51
+ }
52
+ async function writeEvent(db, e, geo, now) {
53
+ await db
54
+ .prepare(`INSERT INTO auth_events
55
+ (at, event, outcome, provider, user_id, flow_id, is_new_user,
56
+ country, asn, colo, timezone, city, region, source, meta)
57
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'server', ?)`)
58
+ .bind(now, e.event, e.outcome ?? null, e.provider ?? null, e.userId ?? null, e.flowId ?? null, e.isNewUser === undefined ? null : e.isNewUser ? 1 : 0, geo.country, geo.asn, geo.colo, geo.timezone, geo.city, geo.region, e.meta ? JSON.stringify(e.meta) : null)
59
+ .run();
60
+ }
61
+ /**
62
+ * 事件出口:先照原样喂 onEvent(消费方钩子,形态与 1.3.0 保持一致),
63
+ * 再异步写自己的表。两条路径并行——onEvent 是给消费方的,不被库劫持去写库表。
64
+ */
65
+ export function createTracker(getConfig) {
66
+ return (c, e) => {
67
+ const cfg = getConfig(c.env);
68
+ const onEvent = cfg.onEvent ?? logEvent;
69
+ onEvent({
70
+ event: e.event,
71
+ ...(e.outcome !== undefined ? { outcome: e.outcome } : {}),
72
+ ...(e.provider !== undefined ? { provider: e.provider } : {}),
73
+ ...(e.userId !== undefined ? { userId: e.userId } : {}),
74
+ ...(e.flowId !== undefined ? { flowId: e.flowId } : {}),
75
+ ...(e.isNewUser !== undefined ? { isNewUser: e.isNewUser } : {}),
76
+ ...e.meta,
77
+ ...e.hookOnly,
78
+ });
79
+ if (cfg.stats?.enabled === false)
80
+ return;
81
+ defer(c, writeEvent(cfg.db, e, geoOf(c), Date.now()).catch((err) => warnUnavailableOnce(cfg, onEvent, err)));
82
+ };
83
+ }
84
+ /** 最可能的原因是没执行 migration 0002。只告警一次,避免每请求刷屏。 */
85
+ function warnUnavailableOnce(cfg, onEvent, err) {
86
+ if (warnedConfigs.has(cfg))
87
+ return;
88
+ warnedConfigs.add(cfg);
89
+ onEvent({
90
+ event: "stats_unavailable",
91
+ hint: "auth_events 写入失败,请执行 migration 0002;登录不受影响",
92
+ message: String(err),
93
+ });
94
+ }
@@ -0,0 +1,26 @@
1
+ -- 登录统计事件表(docs/stats-design.md v1)。归库所有,与 sessions 同库。
2
+ -- 升级到 1.4.0 后必须执行本迁移:统计模块默认开启,表不存在时写入会失败
3
+ -- (登录不受影响,会有一次 stats_unavailable 告警事件)。
4
+ CREATE TABLE IF NOT EXISTS auth_events (
5
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
6
+ at INTEGER NOT NULL, -- UTC 毫秒
7
+ event TEXT NOT NULL,
8
+ outcome TEXT,
9
+ provider TEXT, -- email | github
10
+ user_id TEXT,
11
+ flow_id TEXT, -- 串联 OAuth 三段,非凭证
12
+ is_new_user INTEGER, -- 0 | 1 | NULL
13
+ -- 地理字段:全部取自 Cloudflare 边缘的 request.cf,零外部依赖、无额外请求。
14
+ -- 是 IP 归属地而非用户声明位置,代理会显示出口所在地(见 stats-design.md K 组坑①)。
15
+ country TEXT, -- ISO 3166-1 两位码,缺失落 'unknown'
16
+ asn INTEGER, -- 自治域号,识别家宽 vs 云出口(K9)
17
+ colo TEXT, -- Cloudflare 边缘节点码;与 country 不一致 = 流量绕道
18
+ timezone TEXT, -- IANA 时区名
19
+ city TEXT, -- 常为空;当前无指标使用(见 6.11)
20
+ region TEXT, -- 同上
21
+ source TEXT NOT NULL DEFAULT 'server', -- server | client(client 留给二期)
22
+ meta TEXT -- JSON:familyId、locale、错误串等低频字段
23
+ );
24
+ CREATE INDEX IF NOT EXISTS idx_auth_events_at ON auth_events(at);
25
+ CREATE INDEX IF NOT EXISTS idx_auth_events_event_at ON auth_events(event, at);
26
+ CREATE INDEX IF NOT EXISTS idx_auth_events_flow ON auth_events(flow_id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loginbase",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Shared login foundation for Cloudflare Workers: email OTP + social OAuth + session management, as a Hono sub-app factory.",
5
5
  "type": "module",
6
6
  "license": "MIT",