loginbase 0.1.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/LICENSE +21 -0
- package/README.md +53 -0
- package/dist/code.d.ts +12 -0
- package/dist/code.js +28 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.js +15 -0
- package/dist/email.d.ts +5 -0
- package/dist/email.js +34 -0
- package/dist/handler.d.ts +5 -0
- package/dist/handler.js +182 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +21 -0
- package/dist/log.d.ts +1 -0
- package/dist/log.js +5 -0
- package/dist/middleware.d.ts +9 -0
- package/dist/middleware.js +22 -0
- package/dist/rate_limit.d.ts +6 -0
- package/dist/rate_limit.js +31 -0
- package/dist/session.d.ts +49 -0
- package/dist/session.js +111 -0
- package/dist/token.d.ts +8 -0
- package/dist/token.js +15 -0
- package/migrations/0001_sessions.sql +20 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Harlon Wang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# loginbase
|
|
2
|
+
|
|
3
|
+
> The shared login foundation for my apps — email OTP + social OAuth + session management.
|
|
4
|
+
> Cloudflare Workers server library + Kotlin Multiplatform client.
|
|
5
|
+
|
|
6
|
+
多 App 共用的登录底座:邮箱验证码登录、社交 OAuth(GitHub 等)、会话管理(refresh 轮换 + 重用检测 + 丢回执救活)。服务端库跑在各 App 自己的 Cloudflare Worker 里(数据在各自 D1,不做中心化账号),客户端库以 Kotlin Multiplatform 提供。
|
|
7
|
+
|
|
8
|
+
## 由来
|
|
9
|
+
|
|
10
|
+
- 起点是 TrendingAI 替换 Logto 的调研(邮箱登录被迫走托管 web 页、CN 链路差),结论选定自建,见 [docs/logto-替换方案-调研.md](docs/logto-替换方案-调研.md)
|
|
11
|
+
- 服务端实现的母本是 Tono-Server 已在生产验证的邮箱验证码登录(含全套测试),本仓库是它的抽取 + 泛化
|
|
12
|
+
- 路线选择(公共库而非中心化服务)与仓库设计见 [docs/design.md](docs/design.md),命名记录见 [docs/naming.md](docs/naming.md)
|
|
13
|
+
|
|
14
|
+
## 结构(monorepo:一份协议,两个产物)
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
loginbase/
|
|
18
|
+
├── src/ # TS 服务端库(Hono sub-app 工厂),从 Tono-Server 平移
|
|
19
|
+
├── test/ # vitest
|
|
20
|
+
├── kotlin/ # KMP 客户端库(独立 gradle 工程)
|
|
21
|
+
├── docs/
|
|
22
|
+
│ ├── design.md # 路线与仓库设计决策
|
|
23
|
+
│ ├── server-design.md # 服务端技术方案(公共 API/协议草案/会话模型/平移策略)
|
|
24
|
+
│ ├── plan.md # 实施计划(五步任务清单/验收点/版本线)
|
|
25
|
+
│ ├── naming.md # 命名讨论记录
|
|
26
|
+
│ ├── protocol.md # API 契约(唯一权威)——代码平移时落笔
|
|
27
|
+
│ └── logto-替换方案-调研.md # 背景调研(自 TrendingProjects 迁入)
|
|
28
|
+
└── README.md
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## 产物坐标
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
仓库 HarlonWang/loginbase
|
|
35
|
+
npm loginbase (npm registry,tag 触发 CI 发布)
|
|
36
|
+
Maven wang.harlon:loginbase-kt (Maven Central,tag 触发 CI 发布)
|
|
37
|
+
Kotlin 包 wang.harlon.loginbase
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## 状态与路线
|
|
41
|
+
|
|
42
|
+
当前进行到**落地第 1 步**(服务端平移:库侧完成,48 测试全绿;待发 0.1.0 + Tono-Server 切换)。落地顺序:
|
|
43
|
+
|
|
44
|
+
1. 从 Tono-Server 平移服务端代码与测试,Tono-Server 改为依赖本包(其现有测试即抽取验收)
|
|
45
|
+
2. 钩子化(`onVerified` 用户回调)+ zh/en 邮件模板 + github-oauth 可选插件
|
|
46
|
+
3. TrendingAI 后端接入(`/auth` 挂载 + requireAuth 双轨 + Logto 存量迁移)
|
|
47
|
+
4. KMP 客户端库 + TrendingAI 登录 UI(commonMain)
|
|
48
|
+
5. Tono-Android 择机换用 loginbase-kt 的 android target
|
|
49
|
+
|
|
50
|
+
## 设计红线
|
|
51
|
+
|
|
52
|
+
- 依赖最小集:服务端 hono + jose(+ zod-validator),客户端 ktor + kotlinx-serialization + multiplatform-settings。auth 库是供应链攻击的最高价值目标,每加一个依赖都要过一遍这个念头
|
|
53
|
+
- 协议变更必须服务端 + 客户端 + `docs/protocol.md` 同一个 commit;单一版本线,一个 tag 同时锁定两端
|
package/dist/code.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const CODE_TTL_SECONDS = 600;
|
|
2
|
+
export declare const MAX_ATTEMPTS = 5;
|
|
3
|
+
export interface StoredCode {
|
|
4
|
+
code: string;
|
|
5
|
+
attempts: number;
|
|
6
|
+
issuedAt: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function generateCode(): string;
|
|
9
|
+
export declare function storeCode(kv: KVNamespace, email: string, code: string): Promise<void>;
|
|
10
|
+
export declare function readCode(kv: KVNamespace, email: string): Promise<StoredCode | null>;
|
|
11
|
+
export declare function deleteCode(kv: KVNamespace, email: string): Promise<void>;
|
|
12
|
+
export declare function incrementAttempts(kv: KVNamespace, email: string, stored: StoredCode): Promise<number>;
|
package/dist/code.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const CODE_TTL_SECONDS = 600;
|
|
2
|
+
export const MAX_ATTEMPTS = 5;
|
|
3
|
+
export function generateCode() {
|
|
4
|
+
const array = new Uint32Array(1);
|
|
5
|
+
crypto.getRandomValues(array);
|
|
6
|
+
return (array[0] % 1_000_000).toString().padStart(6, "0");
|
|
7
|
+
}
|
|
8
|
+
export async function storeCode(kv, email, code) {
|
|
9
|
+
const payload = { code, attempts: 0, issuedAt: Date.now() };
|
|
10
|
+
await kv.put(`code:${email}`, JSON.stringify(payload), {
|
|
11
|
+
expirationTtl: CODE_TTL_SECONDS,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
export async function readCode(kv, email) {
|
|
15
|
+
const raw = await kv.get(`code:${email}`);
|
|
16
|
+
return raw ? JSON.parse(raw) : null;
|
|
17
|
+
}
|
|
18
|
+
export async function deleteCode(kv, email) {
|
|
19
|
+
await kv.delete(`code:${email}`);
|
|
20
|
+
}
|
|
21
|
+
export async function incrementAttempts(kv, email, stored) {
|
|
22
|
+
stored.attempts += 1;
|
|
23
|
+
const remainingTtl = Math.max(1, CODE_TTL_SECONDS - Math.floor((Date.now() - stored.issuedAt) / 1000));
|
|
24
|
+
await kv.put(`code:${email}`, JSON.stringify(stored), {
|
|
25
|
+
expirationTtl: remainingTtl,
|
|
26
|
+
});
|
|
27
|
+
return stored.attempts;
|
|
28
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { EmailConfig } from "./email";
|
|
2
|
+
export interface LoginConfig {
|
|
3
|
+
db: D1Database;
|
|
4
|
+
kv: KVNamespace;
|
|
5
|
+
jwt: {
|
|
6
|
+
secret: string;
|
|
7
|
+
};
|
|
8
|
+
email: EmailConfig;
|
|
9
|
+
}
|
|
10
|
+
export interface CreateLoginOptions {
|
|
11
|
+
/** 路由前缀,默认 "/auth"。静态选项(Hono app 构建期即需要),不进 env-依赖的 config。 */
|
|
12
|
+
basePath?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function memoizeResolver<TEnv>(resolve: (env: TEnv) => LoginConfig): (env: TEnv) => LoginConfig;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Workers 的 binding 只在请求期以 env 出现,模块顶层拿不到;resolver 让库在
|
|
2
|
+
// 请求时取配置,并按 env 对象记忆化(同一 isolate 内 env 恒定,WeakMap 即缓存)。
|
|
3
|
+
export function memoizeResolver(resolve) {
|
|
4
|
+
const cache = new WeakMap();
|
|
5
|
+
return (env) => {
|
|
6
|
+
if (typeof env !== "object" || env === null)
|
|
7
|
+
return resolve(env);
|
|
8
|
+
const cached = cache.get(env);
|
|
9
|
+
if (cached)
|
|
10
|
+
return cached;
|
|
11
|
+
const cfg = resolve(env);
|
|
12
|
+
cache.set(env, cfg);
|
|
13
|
+
return cfg;
|
|
14
|
+
};
|
|
15
|
+
}
|
package/dist/email.d.ts
ADDED
package/dist/email.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const RESEND_ENDPOINT = "https://api.resend.com/emails";
|
|
2
|
+
export async function sendCodeEmail(config, email, code) {
|
|
3
|
+
const body = {
|
|
4
|
+
from: config.from,
|
|
5
|
+
to: [email],
|
|
6
|
+
subject: `Your Tono verification code: ${code}`,
|
|
7
|
+
html: renderHtml(code),
|
|
8
|
+
text: renderText(code),
|
|
9
|
+
};
|
|
10
|
+
const res = await fetch(RESEND_ENDPOINT, {
|
|
11
|
+
method: "POST",
|
|
12
|
+
headers: {
|
|
13
|
+
"Content-Type": "application/json",
|
|
14
|
+
Authorization: `Bearer ${config.resendApiKey}`,
|
|
15
|
+
},
|
|
16
|
+
body: JSON.stringify(body),
|
|
17
|
+
});
|
|
18
|
+
if (!res.ok) {
|
|
19
|
+
const msg = await res.text().catch(() => "");
|
|
20
|
+
throw new Error(`Resend failed: ${res.status} ${msg}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function renderHtml(code) {
|
|
24
|
+
return `
|
|
25
|
+
<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;padding:24px;">
|
|
26
|
+
<h2 style="margin:0 0 16px 0;">Your Tono verification code</h2>
|
|
27
|
+
<p style="font-size:32px;letter-spacing:8px;font-weight:700;margin:16px 0;">${code}</p>
|
|
28
|
+
<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>
|
|
29
|
+
</div>
|
|
30
|
+
`;
|
|
31
|
+
}
|
|
32
|
+
function renderText(code) {
|
|
33
|
+
return `Your Tono verification code is ${code}. It expires in 10 minutes.`;
|
|
34
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type AuthVariables } from "./middleware";
|
|
2
|
+
import type { LoginConfig } from "./config";
|
|
3
|
+
export declare function createAuthApp<TEnv>(getConfig: (env: TEnv) => LoginConfig, basePath: string): import("hono/hono-base").HonoBase<{
|
|
4
|
+
Variables: AuthVariables;
|
|
5
|
+
}, import("hono/types").BlankSchema, string, string>;
|
package/dist/handler.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// 从 Tono-Server src/auth/handler.ts 原样平移(落地第 1 步,铁律 3)。
|
|
2
|
+
// 仅两类机械改动:c.env.X 读取改为 config 注入、import 路径调整。
|
|
3
|
+
// 内联的用户 upsert / 90 天试用 / /me 端点为 Tono 业务语义,暂留库内,
|
|
4
|
+
// 第 2 步钩子化(onVerified)时移回 Tono。
|
|
5
|
+
import { Hono } from "hono";
|
|
6
|
+
import { generateCode, storeCode, readCode, deleteCode, incrementAttempts, MAX_ATTEMPTS, } from "./code";
|
|
7
|
+
import { sendCodeEmail } from "./email";
|
|
8
|
+
import { checkSendRateLimit, recordSend } from "./rate_limit";
|
|
9
|
+
import { createSession, hashRefreshToken, findSession, rotateSession, revokeFamily, tryRescueSession, revokeSession, revokeAllForUser, } from "./session";
|
|
10
|
+
import { signAccessToken } from "./token";
|
|
11
|
+
import { createAuthMiddleware } from "./middleware";
|
|
12
|
+
import { logEvent } from "./log";
|
|
13
|
+
// 新用户注册赠送的 Pro 试用时长(3 个月)
|
|
14
|
+
const TRIAL_PERIOD_MS = 90 * 24 * 60 * 60 * 1000;
|
|
15
|
+
export function createAuthApp(getConfig, basePath) {
|
|
16
|
+
const authMiddleware = createAuthMiddleware(getConfig);
|
|
17
|
+
const auth = new Hono().basePath(basePath);
|
|
18
|
+
const cfg = (c) => getConfig(c.env);
|
|
19
|
+
auth.post("/code/send", async (c) => {
|
|
20
|
+
const body = await c.req
|
|
21
|
+
.json()
|
|
22
|
+
.catch(() => ({}));
|
|
23
|
+
const raw = (body.email ?? "").trim().toLowerCase();
|
|
24
|
+
if (!/^\S+@\S+\.\S+$/.test(raw)) {
|
|
25
|
+
return c.json({ error: "invalid_email" }, 400);
|
|
26
|
+
}
|
|
27
|
+
const ip = c.req.header("CF-Connecting-IP") ?? "unknown";
|
|
28
|
+
const rl = await checkSendRateLimit(cfg(c).kv, raw, ip);
|
|
29
|
+
if (!rl.allowed) {
|
|
30
|
+
return c.json({ error: "too_many_requests", retryAfterSeconds: rl.retryAfterSeconds }, 429);
|
|
31
|
+
}
|
|
32
|
+
const code = generateCode();
|
|
33
|
+
try {
|
|
34
|
+
await sendCodeEmail(cfg(c).email, raw, code);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return c.json({ error: "internal" }, 500);
|
|
38
|
+
}
|
|
39
|
+
await storeCode(cfg(c).kv, raw, code);
|
|
40
|
+
await recordSend(cfg(c).kv, raw, ip);
|
|
41
|
+
return c.json({ cooldownSeconds: 60 }, 200);
|
|
42
|
+
});
|
|
43
|
+
auth.post("/code/verify", async (c) => {
|
|
44
|
+
const body = await c.req
|
|
45
|
+
.json()
|
|
46
|
+
.catch(() => ({}));
|
|
47
|
+
const email = (body.email ?? "").trim().toLowerCase();
|
|
48
|
+
const code = (body.code ?? "").trim();
|
|
49
|
+
const stored = await readCode(cfg(c).kv, email);
|
|
50
|
+
if (!stored)
|
|
51
|
+
return c.json({ error: "code_expired" }, 400);
|
|
52
|
+
if (stored.code !== code) {
|
|
53
|
+
const attempts = await incrementAttempts(cfg(c).kv, email, stored);
|
|
54
|
+
if (attempts >= MAX_ATTEMPTS) {
|
|
55
|
+
await deleteCode(cfg(c).kv, email);
|
|
56
|
+
return c.json({ error: "too_many_attempts" }, 429);
|
|
57
|
+
}
|
|
58
|
+
return c.json({ error: "invalid_code" }, 400);
|
|
59
|
+
}
|
|
60
|
+
await deleteCode(cfg(c).kv, email);
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
let user = await cfg(c)
|
|
63
|
+
.db.prepare("SELECT id, email, pro_expires_at, created_at FROM users WHERE email = ?")
|
|
64
|
+
.bind(email)
|
|
65
|
+
.first();
|
|
66
|
+
let isNewUser = false;
|
|
67
|
+
if (!user) {
|
|
68
|
+
const id = crypto.randomUUID();
|
|
69
|
+
const proExpiresAt = now + TRIAL_PERIOD_MS;
|
|
70
|
+
await cfg(c)
|
|
71
|
+
.db.prepare("INSERT INTO users (id, email, pro_expires_at, created_at) VALUES (?, ?, ?, ?)")
|
|
72
|
+
.bind(id, email, proExpiresAt, now)
|
|
73
|
+
.run();
|
|
74
|
+
user = { id, email, pro_expires_at: proExpiresAt, created_at: now };
|
|
75
|
+
isNewUser = true;
|
|
76
|
+
}
|
|
77
|
+
const userAgent = c.req.header("User-Agent") ?? null;
|
|
78
|
+
const ip = c.req.header("CF-Connecting-IP") ?? null;
|
|
79
|
+
const { sessionId, refreshToken } = await createSession(cfg(c).db, {
|
|
80
|
+
userId: user.id,
|
|
81
|
+
userAgent: userAgent ?? undefined,
|
|
82
|
+
ip: ip ?? undefined,
|
|
83
|
+
});
|
|
84
|
+
const accessToken = await signAccessToken(cfg(c).jwt.secret, user.id, sessionId);
|
|
85
|
+
return c.json({
|
|
86
|
+
accessToken,
|
|
87
|
+
refreshToken,
|
|
88
|
+
user: {
|
|
89
|
+
id: user.id,
|
|
90
|
+
email: user.email,
|
|
91
|
+
isPro: user.pro_expires_at != null && user.pro_expires_at > now,
|
|
92
|
+
proExpiresAt: user.pro_expires_at,
|
|
93
|
+
createdAt: user.created_at,
|
|
94
|
+
},
|
|
95
|
+
isNewUser,
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
auth.post("/refresh", async (c) => {
|
|
99
|
+
const body = await c.req
|
|
100
|
+
.json()
|
|
101
|
+
.catch(() => ({}));
|
|
102
|
+
const token = (body.refreshToken ?? "").trim();
|
|
103
|
+
if (!token) {
|
|
104
|
+
return c.json({ error: "invalid_refresh_token", reason: "missing_token" }, 401);
|
|
105
|
+
}
|
|
106
|
+
const sessionId = await hashRefreshToken(token);
|
|
107
|
+
const row = await findSession(cfg(c).db, sessionId);
|
|
108
|
+
if (!row) {
|
|
109
|
+
return c.json({ error: "invalid_refresh_token", reason: "session_not_found" }, 401);
|
|
110
|
+
}
|
|
111
|
+
if (row.revoked_at !== null) {
|
|
112
|
+
// 已轮换的 token 被再次提交:可能是"丢回执的诚实重试",先尝试救活。
|
|
113
|
+
const userAgent = c.req.header("User-Agent") ?? undefined;
|
|
114
|
+
const ip = c.req.header("CF-Connecting-IP") ?? undefined;
|
|
115
|
+
const rescue = await tryRescueSession(cfg(c).db, row, { userAgent, ip });
|
|
116
|
+
if (rescue.status === "rescued") {
|
|
117
|
+
logEvent({
|
|
118
|
+
event: "refresh",
|
|
119
|
+
outcome: "rescued",
|
|
120
|
+
userId: row.user_id,
|
|
121
|
+
familyId: row.family_id,
|
|
122
|
+
ip: ip ?? null,
|
|
123
|
+
});
|
|
124
|
+
const accessToken = await signAccessToken(cfg(c).jwt.secret, row.user_id, rescue.session.sessionId);
|
|
125
|
+
return c.json({ accessToken, refreshToken: rescue.session.refreshToken });
|
|
126
|
+
}
|
|
127
|
+
// 救活不成立 → 判定真重用,撤销整条会话链。guardrail 与 not_eligible 分开记录。
|
|
128
|
+
await revokeFamily(cfg(c).db, row.family_id);
|
|
129
|
+
logEvent({
|
|
130
|
+
event: "refresh",
|
|
131
|
+
outcome: rescue.status === "guardrail" ? "guardrail_revoked" : "reuse_revoked",
|
|
132
|
+
userId: row.user_id,
|
|
133
|
+
familyId: row.family_id,
|
|
134
|
+
ip: ip ?? null,
|
|
135
|
+
});
|
|
136
|
+
return c.json({ error: "invalid_refresh_token", reason: "session_revoked" }, 401);
|
|
137
|
+
}
|
|
138
|
+
if (row.expires_at !== null && row.expires_at <= Date.now()) {
|
|
139
|
+
return c.json({ error: "invalid_refresh_token", reason: "session_expired" }, 401);
|
|
140
|
+
}
|
|
141
|
+
const userAgent = c.req.header("User-Agent") ?? undefined;
|
|
142
|
+
const ip = c.req.header("CF-Connecting-IP") ?? undefined;
|
|
143
|
+
const next = await rotateSession(cfg(c).db, sessionId, { userAgent, ip });
|
|
144
|
+
if (!next) {
|
|
145
|
+
return c.json({ error: "invalid_refresh_token", reason: "rotate_failed" }, 401);
|
|
146
|
+
}
|
|
147
|
+
const accessToken = await signAccessToken(cfg(c).jwt.secret, row.user_id, next.sessionId);
|
|
148
|
+
return c.json({ accessToken, refreshToken: next.refreshToken });
|
|
149
|
+
});
|
|
150
|
+
// 返回当前登录用户的最新信息(含实时 Pro 状态),供客户端在前台/启动时刷新本地缓存,
|
|
151
|
+
// 无需重新登录即可感知服务端 pro_expires_at 的变化(续费 / 到期 / 手动赠送)。
|
|
152
|
+
auth.get("/me", authMiddleware, async (c) => {
|
|
153
|
+
const userId = c.get("userId");
|
|
154
|
+
const row = await cfg(c)
|
|
155
|
+
.db.prepare("SELECT id, email, pro_expires_at, created_at FROM users WHERE id = ?")
|
|
156
|
+
.bind(userId)
|
|
157
|
+
.first();
|
|
158
|
+
if (!row)
|
|
159
|
+
return c.json({ error: "user_not_found" }, 404);
|
|
160
|
+
const now = Date.now();
|
|
161
|
+
return c.json({
|
|
162
|
+
id: row.id,
|
|
163
|
+
email: row.email,
|
|
164
|
+
isPro: row.pro_expires_at != null && row.pro_expires_at > now,
|
|
165
|
+
proExpiresAt: row.pro_expires_at,
|
|
166
|
+
createdAt: row.created_at,
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
auth.delete("/sessions", authMiddleware, async (c) => {
|
|
170
|
+
const sessionId = c.get("sessionId");
|
|
171
|
+
if (!sessionId)
|
|
172
|
+
return c.json({ error: "unauthorized" }, 401);
|
|
173
|
+
await revokeSession(cfg(c).db, sessionId);
|
|
174
|
+
return c.body(null, 204);
|
|
175
|
+
});
|
|
176
|
+
auth.delete("/sessions/all", authMiddleware, async (c) => {
|
|
177
|
+
const userId = c.get("userId");
|
|
178
|
+
await revokeAllForUser(cfg(c).db, userId);
|
|
179
|
+
return c.body(null, 204);
|
|
180
|
+
});
|
|
181
|
+
return auth;
|
|
182
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Hono } from "hono";
|
|
2
|
+
import type { MiddlewareHandler } from "hono";
|
|
3
|
+
import { type CreateLoginOptions, type LoginConfig } from "./config";
|
|
4
|
+
import { type AuthVariables } from "./middleware";
|
|
5
|
+
export interface Login<TEnv> {
|
|
6
|
+
/** Hono 实例(已含 basePath)。Hono 消费方:app.route("/", login.app) */
|
|
7
|
+
app: Hono<{
|
|
8
|
+
Variables: AuthVariables;
|
|
9
|
+
}>;
|
|
10
|
+
/** 裸 JS Worker 一行挂载:if (pathname.startsWith("/auth")) return login.fetch(req, env, ctx) */
|
|
11
|
+
fetch: (request: Request, env: TEnv, ctx?: ExecutionContext) => Response | Promise<Response>;
|
|
12
|
+
/** Hono 中间件:Bearer 校验,set userId / sessionId */
|
|
13
|
+
middleware: MiddlewareHandler<{
|
|
14
|
+
Variables: AuthVariables;
|
|
15
|
+
}>;
|
|
16
|
+
}
|
|
17
|
+
export declare function createLogin<TEnv = unknown>(resolve: (env: TEnv) => LoginConfig, options?: CreateLoginOptions): Login<TEnv>;
|
|
18
|
+
export type { LoginConfig, CreateLoginOptions } from "./config";
|
|
19
|
+
export type { AuthVariables } from "./middleware";
|
|
20
|
+
export { createAuthMiddleware } from "./middleware";
|
|
21
|
+
export * from "./code";
|
|
22
|
+
export * from "./email";
|
|
23
|
+
export * from "./rate_limit";
|
|
24
|
+
export * from "./session";
|
|
25
|
+
export * from "./token";
|
|
26
|
+
export { logEvent } from "./log";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { memoizeResolver } from "./config";
|
|
2
|
+
import { createAuthApp } from "./handler";
|
|
3
|
+
import { createAuthMiddleware } from "./middleware";
|
|
4
|
+
export function createLogin(resolve, options = {}) {
|
|
5
|
+
const getConfig = memoizeResolver(resolve);
|
|
6
|
+
const app = createAuthApp(getConfig, options.basePath ?? "/auth");
|
|
7
|
+
return {
|
|
8
|
+
app,
|
|
9
|
+
fetch: (request, env, ctx) => app.fetch(request, env, ctx),
|
|
10
|
+
middleware: createAuthMiddleware(getConfig),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export { createAuthMiddleware } from "./middleware";
|
|
14
|
+
// 底层模块出口:verifyAccessToken 供裸 Worker 的 requireAuth 双轨;
|
|
15
|
+
// session/code 等出口供消费方测试工具(如 Tono test/helpers)复用。
|
|
16
|
+
export * from "./code";
|
|
17
|
+
export * from "./email";
|
|
18
|
+
export * from "./rate_limit";
|
|
19
|
+
export * from "./session";
|
|
20
|
+
export * from "./token";
|
|
21
|
+
export { logEvent } from "./log";
|
package/dist/log.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function logEvent(event: Record<string, unknown>): void;
|
package/dist/log.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import type { LoginConfig } from "./config";
|
|
3
|
+
export interface AuthVariables {
|
|
4
|
+
userId: string;
|
|
5
|
+
sessionId?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function createAuthMiddleware<TEnv>(getConfig: (env: TEnv) => LoginConfig): MiddlewareHandler<{
|
|
8
|
+
Variables: AuthVariables;
|
|
9
|
+
}>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jwtVerify } from "jose";
|
|
2
|
+
export function createAuthMiddleware(getConfig) {
|
|
3
|
+
return async (c, next) => {
|
|
4
|
+
const header = c.req.header("Authorization");
|
|
5
|
+
if (!header?.startsWith("Bearer ")) {
|
|
6
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
7
|
+
}
|
|
8
|
+
const token = header.slice(7);
|
|
9
|
+
try {
|
|
10
|
+
const secret = new TextEncoder().encode(getConfig(c.env).jwt.secret);
|
|
11
|
+
const { payload } = await jwtVerify(token, secret);
|
|
12
|
+
c.set("userId", payload.sub);
|
|
13
|
+
if (typeof payload.sid === "string") {
|
|
14
|
+
c.set("sessionId", payload.sid);
|
|
15
|
+
}
|
|
16
|
+
await next();
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return c.json({ error: "Invalid token" }, 401);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export interface RateLimitResult {
|
|
2
|
+
allowed: boolean;
|
|
3
|
+
retryAfterSeconds: number;
|
|
4
|
+
}
|
|
5
|
+
export declare function checkSendRateLimit(kv: KVNamespace, email: string, ip: string): Promise<RateLimitResult>;
|
|
6
|
+
export declare function recordSend(kv: KVNamespace, email: string, ip: string): Promise<void>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const COOLDOWN_SECONDS = 60;
|
|
2
|
+
const EMAIL_WINDOW_SECONDS = 600;
|
|
3
|
+
const EMAIL_MAX_IN_WINDOW = 3;
|
|
4
|
+
const IP_WINDOW_SECONDS = 3600;
|
|
5
|
+
const IP_MAX_IN_WINDOW = 10;
|
|
6
|
+
export async function checkSendRateLimit(kv, email, ip) {
|
|
7
|
+
const cooldown = await kv.get(`cooldown:${email}`);
|
|
8
|
+
if (cooldown) {
|
|
9
|
+
return { allowed: false, retryAfterSeconds: COOLDOWN_SECONDS };
|
|
10
|
+
}
|
|
11
|
+
const emailCount = parseInt((await kv.get(`rl:email:${email}`)) ?? "0", 10);
|
|
12
|
+
if (emailCount >= EMAIL_MAX_IN_WINDOW) {
|
|
13
|
+
return { allowed: false, retryAfterSeconds: EMAIL_WINDOW_SECONDS };
|
|
14
|
+
}
|
|
15
|
+
const ipCount = parseInt((await kv.get(`rl:ip:${ip}`)) ?? "0", 10);
|
|
16
|
+
if (ipCount >= IP_MAX_IN_WINDOW) {
|
|
17
|
+
return { allowed: false, retryAfterSeconds: IP_WINDOW_SECONDS };
|
|
18
|
+
}
|
|
19
|
+
return { allowed: true, retryAfterSeconds: 0 };
|
|
20
|
+
}
|
|
21
|
+
export async function recordSend(kv, email, ip) {
|
|
22
|
+
await kv.put(`cooldown:${email}`, "1", { expirationTtl: COOLDOWN_SECONDS });
|
|
23
|
+
const emailKey = `rl:email:${email}`;
|
|
24
|
+
const emailCount = parseInt((await kv.get(emailKey)) ?? "0", 10);
|
|
25
|
+
await kv.put(emailKey, String(emailCount + 1), {
|
|
26
|
+
expirationTtl: EMAIL_WINDOW_SECONDS,
|
|
27
|
+
});
|
|
28
|
+
const ipKey = `rl:ip:${ip}`;
|
|
29
|
+
const ipCount = parseInt((await kv.get(ipKey)) ?? "0", 10);
|
|
30
|
+
await kv.put(ipKey, String(ipCount + 1), { expirationTtl: IP_WINDOW_SECONDS });
|
|
31
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export declare const REFRESH_TOKEN_BYTES = 32;
|
|
2
|
+
export interface SessionRow {
|
|
3
|
+
id: string;
|
|
4
|
+
user_id: string;
|
|
5
|
+
family_id: string;
|
|
6
|
+
expires_at: number | null;
|
|
7
|
+
created_at: number;
|
|
8
|
+
last_used_at: number;
|
|
9
|
+
user_agent: string | null;
|
|
10
|
+
ip: string | null;
|
|
11
|
+
revoked_at: number | null;
|
|
12
|
+
replaced_by_id: string | null;
|
|
13
|
+
rescued_at: number | null;
|
|
14
|
+
}
|
|
15
|
+
export interface CreateSessionInput {
|
|
16
|
+
userId: string;
|
|
17
|
+
userAgent?: string;
|
|
18
|
+
ip?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface CreateSessionOutput {
|
|
21
|
+
sessionId: string;
|
|
22
|
+
refreshToken: string;
|
|
23
|
+
familyId: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function generateRefreshToken(): string;
|
|
26
|
+
export declare function hashRefreshToken(token: string): Promise<string>;
|
|
27
|
+
export declare function createSession(db: D1Database, input: CreateSessionInput): Promise<CreateSessionOutput>;
|
|
28
|
+
export declare function findSession(db: D1Database, sessionId: string): Promise<SessionRow | null>;
|
|
29
|
+
export declare function rotateSession(db: D1Database, oldSessionId: string, meta: {
|
|
30
|
+
userAgent?: string;
|
|
31
|
+
ip?: string;
|
|
32
|
+
}): Promise<CreateSessionOutput | null>;
|
|
33
|
+
export declare const RESCUE_WINDOW_MS: number;
|
|
34
|
+
export declare const RESCUE_LIMIT = 3;
|
|
35
|
+
export type RescueResult = {
|
|
36
|
+
status: "rescued";
|
|
37
|
+
session: CreateSessionOutput;
|
|
38
|
+
} | {
|
|
39
|
+
status: "not_eligible";
|
|
40
|
+
} | {
|
|
41
|
+
status: "guardrail";
|
|
42
|
+
};
|
|
43
|
+
export declare function tryRescueSession(db: D1Database, revoked: SessionRow, meta: {
|
|
44
|
+
userAgent?: string;
|
|
45
|
+
ip?: string;
|
|
46
|
+
}): Promise<RescueResult>;
|
|
47
|
+
export declare function revokeFamily(db: D1Database, familyId: string): Promise<void>;
|
|
48
|
+
export declare function revokeSession(db: D1Database, sessionId: string): Promise<void>;
|
|
49
|
+
export declare function revokeAllForUser(db: D1Database, userId: string): Promise<void>;
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
export const REFRESH_TOKEN_BYTES = 32;
|
|
2
|
+
export function generateRefreshToken() {
|
|
3
|
+
const bytes = new Uint8Array(REFRESH_TOKEN_BYTES);
|
|
4
|
+
crypto.getRandomValues(bytes);
|
|
5
|
+
return btoa(String.fromCharCode(...bytes))
|
|
6
|
+
.replace(/\+/g, "-")
|
|
7
|
+
.replace(/\//g, "_")
|
|
8
|
+
.replace(/=+$/, "");
|
|
9
|
+
}
|
|
10
|
+
export async function hashRefreshToken(token) {
|
|
11
|
+
const data = new TextEncoder().encode(token);
|
|
12
|
+
const buf = await crypto.subtle.digest("SHA-256", data);
|
|
13
|
+
return Array.from(new Uint8Array(buf))
|
|
14
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
15
|
+
.join("");
|
|
16
|
+
}
|
|
17
|
+
export async function createSession(db, input) {
|
|
18
|
+
const refreshToken = generateRefreshToken();
|
|
19
|
+
const sessionId = await hashRefreshToken(refreshToken);
|
|
20
|
+
const familyId = crypto.randomUUID();
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
await db
|
|
23
|
+
.prepare(`INSERT INTO sessions (id, user_id, family_id, expires_at, created_at, last_used_at, user_agent, ip, revoked_at, replaced_by_id)
|
|
24
|
+
VALUES (?, ?, ?, NULL, ?, ?, ?, ?, NULL, NULL)`)
|
|
25
|
+
.bind(sessionId, input.userId, familyId, now, now, input.userAgent ?? null, input.ip ?? null)
|
|
26
|
+
.run();
|
|
27
|
+
return { sessionId, refreshToken, familyId };
|
|
28
|
+
}
|
|
29
|
+
export async function findSession(db, sessionId) {
|
|
30
|
+
return await db
|
|
31
|
+
.prepare("SELECT * FROM sessions WHERE id = ?")
|
|
32
|
+
.bind(sessionId)
|
|
33
|
+
.first();
|
|
34
|
+
}
|
|
35
|
+
export async function rotateSession(db, oldSessionId, meta) {
|
|
36
|
+
const old = await findSession(db, oldSessionId);
|
|
37
|
+
if (!old)
|
|
38
|
+
return null;
|
|
39
|
+
const refreshToken = generateRefreshToken();
|
|
40
|
+
const newId = await hashRefreshToken(refreshToken);
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
await db.batch([
|
|
43
|
+
db
|
|
44
|
+
.prepare(`INSERT INTO sessions (id, user_id, family_id, expires_at, created_at, last_used_at, user_agent, ip, revoked_at, replaced_by_id)
|
|
45
|
+
VALUES (?, ?, ?, NULL, ?, ?, ?, ?, NULL, NULL)`)
|
|
46
|
+
.bind(newId, old.user_id, old.family_id, now, now, meta.userAgent ?? old.user_agent, meta.ip ?? old.ip),
|
|
47
|
+
db
|
|
48
|
+
.prepare("UPDATE sessions SET revoked_at = ?, replaced_by_id = ? WHERE id = ? AND revoked_at IS NULL")
|
|
49
|
+
.bind(now, newId, oldSessionId),
|
|
50
|
+
]);
|
|
51
|
+
return { sessionId: newId, refreshToken, familyId: old.family_id };
|
|
52
|
+
}
|
|
53
|
+
// 救活护栏:同一 family 在窗口内允许的最大救活次数。
|
|
54
|
+
// 正常的"丢回执诚实重试"每次最多触发 1 次救活(拿到新证后即不再用旧链);
|
|
55
|
+
// 持续超限只可能是 token 被盗后多方交替使用,超限即按真重用撤销整链。
|
|
56
|
+
export const RESCUE_WINDOW_MS = 60 * 60 * 1000;
|
|
57
|
+
export const RESCUE_LIMIT = 3;
|
|
58
|
+
async function recentRescueCount(db, familyId, since) {
|
|
59
|
+
const row = await db
|
|
60
|
+
.prepare("SELECT COUNT(*) AS n FROM sessions WHERE family_id = ? AND rescued_at IS NOT NULL AND rescued_at > ?")
|
|
61
|
+
.bind(familyId, since)
|
|
62
|
+
.first();
|
|
63
|
+
return row?.n ?? 0;
|
|
64
|
+
}
|
|
65
|
+
// 丢回执诚实重试的救活:presented 的 token 已被轮换(revoked),但它的直接后继
|
|
66
|
+
// 仍 active(会话链未被第二方推进)—— 说明客户端没收到上次轮换的回执、手里只剩旧
|
|
67
|
+
// token。此时从后继再轮换一次、发一套可用的新证,避免误把整条链当成 token 重用杀掉。
|
|
68
|
+
export async function tryRescueSession(db, revoked, meta) {
|
|
69
|
+
if (revoked.replaced_by_id === null)
|
|
70
|
+
return { status: "not_eligible" };
|
|
71
|
+
const successor = await findSession(db, revoked.replaced_by_id);
|
|
72
|
+
if (!successor || successor.revoked_at !== null)
|
|
73
|
+
return { status: "not_eligible" };
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
if ((await recentRescueCount(db, revoked.family_id, now - RESCUE_WINDOW_MS)) >= RESCUE_LIMIT) {
|
|
76
|
+
return { status: "guardrail" };
|
|
77
|
+
}
|
|
78
|
+
const refreshToken = generateRefreshToken();
|
|
79
|
+
const newId = await hashRefreshToken(refreshToken);
|
|
80
|
+
await db.batch([
|
|
81
|
+
db
|
|
82
|
+
.prepare(`INSERT INTO sessions (id, user_id, family_id, expires_at, created_at, last_used_at, user_agent, ip, revoked_at, replaced_by_id, rescued_at)
|
|
83
|
+
VALUES (?, ?, ?, NULL, ?, ?, ?, ?, NULL, NULL, ?)`)
|
|
84
|
+
.bind(newId, successor.user_id, successor.family_id, now, now, meta.userAgent ?? successor.user_agent, meta.ip ?? successor.ip, now),
|
|
85
|
+
db
|
|
86
|
+
.prepare("UPDATE sessions SET revoked_at = ?, replaced_by_id = ? WHERE id = ? AND revoked_at IS NULL")
|
|
87
|
+
.bind(now, newId, successor.id),
|
|
88
|
+
]);
|
|
89
|
+
return {
|
|
90
|
+
status: "rescued",
|
|
91
|
+
session: { sessionId: newId, refreshToken, familyId: successor.family_id },
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export async function revokeFamily(db, familyId) {
|
|
95
|
+
await db
|
|
96
|
+
.prepare("UPDATE sessions SET revoked_at = ? WHERE family_id = ? AND revoked_at IS NULL")
|
|
97
|
+
.bind(Date.now(), familyId)
|
|
98
|
+
.run();
|
|
99
|
+
}
|
|
100
|
+
export async function revokeSession(db, sessionId) {
|
|
101
|
+
await db
|
|
102
|
+
.prepare("UPDATE sessions SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL")
|
|
103
|
+
.bind(Date.now(), sessionId)
|
|
104
|
+
.run();
|
|
105
|
+
}
|
|
106
|
+
export async function revokeAllForUser(db, userId) {
|
|
107
|
+
await db
|
|
108
|
+
.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL")
|
|
109
|
+
.bind(Date.now(), userId)
|
|
110
|
+
.run();
|
|
111
|
+
}
|
package/dist/token.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type JWTPayload } from "jose";
|
|
2
|
+
export declare const ACCESS_TTL_SECONDS = 3600;
|
|
3
|
+
export interface AccessPayload extends JWTPayload {
|
|
4
|
+
sub: string;
|
|
5
|
+
sid: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function signAccessToken(secret: string, userId: string, sessionId: string): Promise<string>;
|
|
8
|
+
export declare function verifyAccessToken(secret: string, token: string): Promise<AccessPayload>;
|
package/dist/token.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { SignJWT, jwtVerify } from "jose";
|
|
2
|
+
export const ACCESS_TTL_SECONDS = 3600;
|
|
3
|
+
export async function signAccessToken(secret, userId, sessionId) {
|
|
4
|
+
const key = new TextEncoder().encode(secret);
|
|
5
|
+
return new SignJWT({ sub: userId, sid: sessionId })
|
|
6
|
+
.setProtectedHeader({ alg: "HS256" })
|
|
7
|
+
.setIssuedAt()
|
|
8
|
+
.setExpirationTime(`${ACCESS_TTL_SECONDS}s`)
|
|
9
|
+
.sign(key);
|
|
10
|
+
}
|
|
11
|
+
export async function verifyAccessToken(secret, token) {
|
|
12
|
+
const key = new TextEncoder().encode(secret);
|
|
13
|
+
const { payload } = await jwtVerify(token, key);
|
|
14
|
+
return payload;
|
|
15
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
-- loginbase 会话表(Tono-Server migrations 0002_create_sessions + 0004_add_session_rescue 的合并端态)。
|
|
2
|
+
-- id 即 refresh token 的 SHA-256 hex;user_id 由业务侧提供,库只存不读。
|
|
3
|
+
-- rescued_at 非 NULL 表示该行由救活逻辑(从存活后继再轮换)产生,
|
|
4
|
+
-- 用于统计同一 family 在时间窗口内的救活次数,超限即按真重用撤销整链。
|
|
5
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
6
|
+
id TEXT PRIMARY KEY,
|
|
7
|
+
user_id TEXT NOT NULL,
|
|
8
|
+
family_id TEXT NOT NULL,
|
|
9
|
+
expires_at INTEGER,
|
|
10
|
+
created_at INTEGER NOT NULL,
|
|
11
|
+
last_used_at INTEGER NOT NULL,
|
|
12
|
+
user_agent TEXT,
|
|
13
|
+
ip TEXT,
|
|
14
|
+
revoked_at INTEGER,
|
|
15
|
+
replaced_by_id TEXT,
|
|
16
|
+
rescued_at INTEGER
|
|
17
|
+
);
|
|
18
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
|
19
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_family_id ON sessions(family_id);
|
|
20
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_rescued ON sessions(family_id, rescued_at);
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "loginbase",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared login foundation for Cloudflare Workers: email OTP + social OAuth + session management, as a Hono sub-app factory.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/HarlonWang/loginbase.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/HarlonWang/loginbase#readme",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"auth",
|
|
14
|
+
"login",
|
|
15
|
+
"email-otp",
|
|
16
|
+
"session",
|
|
17
|
+
"cloudflare-workers",
|
|
18
|
+
"hono"
|
|
19
|
+
],
|
|
20
|
+
"main": "dist/index.js",
|
|
21
|
+
"types": "dist/index.d.ts",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"import": "./dist/index.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"migrations"
|
|
31
|
+
],
|
|
32
|
+
"sideEffects": false,
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -p tsconfig.build.json",
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"test": "vitest run"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"hono": "^4.12.8"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"jose": "^6.2.1"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@cloudflare/vitest-pool-workers": "~0.13.1",
|
|
46
|
+
"@cloudflare/workers-types": "~4.20260317.1",
|
|
47
|
+
"hono": "^4.12.8",
|
|
48
|
+
"typescript": "^5.9.3",
|
|
49
|
+
"vitest": "^4.1.0",
|
|
50
|
+
"wrangler": "~4.74.0"
|
|
51
|
+
},
|
|
52
|
+
"allowScripts": {
|
|
53
|
+
"workerd@1.20260317.1": true,
|
|
54
|
+
"workerd@1.20260312.1": true
|
|
55
|
+
}
|
|
56
|
+
}
|