返回 DeepSeek-Reasonix
identity.ts
根目录 / workers / forum / src / identity.ts
1 import type { Context, MiddlewareHandler } from "hono";
2 import type { AppEnv, Bindings, Member } from "./env";
3
4 // The id.reasonix.io session cookie is scoped to `.reasonix.io`, so the browser
5 // sends it here too; we hand it back as a Bearer token to resolve the identity.
6 const SHARED_COOKIE = "rxid";
7
8 function bearerFrom(c: Context<AppEnv>): string | undefined {
9 const cookie = c.req.header("cookie") ?? "";
10 for (const part of cookie.split(";")) {
11 const [k, ...v] = part.trim().split("=");
12 if (k === SHARED_COOKIE) return v.join("=");
13 }
14 return undefined;
15 }
16
17 function isAdminEmail(env: Bindings, email: string): boolean {
18 return (env.ADMIN_EMAILS ?? "")
19 .split(",")
20 .map((s) => s.trim().toLowerCase())
21 .filter(Boolean)
22 .includes(email);
23 }
24
25 interface Identity {
26 email: string;
27 handle: string;
28 emailVerified: boolean;
29 }
30
31 async function resolveIdentity(c: Context<AppEnv>): Promise<Identity | null> {
32 const token = bearerFrom(c);
33 if (!token) return null;
34 const base = c.env.ID_ORIGIN.replace(/\/$/, "");
35 const res = await fetch(`${base}/me`, { headers: { authorization: `Bearer ${token}` } });
36 if (!res.ok) return null;
37 const data = await res.json<{ user?: { email?: string; handle?: string; emailVerified?: boolean } }>().catch(() => null);
38 const email = data?.user?.email?.toLowerCase();
39 if (!email) return null;
40 return { email, handle: data?.user?.handle ?? email.split("@")[0], emailVerified: data?.user?.emailVerified === true };
41 }
42
43 // Resolves the shared identity and upserts a local member row (trust 0 for a new
44 // identity; ADMIN_EMAILS are seeded as admin). Runs for every request; the member
45 // is null when there's no valid session.
46 export const loadMember: MiddlewareHandler<AppEnv> = async (c, next) => {
47 const identity = await resolveIdentity(c);
48 let member: Member | null = null;
49 if (identity) {
50 const now = new Date().toISOString();
51 const seedRole = isAdminEmail(c.env, identity.email) ? "admin" : "member";
52 await c.env.DB.prepare(
53 `INSERT INTO members (email, handle, trust, role, created_at, last_seen_at)
54 VALUES (?1, ?2, ?3, ?4, ?5, ?5)
55 ON CONFLICT(email) DO UPDATE SET handle = ?2, last_seen_at = ?5,
56 role = CASE WHEN ?4 = 'admin' THEN 'admin' ELSE members.role END`,
57 )
58 .bind(identity.email, identity.handle, seedRole === "admin" ? 2 : 0, seedRole, now)
59 .run();
60 const row = await c.env.DB.prepare(
61 "SELECT email, handle, trust, role, silenced_until FROM members WHERE email = ?1",
62 )
63 .bind(identity.email)
64 .first<{ email: string; handle: string; trust: number; role: Member["role"]; silenced_until: string | null }>();
65 if (row) {
66 member = {
67 email: row.email,
68 handle: row.handle,
69 emailVerified: identity.emailVerified,
70 trust: row.trust,
71 role: row.role,
72 silencedUntil: row.silenced_until,
73 };
74 }
75 }
76 c.set("member", member);
77 await next();
78 };
79
80 export function currentMember(c: Context<AppEnv>): Member {
81 const member = c.get("member");
82 if (!member) throw new HttpError(401, "unauthorized", "Sign in to continue.");
83 return member;
84 }
85
86 export class HttpError extends Error {
87 constructor(
88 public readonly status: number,
89 public readonly code: string,
90 message: string,
91 ) {
92 super(message);
93 }
94 }
95
95 lines TYPESCRIPT