返回 DeepSeek-Reasonix
deviceGrants.ts
根目录 / workers / accounts / src / db / deviceGrants.ts
1 import { generateToken, generateUserCode, hashToken } from "../auth/crypto";
2 import { DEVICE_POLL_INTERVAL_S } from "../config";
3
4 export type DeviceGrantStatus = "pending" | "approved" | "denied";
5 export type SessionKind = "web" | "cli";
6
7 export interface StartedGrant {
8 deviceCode: string;
9 userCode: string;
10 expiresAt: string;
11 }
12
13 export interface DeviceGrantInfo {
14 userCode: string;
15 userAgent: string;
16 createdAt: string;
17 expiresAt: string;
18 }
19
20 export interface ClaimedGrant {
21 userId: number;
22 kind: SessionKind;
23 userAgent: string;
24 }
25
26 export type PollStatus =
27 | { kind: "pending"; slowDown: boolean }
28 | { kind: "denied" }
29 | { kind: "expired" }
30 | { kind: "not_found" };
31
32 // Strip separators/whitespace and upper-case so a user code typed as "wdjb-mjht"
33 // or "WDJB MJHT" matches the canonical form stored at issue time.
34 export function normalizeUserCode(raw: string): string {
35 return raw.replace(/[^0-9a-zA-Z]/g, "").toUpperCase();
36 }
37
38 // Present a canonical code as two hyphen-separated groups for readability.
39 export function formatUserCode(canonical: string): string {
40 const mid = Math.ceil(canonical.length / 2);
41 return `${canonical.slice(0, mid)}-${canonical.slice(mid)}`;
42 }
43
44 export class DeviceGrantRepo {
45 constructor(
46 private readonly db: D1Database,
47 private readonly pepper: string,
48 ) {}
49
50 // Issues a pending grant. Returns the raw device_code (for the client to poll)
51 // and the canonical user_code (for the human to approve); only the device
52 // code's peppered hash is stored, mirroring sessions/email tokens.
53 async start(opts: { userAgent?: string; ttlMs: number; kind?: SessionKind }): Promise<StartedGrant> {
54 const deviceCode = generateToken();
55 const deviceCodeHash = await hashToken(this.pepper, deviceCode);
56 const now = new Date();
57 const expiresAt = new Date(now.getTime() + opts.ttlMs).toISOString();
58 const userAgent = (opts.userAgent ?? "").slice(0, 256);
59 const kind: SessionKind = opts.kind ?? "cli";
60 for (let attempt = 0; ; attempt++) {
61 const userCode = generateUserCode();
62 try {
63 await this.db
64 .prepare(
65 `INSERT INTO device_grants (device_code_hash, user_code, status, kind, user_agent, created_at, expires_at)
66 VALUES (?1, ?2, 'pending', ?3, ?4, ?5, ?6)`,
67 )
68 .bind(deviceCodeHash, userCode, kind, userAgent, now.toISOString(), expiresAt)
69 .run();
70 return { deviceCode, userCode, expiresAt };
71 } catch (err) {
72 if (attempt >= 4) throw err; // exhausted user_code collision retries
73 }
74 }
75 }
76
77 // Safe display metadata for the approval screen; only a live pending grant is
78 // revealed, so an expired or already-decided code looks unknown.
79 async info(userCode: string): Promise<DeviceGrantInfo | null> {
80 const now = new Date().toISOString();
81 const row = await this.db
82 .prepare(
83 `SELECT user_code, user_agent, created_at, expires_at FROM device_grants
84 WHERE user_code = ?1 AND status = 'pending' AND expires_at > ?2`,
85 )
86 .bind(normalizeUserCode(userCode), now)
87 .first<{ user_code: string; user_agent: string; created_at: string; expires_at: string }>();
88 if (!row) return null;
89 return { userCode: row.user_code, userAgent: row.user_agent, createdAt: row.created_at, expiresAt: row.expires_at };
90 }
91
92 // Binds a pending grant to the approving user. Returns false when the code is
93 // unknown, expired, or already decided.
94 async approve(userCode: string, userId: number): Promise<boolean> {
95 const now = new Date().toISOString();
96 const row = await this.db
97 .prepare(
98 `UPDATE device_grants SET status = 'approved', user_id = ?1, approved_at = ?2
99 WHERE user_code = ?3 AND status = 'pending' AND expires_at > ?2
100 RETURNING device_code_hash`,
101 )
102 .bind(userId, now, normalizeUserCode(userCode))
103 .first<{ device_code_hash: string }>();
104 return row !== null;
105 }
106
107 async deny(userCode: string): Promise<void> {
108 const now = new Date().toISOString();
109 await this.db
110 .prepare(
111 `UPDATE device_grants SET status = 'denied'
112 WHERE user_code = ?1 AND status = 'pending' AND expires_at > ?2`,
113 )
114 .bind(normalizeUserCode(userCode), now)
115 .run();
116 }
117
118 // Atomically claims an approved grant via DELETE ... RETURNING: exactly one
119 // poll wins and gets the bound user; the row is gone afterwards so a session is
120 // minted once. The caller creates the session (this repo never sees the pepper
121 // twice). A losing poll gets null and falls through to pollStatus.
122 async claim(deviceCode: string): Promise<ClaimedGrant | null> {
123 const deviceCodeHash = await hashToken(this.pepper, deviceCode);
124 const now = new Date().toISOString();
125 const row = await this.db
126 .prepare(
127 `DELETE FROM device_grants
128 WHERE device_code_hash = ?1 AND status = 'approved' AND user_id IS NOT NULL AND expires_at > ?2
129 RETURNING user_id, kind, user_agent`,
130 )
131 .bind(deviceCodeHash, now)
132 .first<{ user_id: number; kind: SessionKind; user_agent: string }>();
133 if (!row) return null;
134 return { userId: row.user_id, kind: row.kind, userAgent: row.user_agent };
135 }
136
137 // Reports why a not-yet-claimable poll isn't complete. Prunes an expired grant.
138 async pollStatus(deviceCode: string): Promise<PollStatus> {
139 const deviceCodeHash = await hashToken(this.pepper, deviceCode);
140 const now = new Date();
141 const nowIso = now.toISOString();
142 const row = await this.db
143 .prepare(`SELECT status, expires_at, last_polled_at FROM device_grants WHERE device_code_hash = ?1`)
144 .bind(deviceCodeHash)
145 .first<{ status: DeviceGrantStatus; expires_at: string; last_polled_at: string | null }>();
146 if (!row) return { kind: "not_found" };
147 if (row.expires_at <= nowIso) {
148 await this.db.prepare("DELETE FROM device_grants WHERE device_code_hash = ?1").bind(deviceCodeHash).run();
149 return { kind: "expired" };
150 }
151 if (row.status === "denied") return { kind: "denied" };
152 const slowDown =
153 row.last_polled_at !== null && now.getTime() - new Date(row.last_polled_at).getTime() < DEVICE_POLL_INTERVAL_S * 1000;
154 await this.db.prepare("UPDATE device_grants SET last_polled_at = ?1 WHERE device_code_hash = ?2").bind(nowIso, deviceCodeHash).run();
155 return { kind: "pending", slowDown };
156 }
157 }
158
158 lines TYPESCRIPT