loginbase 1.2.0 → 1.3.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/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,7 +3,7 @@
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";
@@ -36,12 +36,24 @@ export function createAuthApp(getConfig, basePath) {
36
36
  return c.json({ error: "too_many_requests", retryAfterSeconds: rl.retryAfterSeconds }, 429);
37
37
  }
38
38
  const code = generateCode();
39
+ const emitEvent = emit(c);
40
+ warnEmailConfigOnce(cfg(c).email, emitEvent);
41
+ const locale = resolveEmailLocale(cfg(c).email, body.locale);
39
42
  try {
40
- await sendCodeEmail(cfg(c).email, raw, code);
43
+ await sendCodeEmail(cfg(c).email, raw, code, locale.locale);
41
44
  }
42
45
  catch {
43
46
  return c.json({ error: "internal" }, 500);
44
47
  }
48
+ // 静默回落意味着「为什么收到英文邮件」在别处查不出来,故选中语言必须留痕
49
+ emitEvent({
50
+ event: "code_sent",
51
+ locale: {
52
+ resolved: locale.locale,
53
+ ...(locale.requested ? { requested: locale.requested } : {}),
54
+ ...(locale.fallback ? { fallback: true } : {}),
55
+ },
56
+ });
45
57
  await storeCode(cfg(c).kv, raw, code);
46
58
  await recordSend(cfg(c).kv, raw, ip);
47
59
  return c.json({ cooldownSeconds: 60 }, 200);
@@ -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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loginbase",
3
- "version": "1.2.0",
3
+ "version": "1.3.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",