1
9
10import { randomUUID } from "node:crypto";
11import { TOKEN_LIFETIMES } from "@factory/shared/constants";
12
13export interface Session {
14 id: string;
15 userId: string;
16
17 refreshTokenHash: string;
18 familyId: string;
19 userAgent?: string;
20 ip?: string;
21 createdAt: number;
22 lastActiveAt: number;
23 expiresAt: number;
24 revokedAt?: number;
25}
26
27export interface SessionStore {
28 put(session: Session): Promise<void>;
29 get(id: string): Promise<Session | null>;
30 listByUser(userId: string): Promise<Session[]>;
31 delete(id: string): Promise<void>;
32
38 listAll?(): Promise<Session[]>;
39}
40
41export interface CreateSessionOptions {
42 userId: string;
43 refreshTokenHash: string;
44 familyId: string;
45 userAgent?: string;
46 ip?: string;
47 ttlMs?: number;
48 nowMs?: number;
49}
50
51export class SessionManager {
52 readonly store: SessionStore;
53
54 constructor(store: SessionStore) {
55 this.store = store;
56 }
57
58 async create(opts: CreateSessionOptions): Promise<Session> {
59 const now = opts.nowMs ?? Date.now();
60 const ttl = opts.ttlMs ?? TOKEN_LIFETIMES.REFRESH_TOKEN_MS;
61 const session: Session = {
62 id: `sess_${randomUUID().replace(/-/g, "")}`,
63 userId: opts.userId,
64 refreshTokenHash: opts.refreshTokenHash,
65 familyId: opts.familyId,
66 createdAt: now,
67 lastActiveAt: now,
68 expiresAt: now + ttl
69 };
70 if (opts.userAgent !== undefined) session.userAgent = opts.userAgent;
71 if (opts.ip !== undefined) session.ip = opts.ip;
72 await this.store.put(session);
73 return session;
74 }
75
76 get(id: string): Promise<Session | null> {
77 return this.store.get(id);
78 }
79
80
81 async touch(id: string, nowMs: number = Date.now()): Promise<Session | null> {
82 const s = await this.store.get(id);
83 if (!s || s.revokedAt !== undefined) return null;
84 const next: Session = { ...s, lastActiveAt: nowMs };
85 await this.store.put(next);
86 return next;
87 }
88
89 async revoke(id: string, nowMs: number = Date.now()): Promise<boolean> {
90 const s = await this.store.get(id);
91 if (!s || s.revokedAt !== undefined) return false;
92 await this.store.put({ ...s, revokedAt: nowMs });
93 return true;
94 }
95
96
97 async revokeAllForUser(userId: string, nowMs: number = Date.now()): Promise<number> {
98 const sessions = await this.store.listByUser(userId);
99 let n = 0;
100 for (const s of sessions) {
101 if (s.revokedAt === undefined) {
102 await this.store.put({ ...s, revokedAt: nowMs });
103 n += 1;
104 }
105 }
106 return n;
107 }
108
109
110 async pruneExpired(nowMs: number = Date.now()): Promise<number> {
111
112
113
114 const store = this.store as SessionStore & { listAll?: () => Promise<Session[]> };
115 if (typeof store.listAll !== "function") return 0;
116 const all = await store.listAll();
117 let n = 0;
118 for (const s of all) {
119 if ((s.revokedAt !== undefined || s.expiresAt <= nowMs)) {
120 await this.store.delete(s.id);
121 n += 1;
122 }
123 }
124 return n;
125 }
126}
127