loginbase 1.2.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/README.md CHANGED
@@ -53,5 +53,5 @@ Kotlin 包 wang.harlon.loginbase
53
53
 
54
54
  ## 设计红线
55
55
 
56
- - 依赖最小集:服务端 hono + jose(+ zod-validator),客户端 ktor + kotlinx-serialization + multiplatform-settings。auth 库是供应链攻击的最高价值目标,每加一个依赖都要过一遍这个念头
56
+ - 依赖最小集:服务端 hono + jose(+ zod-validator),客户端 ktor-client-core + kotlinx-serialization-json + kotlinx-coroutines-core。auth 库是供应链攻击的最高价值目标,每加一个依赖都要过一遍这个念头
57
57
  - 协议变更:服务端实现 + `docs/protocol.md` 同一个 commit,并在 `loginbase-kt` 仓开跟进 issue,客户端版本落地前不关(2026-08-13 由 monorepo 三位一体改判,理由见 design.md)
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/email.d.ts CHANGED
@@ -1,17 +1,70 @@
1
- export interface EmailTemplates {
2
- subject(code: string): string;
3
- html(code: string): string;
4
- text(code: string): string;
1
+ /** 模板渲染上下文(2.0.0:由裸 code 参数改为 ctx 对象) */
2
+ export interface TemplateContext {
3
+ code: string;
4
+ brand?: string;
5
+ /** 本封邮件的「选中语言」——已归一化的 BCP 47 标签 */
6
+ locale: string;
7
+ /** 收件地址(已归一化小写) */
8
+ email: string;
9
+ /** 验证码有效期,解开「10 分钟」写死在文案里的约定耦合 */
10
+ ttlMinutes: number;
11
+ }
12
+ export type TemplatePart = (ctx: TemplateContext) => string;
13
+ /** 三件皆可选:内置已有的语言可以只覆盖其中一件,其余由内置同语言补齐 */
14
+ export interface EmailTemplate {
15
+ subject?: TemplatePart;
16
+ html?: TemplatePart;
17
+ text?: TemplatePart;
5
18
  }
6
19
  export interface EmailConfig {
7
20
  resendApiKey: string;
8
21
  from: string;
9
- /** 品牌名,注入内置模板的标题与正文;不影响 templates 整体覆盖 */
22
+ /** 品牌名,经 ctx.brand 送达内置模板与消费方模板 */
10
23
  brand?: string;
11
- /** 内置模板语言,默认 "en" */
12
- locale?: "en" | "zh";
13
- /** 整体覆盖模板;提供时 brand/locale 不生效 */
14
- templates?: EmailTemplates;
24
+ /**
25
+ * 客户端没说时用哪个语言(2.0.0:原 `locale`,语义由「本 App 邮件语言」正名为
26
+ * 「兜底语言」)。默认 "en";配了库不支持的语言则回落 "en" 并告警。
27
+ */
28
+ fallbackLocale?: string;
29
+ /** 按 locale 键:覆盖内置语言的任意部件,或新增内置没有的语言(须三件齐全) */
30
+ templates?: Record<string, EmailTemplate>;
31
+ }
32
+ /**
33
+ * BCP 47 标签归一化:`_`→`-`(Android `Locale.toString()` 给的是 `zh_CN`,
34
+ * 只有 `toLanguageTag()` 才是 BCP 47)、转小写(大小写不敏感)、截断 64。
35
+ *
36
+ * 非字符串、空串、含 `[a-z0-9-]` 以外字符者一律返回 null=「视为未传」:
37
+ * 这类值本就匹配不到任何语言,行为与未传等价,顺便把垃圾挡在事件日志之外。
38
+ *
39
+ * `und`(BCP 47 的「未确定语言」)同样归为未传——客户端取不到平台语言时可能传它,
40
+ * 若当成普通标签处理,它会匹配失败并留下 `fallback: true`,把「取不到语言」伪装成
41
+ * 「要了一门不支持的语言」,污染观测(见 plan.md 的哨兵口径)。
42
+ */
43
+ export declare function normalizeLocale(value: unknown): string | null;
44
+ export interface LocaleResolution {
45
+ /** 选中语言 */
46
+ locale: string;
47
+ /** 客户端传来并通过归一化的值(未传/非法时缺省) */
48
+ requested?: string;
49
+ /** true = 客户端要的语言没给到(未传不算回落) */
50
+ fallback: boolean;
15
51
  }
16
- export declare function resolveTemplates(config: EmailConfig): EmailTemplates;
17
- export declare function sendCodeEmail(config: EmailConfig, email: string, code: string): Promise<void>;
52
+ /**
53
+ * 规则 ①:语言只解析一次——请求 fallbackLocale 库内置 en,第一个命中者胜。
54
+ * **静默回落,永不 4xx**:语言是展示偏好不是凭据,一个 4xx 会让用户登不进去。
55
+ */
56
+ export declare function resolveEmailLocale(config: EmailConfig, requested: unknown): LocaleResolution;
57
+ /**
58
+ * 规则 ②:模板在选中语言内部合并——内置打底,消费方逐部件覆盖,永不跨语言。
59
+ * (末尾的内置兜底只为防御式直调;走 resolveEmailLocale 的路径上取不到它。)
60
+ */
61
+ export declare function resolveTemplate(config: EmailConfig, locale: string): Required<EmailTemplate>;
62
+ /**
63
+ * 配置层面的问题清单(纯函数,便于测试):
64
+ * - `incomplete`:给了内置没有的语言却没写全三件——他的模板一次都不会被用上,
65
+ * 而配置语法完全合法、从代码里看不出来,不报就只能靠用户投诉发现;
66
+ * - `unsupported_fallback`:兜底语言自己都不在支持集里,实际会用库内置 en。
67
+ */
68
+ export declare function emailConfigWarnings(config: EmailConfig): Record<string, unknown>[];
69
+ export declare function warnEmailConfigOnce(config: EmailConfig, emit: (event: Record<string, unknown>) => void): void;
70
+ export declare function sendCodeEmail(config: EmailConfig, email: string, code: string, locale?: string): Promise<void>;
package/dist/email.js CHANGED
@@ -1,19 +1,178 @@
1
- import { enTemplates } from "./templates/en.js";
2
- import { zhTemplates } from "./templates/zh.js";
1
+ import { CODE_TTL_SECONDS } from "./code.js";
2
+ import { enTemplate } from "./templates/en.js";
3
+ import { zhTemplate } from "./templates/zh.js";
3
4
  const RESEND_ENDPOINT = "https://api.resend.com/emails";
4
- export function resolveTemplates(config) {
5
- if (config.templates)
6
- return config.templates;
7
- return config.locale === "zh" ? zhTemplates(config.brand) : enTemplates(config.brand);
5
+ const BUILTIN = {
6
+ en: enTemplate,
7
+ zh: zhTemplate,
8
+ };
9
+ const BUILTIN_FALLBACK_LOCALE = "en";
10
+ const MAX_LOCALE_LENGTH = 64;
11
+ const PARTS = ["subject", "html", "text"];
12
+ /**
13
+ * BCP 47 标签归一化:`_`→`-`(Android `Locale.toString()` 给的是 `zh_CN`,
14
+ * 只有 `toLanguageTag()` 才是 BCP 47)、转小写(大小写不敏感)、截断 64。
15
+ *
16
+ * 非字符串、空串、含 `[a-z0-9-]` 以外字符者一律返回 null=「视为未传」:
17
+ * 这类值本就匹配不到任何语言,行为与未传等价,顺便把垃圾挡在事件日志之外。
18
+ *
19
+ * `und`(BCP 47 的「未确定语言」)同样归为未传——客户端取不到平台语言时可能传它,
20
+ * 若当成普通标签处理,它会匹配失败并留下 `fallback: true`,把「取不到语言」伪装成
21
+ * 「要了一门不支持的语言」,污染观测(见 plan.md 的哨兵口径)。
22
+ */
23
+ export function normalizeLocale(value) {
24
+ if (typeof value !== "string")
25
+ return null;
26
+ const tag = value.trim().slice(0, MAX_LOCALE_LENGTH).replace(/_/g, "-").toLowerCase();
27
+ if (tag === "" || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(tag))
28
+ return null;
29
+ if (tag === "und" || tag.startsWith("und-"))
30
+ return null;
31
+ return tag;
8
32
  }
9
- export async function sendCodeEmail(config, email, code) {
10
- const templates = resolveTemplates(config);
33
+ // 消费方模板的键同样要归一化(他可能写 "zh-Hant"),按 templates 对象记忆化
34
+ const indexCache = new WeakMap();
35
+ function customIndex(config) {
36
+ const src = config.templates;
37
+ if (!src)
38
+ return new Map();
39
+ const cached = indexCache.get(src);
40
+ if (cached)
41
+ return cached;
42
+ const index = new Map();
43
+ for (const [key, template] of Object.entries(src)) {
44
+ const tag = normalizeLocale(key);
45
+ if (tag && template)
46
+ index.set(tag, template);
47
+ }
48
+ indexCache.set(src, index);
49
+ return index;
50
+ }
51
+ function isComplete(template) {
52
+ return !!template && PARTS.every((p) => typeof template[p] === "function");
53
+ }
54
+ /**
55
+ * 支持集 = 库内置语言 ∪ { 消费方写全三件的语言 }。
56
+ *
57
+ * 判据不是「消费方写了几件」,而是「缺的几件能不能用**同一种语言**补上」:
58
+ * 内置已有的语言永远补得上,故部分覆盖合法;内置没有的语言只有消费方一个来源,
59
+ * 不写全就没人能补——此时判定该语言不成立,整封回落兜底语言(见 server-design.md
60
+ * 「为什么禁止跨语言混搭」)。
61
+ */
62
+ function supports(config, locale) {
63
+ return locale in BUILTIN || isComplete(customIndex(config).get(locale));
64
+ }
65
+ /**
66
+ * RFC 4647 Lookup 简化版:逐级砍子标签(`zh-hans-cn`→`zh-hans`→`zh`),
67
+ * 不一步截到主语言——将来加繁体模板时 `zh-Hant` 才不会掉进简体。
68
+ */
69
+ function lookup(tag, has) {
70
+ let current = tag;
71
+ for (;;) {
72
+ if (has(current))
73
+ return current;
74
+ const cut = current.lastIndexOf("-");
75
+ if (cut < 0)
76
+ return null;
77
+ current = current.slice(0, cut);
78
+ }
79
+ }
80
+ /**
81
+ * 规则 ①:语言只解析一次——请求 → fallbackLocale → 库内置 en,第一个命中者胜。
82
+ * **静默回落,永不 4xx**:语言是展示偏好不是凭据,一个 4xx 会让用户登不进去。
83
+ */
84
+ export function resolveEmailLocale(config, requested) {
85
+ const has = (locale) => supports(config, locale);
86
+ const req = normalizeLocale(requested);
87
+ if (req) {
88
+ const hit = lookup(req, has);
89
+ if (hit)
90
+ return { locale: hit, requested: req, fallback: false };
91
+ }
92
+ const configured = normalizeLocale(config.fallbackLocale);
93
+ const locale = (configured ? lookup(configured, has) : null) ?? BUILTIN_FALLBACK_LOCALE;
94
+ return req
95
+ ? { locale, requested: req, fallback: true }
96
+ : { locale, fallback: false };
97
+ }
98
+ /**
99
+ * 规则 ②:模板在选中语言内部合并——内置打底,消费方逐部件覆盖,永不跨语言。
100
+ * (末尾的内置兜底只为防御式直调;走 resolveEmailLocale 的路径上取不到它。)
101
+ */
102
+ export function resolveTemplate(config, locale) {
103
+ const custom = customIndex(config).get(locale);
104
+ const builtin = BUILTIN[locale];
105
+ const pick = (part) => custom?.[part] ?? builtin?.[part] ?? BUILTIN[BUILTIN_FALLBACK_LOCALE][part];
106
+ return { subject: pick("subject"), html: pick("html"), text: pick("text") };
107
+ }
108
+ /**
109
+ * 配置层面的问题清单(纯函数,便于测试):
110
+ * - `incomplete`:给了内置没有的语言却没写全三件——他的模板一次都不会被用上,
111
+ * 而配置语法完全合法、从代码里看不出来,不报就只能靠用户投诉发现;
112
+ * - `unsupported_fallback`:兜底语言自己都不在支持集里,实际会用库内置 en。
113
+ */
114
+ export function emailConfigWarnings(config) {
115
+ const warnings = [];
116
+ // 归一化认不出的键(如 "中文"、"zh Hant")会被索引直接丢掉——语法合法、
117
+ // 却永远不会被任何请求命中,是彻底的死配置,不报就只能靠用户投诉发现
118
+ for (const key of Object.keys(config.templates ?? {})) {
119
+ if (normalizeLocale(key) === null) {
120
+ warnings.push({
121
+ event: "email_template_config",
122
+ status: "invalid_locale_key",
123
+ key,
124
+ });
125
+ }
126
+ }
127
+ for (const [locale, template] of customIndex(config)) {
128
+ if (locale in BUILTIN)
129
+ continue; // 内置能同语言补齐,部分覆盖合法
130
+ const missing = PARTS.filter((p) => typeof template[p] !== "function");
131
+ if (missing.length > 0) {
132
+ warnings.push({
133
+ event: "email_template_config",
134
+ status: "incomplete",
135
+ locale,
136
+ missing,
137
+ });
138
+ }
139
+ }
140
+ const configured = normalizeLocale(config.fallbackLocale);
141
+ if (configured && !lookup(configured, (l) => supports(config, l))) {
142
+ warnings.push({
143
+ event: "email_template_config",
144
+ status: "unsupported_fallback",
145
+ locale: configured,
146
+ resolved: BUILTIN_FALLBACK_LOCALE,
147
+ });
148
+ }
149
+ return warnings;
150
+ }
151
+ // 每个 config 对象只报一次(config 按 isolate 记忆化 → 等价于每实例一次)。
152
+ // 不抛错、不阻断:模板配错不该让登录服务起不来,与「静默回落」同一取向。
153
+ const warned = new WeakSet();
154
+ export function warnEmailConfigOnce(config, emit) {
155
+ if (warned.has(config))
156
+ return;
157
+ warned.add(config);
158
+ for (const warning of emailConfigWarnings(config))
159
+ emit(warning);
160
+ }
161
+ export async function sendCodeEmail(config, email, code, locale = BUILTIN_FALLBACK_LOCALE) {
162
+ const template = resolveTemplate(config, locale);
163
+ const ctx = {
164
+ code,
165
+ brand: config.brand,
166
+ locale,
167
+ email,
168
+ ttlMinutes: Math.round(CODE_TTL_SECONDS / 60),
169
+ };
11
170
  const body = {
12
171
  from: config.from,
13
172
  to: [email],
14
- subject: templates.subject(code),
15
- html: templates.html(code),
16
- text: templates.text(code),
173
+ subject: template.subject(ctx),
174
+ html: template.html(ctx),
175
+ text: template.text(ctx),
17
176
  };
18
177
  const res = await fetch(RESEND_ENDPOINT, {
19
178
  method: "POST",
package/dist/handler.js CHANGED
@@ -3,12 +3,13 @@
3
3
  // 事件出口接 onEvent,refreshTtlMs / accessTtlSeconds 可配生效。
4
4
  import { Hono } from "hono";
5
5
  import { generateCode, storeCode, readCode, deleteCode, incrementAttempts, MAX_ATTEMPTS, } from "./code.js";
6
- import { sendCodeEmail } from "./email.js";
6
+ import { sendCodeEmail, resolveEmailLocale, warnEmailConfigOnce, } from "./email.js";
7
7
  import { checkSendRateLimit, recordSend } from "./rate_limit.js";
8
8
  import { createSession, hashRefreshToken, findSession, rotateSession, revokeFamily, tryRescueSession, revokeSession, revokeAllForUser, } from "./session.js";
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,15 +36,35 @@ 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();
45
+ const emitEvent = emit(c);
46
+ warnEmailConfigOnce(cfg(c).email, emitEvent);
47
+ const locale = resolveEmailLocale(cfg(c).email, body.locale);
39
48
  try {
40
- await sendCodeEmail(cfg(c).email, raw, code);
49
+ await sendCodeEmail(cfg(c).email, raw, code, locale.locale);
41
50
  }
42
- catch {
51
+ catch (err) {
52
+ // 发信失败此前不留任何痕迹;邮件是邮箱登录的命脉,断了整条链就没了。
53
+ // Resend 的 HTTP 码在错误串里,聚合时再解析(不为此改造 email.ts 的抛错形态)。
54
+ track(c, { event: "code_send_failed", meta: { message: String(err) } });
43
55
  return c.json({ error: "internal" }, 500);
44
56
  }
57
+ // 静默回落意味着「为什么收到英文邮件」在别处查不出来,故选中语言必须留痕
58
+ track(c, {
59
+ event: "code_sent",
60
+ meta: {
61
+ locale: {
62
+ resolved: locale.locale,
63
+ ...(locale.requested ? { requested: locale.requested } : {}),
64
+ ...(locale.fallback ? { fallback: true } : {}),
65
+ },
66
+ },
67
+ });
45
68
  await storeCode(cfg(c).kv, raw, code);
46
69
  await recordSend(cfg(c).kv, raw, ip);
47
70
  return c.json({ cooldownSeconds: 60 }, 200);
@@ -53,14 +76,19 @@ export function createAuthApp(getConfig, basePath) {
53
76
  const email = trimmedField(body.email).toLowerCase();
54
77
  const code = trimmedField(body.code);
55
78
  const stored = await readCode(cfg(c).kv, email);
56
- if (!stored)
79
+ if (!stored) {
80
+ // 过期、已焚、从未发过、已用过——在 KV 层都表现为读不到,无法再分(C3 口径)
81
+ track(c, { event: "code_verify", outcome: "code_not_found" });
57
82
  return c.json({ error: "code_expired" }, 400);
83
+ }
58
84
  if (stored.code !== code) {
59
85
  const attempts = await incrementAttempts(cfg(c).kv, email, stored);
60
86
  if (attempts >= MAX_ATTEMPTS) {
61
87
  await deleteCode(cfg(c).kv, email);
88
+ track(c, { event: "code_verify", outcome: "too_many_attempts" });
62
89
  return c.json({ error: "too_many_attempts" }, 429);
63
90
  }
91
+ track(c, { event: "code_verify", outcome: "invalid_code" });
64
92
  return c.json({ error: "invalid_code" }, 400);
65
93
  }
66
94
  await deleteCode(cfg(c).kv, email);
@@ -77,6 +105,7 @@ export function createAuthApp(getConfig, basePath) {
77
105
  });
78
106
  }
79
107
  catch {
108
+ track(c, { event: "code_verify", outcome: "internal" });
80
109
  return c.json({ error: "internal" }, 500);
81
110
  }
82
111
  const { sessionId, refreshToken } = await createSession(cfg(c).db, {
@@ -86,6 +115,15 @@ export function createAuthApp(getConfig, basePath) {
86
115
  expiresAt: sessionExpiry(c),
87
116
  });
88
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
+ });
89
127
  return c.json({
90
128
  accessToken,
91
129
  refreshToken,
@@ -99,11 +137,17 @@ export function createAuthApp(getConfig, basePath) {
99
137
  .catch(() => ({}));
100
138
  const token = trimmedField(body.refreshToken);
101
139
  if (!token) {
140
+ track(c, { event: "refresh", outcome: "invalid", meta: { reason: "missing_token" } });
102
141
  return c.json({ error: "invalid_refresh_token", reason: "missing_token" }, 401);
103
142
  }
104
143
  const sessionId = await hashRefreshToken(token);
105
144
  const row = await findSession(cfg(c).db, sessionId);
106
145
  if (!row) {
146
+ track(c, {
147
+ event: "refresh",
148
+ outcome: "invalid",
149
+ meta: { reason: "session_not_found" },
150
+ });
107
151
  return c.json({ error: "invalid_refresh_token", reason: "session_not_found" }, 401);
108
152
  }
109
153
  if (row.revoked_at !== null) {
@@ -112,37 +156,56 @@ export function createAuthApp(getConfig, basePath) {
112
156
  const ip = c.req.header("CF-Connecting-IP") ?? undefined;
113
157
  const rescue = await tryRescueSession(cfg(c).db, row, { userAgent, ip }, sessionExpiry(c));
114
158
  if (rescue.status === "rescued") {
115
- emit(c)({
159
+ track(c, {
116
160
  event: "refresh",
117
161
  outcome: "rescued",
118
162
  userId: row.user_id,
119
- familyId: row.family_id,
120
- ip: ip ?? null,
163
+ meta: { familyId: row.family_id },
164
+ hookOnly: { ip: ip ?? null },
121
165
  });
122
166
  const accessToken = await signAccessToken(cfg(c).jwt.secret, row.user_id, rescue.session.sessionId, accessTtl(c));
123
167
  return c.json({ accessToken, refreshToken: rescue.session.refreshToken });
124
168
  }
125
169
  // 救活不成立 → 判定真重用,撤销整条会话链。guardrail 与 not_eligible 分开记录。
126
170
  await revokeFamily(cfg(c).db, row.family_id);
127
- emit(c)({
171
+ track(c, {
128
172
  event: "refresh",
129
173
  outcome: rescue.status === "guardrail" ? "guardrail_revoked" : "reuse_revoked",
130
174
  userId: row.user_id,
131
- familyId: row.family_id,
132
- ip: ip ?? null,
175
+ meta: { familyId: row.family_id },
176
+ hookOnly: { ip: ip ?? null },
133
177
  });
134
178
  return c.json({ error: "invalid_refresh_token", reason: "session_revoked" }, 401);
135
179
  }
136
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
+ });
137
187
  return c.json({ error: "invalid_refresh_token", reason: "session_expired" }, 401);
138
188
  }
139
189
  const userAgent = c.req.header("User-Agent") ?? undefined;
140
190
  const ip = c.req.header("CF-Connecting-IP") ?? undefined;
141
191
  const next = await rotateSession(cfg(c).db, sessionId, { userAgent, ip }, sessionExpiry(c));
142
192
  if (!next) {
193
+ track(c, {
194
+ event: "refresh",
195
+ outcome: "invalid",
196
+ userId: row.user_id,
197
+ meta: { reason: "rotate_failed" },
198
+ });
143
199
  return c.json({ error: "invalid_refresh_token", reason: "rotate_failed" }, 401);
144
200
  }
145
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
+ });
146
209
  return c.json({ accessToken, refreshToken: next.refreshToken });
147
210
  });
148
211
  auth.delete("/sessions", authMiddleware, async (c) => {
@@ -150,11 +213,14 @@ export function createAuthApp(getConfig, basePath) {
150
213
  if (!sessionId)
151
214
  return c.json({ error: "unauthorized" }, 401);
152
215
  await revokeSession(cfg(c).db, sessionId);
216
+ // 主动登出与「被轮换而作废」在 sessions 表里长得很像,只有事件能干净区分
217
+ track(c, { event: "session_revoked", outcome: "current", userId: c.get("userId") });
153
218
  return c.body(null, 204);
154
219
  });
155
220
  auth.delete("/sessions/all", authMiddleware, async (c) => {
156
221
  const userId = c.get("userId");
157
222
  await revokeAllForUser(cfg(c).db, userId);
223
+ track(c, { event: "session_revoked", outcome: "all", userId });
158
224
  return c.body(null, 204);
159
225
  });
160
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
+ }
@@ -1,2 +1,2 @@
1
- import type { EmailTemplates } from "../email.js";
2
- export declare function enTemplates(brand?: string): EmailTemplates;
1
+ import type { EmailTemplate } from "../email.js";
2
+ export declare const enTemplate: Required<EmailTemplate>;
@@ -1,15 +1,13 @@
1
1
  // en 模板即 Tono 生产模板的 brand 参数化:brand="Tono" 时输出与平移前逐字节一致。
2
- export function enTemplates(brand) {
3
- const title = brand ? `Your ${brand} verification code` : "Your verification code";
4
- return {
5
- subject: (code) => `${title}: ${code}`,
6
- html: (code) => `
2
+ const title = (brand) => brand ? `Your ${brand} verification code` : "Your verification code";
3
+ export const enTemplate = {
4
+ subject: (ctx) => `${title(ctx.brand)}: ${ctx.code}`,
5
+ html: (ctx) => `
7
6
  <div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;padding:24px;">
8
- <h2 style="margin:0 0 16px 0;">${title}</h2>
9
- <p style="font-size:32px;letter-spacing:8px;font-weight:700;margin:16px 0;">${code}</p>
10
- <p style="color:#666;font-size:14px;">This code expires in 10 minutes. If you didn't request it, you can ignore this email.</p>
7
+ <h2 style="margin:0 0 16px 0;">${title(ctx.brand)}</h2>
8
+ <p style="font-size:32px;letter-spacing:8px;font-weight:700;margin:16px 0;">${ctx.code}</p>
9
+ <p style="color:#666;font-size:14px;">This code expires in ${ctx.ttlMinutes} minutes. If you didn't request it, you can ignore this email.</p>
11
10
  </div>
12
11
  `,
13
- text: (code) => `${title} is ${code}. It expires in 10 minutes.`,
14
- };
15
- }
12
+ text: (ctx) => `${title(ctx.brand)} is ${ctx.code}. It expires in ${ctx.ttlMinutes} minutes.`,
13
+ };
@@ -1,2 +1,2 @@
1
- import type { EmailTemplates } from "../email.js";
2
- export declare function zhTemplates(brand?: string): EmailTemplates;
1
+ import type { EmailTemplate } from "../email.js";
2
+ export declare const zhTemplate: Required<EmailTemplate>;
@@ -1,14 +1,12 @@
1
- export function zhTemplates(brand) {
2
- const title = brand ? `${brand} 登录验证码` : "登录验证码";
3
- return {
4
- subject: (code) => `${title}:${code}`,
5
- html: (code) => `
1
+ const title = (brand) => (brand ? `${brand} 登录验证码` : "登录验证码");
2
+ export const zhTemplate = {
3
+ subject: (ctx) => `${title(ctx.brand)}:${ctx.code}`,
4
+ html: (ctx) => `
6
5
  <div style="font-family:-apple-system,BlinkMacSystemFont,'PingFang SC','Microsoft YaHei',sans-serif;padding:24px;">
7
- <h2 style="margin:0 0 16px 0;">${title}</h2>
8
- <p style="font-size:32px;letter-spacing:8px;font-weight:700;margin:16px 0;">${code}</p>
9
- <p style="color:#666;font-size:14px;">验证码 10 分钟内有效。如果这不是你的操作,请忽略这封邮件。</p>
6
+ <h2 style="margin:0 0 16px 0;">${title(ctx.brand)}</h2>
7
+ <p style="font-size:32px;letter-spacing:8px;font-weight:700;margin:16px 0;">${ctx.code}</p>
8
+ <p style="color:#666;font-size:14px;">验证码 ${ctx.ttlMinutes} 分钟内有效。如果这不是你的操作,请忽略这封邮件。</p>
10
9
  </div>
11
10
  `,
12
- text: (code) => `你的${title}是 ${code},10 分钟内有效。`,
13
- };
14
- }
11
+ text: (ctx) => `你的${title(ctx.brand)}是 ${ctx.code},${ctx.ttlMinutes} 分钟内有效。`,
12
+ };
@@ -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.2.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",