@puzzle-ui/session 0.0.0-stage → 0.5.1

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.
Files changed (61) hide show
  1. package/README.md +158 -2
  2. package/dist/LICENSE +21 -0
  3. package/dist/errors/session.errors.cjs +47 -0
  4. package/dist/errors/session.errors.cjs.map +1 -0
  5. package/dist/errors/session.errors.d.cts +19 -0
  6. package/dist/errors/session.errors.d.ts +19 -0
  7. package/dist/errors/session.errors.js +42 -0
  8. package/dist/errors/session.errors.js.map +1 -0
  9. package/dist/index.cjs +32 -0
  10. package/dist/index.d.cts +12 -0
  11. package/dist/index.d.ts +12 -0
  12. package/dist/index.js +9 -0
  13. package/dist/session-data.manager.cjs +141 -0
  14. package/dist/session-data.manager.cjs.map +1 -0
  15. package/dist/session-data.manager.d.cts +26 -0
  16. package/dist/session-data.manager.d.ts +26 -0
  17. package/dist/session-data.manager.js +141 -0
  18. package/dist/session-data.manager.js.map +1 -0
  19. package/dist/session.cookies.cjs +64 -0
  20. package/dist/session.cookies.cjs.map +1 -0
  21. package/dist/session.cookies.d.cts +10 -0
  22. package/dist/session.cookies.d.ts +10 -0
  23. package/dist/session.cookies.js +61 -0
  24. package/dist/session.cookies.js.map +1 -0
  25. package/dist/session.csrf.cjs +42 -0
  26. package/dist/session.csrf.cjs.map +1 -0
  27. package/dist/session.csrf.d.cts +6 -0
  28. package/dist/session.csrf.d.ts +6 -0
  29. package/dist/session.csrf.js +41 -0
  30. package/dist/session.csrf.js.map +1 -0
  31. package/dist/session.manager.cjs +289 -0
  32. package/dist/session.manager.cjs.map +1 -0
  33. package/dist/session.manager.d.cts +25 -0
  34. package/dist/session.manager.d.ts +25 -0
  35. package/dist/session.manager.js +289 -0
  36. package/dist/session.manager.js.map +1 -0
  37. package/dist/session.migration.cjs +163 -0
  38. package/dist/session.migration.cjs.map +1 -0
  39. package/dist/session.migration.d.cts +50 -0
  40. package/dist/session.migration.d.ts +50 -0
  41. package/dist/session.migration.js +163 -0
  42. package/dist/session.migration.js.map +1 -0
  43. package/dist/session.storage.cjs +64 -0
  44. package/dist/session.storage.cjs.map +1 -0
  45. package/dist/session.storage.d.cts +8 -0
  46. package/dist/session.storage.d.ts +8 -0
  47. package/dist/session.storage.js +58 -0
  48. package/dist/session.storage.js.map +1 -0
  49. package/dist/types/session-lock.types.d.cts +4 -0
  50. package/dist/types/session-lock.types.d.ts +4 -0
  51. package/dist/types/session-manager.types.d.cts +37 -0
  52. package/dist/types/session-manager.types.d.ts +37 -0
  53. package/dist/types/session.types.d.cts +51 -0
  54. package/dist/types/session.types.d.ts +51 -0
  55. package/dist/utils/session-crypto.cjs +29 -0
  56. package/dist/utils/session-crypto.cjs.map +1 -0
  57. package/dist/utils/session-crypto.d.cts +3 -0
  58. package/dist/utils/session-crypto.d.ts +3 -0
  59. package/dist/utils/session-crypto.js +27 -0
  60. package/dist/utils/session-crypto.js.map +1 -0
  61. package/package.json +48 -4
@@ -0,0 +1,289 @@
1
+ const require_session_errors = require("./errors/session.errors.cjs");
2
+ const require_session_crypto = require("./utils/session-crypto.cjs");
3
+ const require_session_storage = require("./session.storage.cjs");
4
+ let _puzzle_ui_storage = require("@puzzle-ui/storage");
5
+ // src/session.manager.ts
6
+ const DEFAULT_OPTIONS = {
7
+ lifetime: {
8
+ idleTimeoutSeconds: 604800,
9
+ absoluteLifetimeSeconds: 2592e3
10
+ },
11
+ maximumSessionsPerUser: 10,
12
+ touchIntervalSeconds: 60
13
+ };
14
+ const toPublicSession = (record) => ({
15
+ id: record.id,
16
+ tokenHash: record.tokenHash,
17
+ userId: record.userId,
18
+ kind: record.kind,
19
+ createdAt: record.createdAt,
20
+ lastSeenAt: record.lastSeenAt,
21
+ expiresAt: record.expiresAt,
22
+ revokedAt: record.revokedAt,
23
+ metadata: record.metadata
24
+ });
25
+ const isActive = (session, now) => session.revokedAt === null && session.expiresAt.getTime() > now.getTime();
26
+ const validateOptions = (options) => {
27
+ const { idleTimeoutSeconds, absoluteLifetimeSeconds } = options.lifetime;
28
+ if (!Number.isFinite(idleTimeoutSeconds) || !Number.isFinite(absoluteLifetimeSeconds) || idleTimeoutSeconds <= 0 || absoluteLifetimeSeconds <= 0 || idleTimeoutSeconds > absoluteLifetimeSeconds) throw new require_session_errors.SessionConfigurationError("Session lifetimes must be positive and the idle timeout cannot exceed the absolute lifetime.");
29
+ if (options.maximumSessionsPerUser !== void 0 && (!Number.isInteger(options.maximumSessionsPerUser) || options.maximumSessionsPerUser < 1)) throw new require_session_errors.SessionConfigurationError("maximumSessionsPerUser must be a positive integer.");
30
+ if (options.touchIntervalSeconds !== void 0 && (!Number.isFinite(options.touchIntervalSeconds) || options.touchIntervalSeconds < 0)) throw new require_session_errors.SessionConfigurationError("touchIntervalSeconds must be a non-negative number.");
31
+ };
32
+ var StorageSessionManager = class {
33
+ storage;
34
+ options;
35
+ collection;
36
+ clock;
37
+ constructor(storage, options = {}) {
38
+ this.storage = storage;
39
+ this.options = {
40
+ ...DEFAULT_OPTIONS,
41
+ ...options,
42
+ lifetime: {
43
+ ...DEFAULT_OPTIONS.lifetime,
44
+ ...options.lifetime
45
+ }
46
+ };
47
+ validateOptions(this.options);
48
+ this.collection = options.collectionName ?? "sessions";
49
+ this.clock = options.clock ?? (() => /* @__PURE__ */ new Date());
50
+ if (!storage.capabilities.uniqueConstraints || !storage.capabilities.conditionalWrites || !storage.capabilities.atomicBatch) throw new require_session_errors.SessionConfigurationError("The storage adapter does not support the atomicity required by session management.");
51
+ }
52
+ async initialize() {
53
+ await this.storage.registerCollection({
54
+ ...require_session_storage.sessionCollection,
55
+ name: this.collection
56
+ });
57
+ await this.storage.registerCollection(require_session_storage.sessionUserLockCollection);
58
+ }
59
+ now() {
60
+ return this.clock();
61
+ }
62
+ absoluteExpiry(createdAt) {
63
+ return new Date(createdAt.getTime() + this.options.lifetime.absoluteLifetimeSeconds * 1e3);
64
+ }
65
+ idleExpiry(lastSeenAt) {
66
+ return new Date(lastSeenAt.getTime() + this.options.lifetime.idleTimeoutSeconds * 1e3);
67
+ }
68
+ effectiveExpiry(createdAt, lastSeenAt) {
69
+ return new Date(Math.min(this.absoluteExpiry(createdAt).getTime(), this.idleExpiry(lastSeenAt).getTime()));
70
+ }
71
+ async createRecord(input, now) {
72
+ if (input.kind === "authenticated" && !input.userId) throw new require_session_errors.SessionConfigurationError("Authenticated sessions require a user ID.");
73
+ if (input.kind === "anonymous" && input.userId !== null) throw new require_session_errors.SessionConfigurationError("Anonymous sessions cannot have a user ID.");
74
+ const token = require_session_crypto.generateSessionToken();
75
+ const tokenHash = await require_session_crypto.hashSessionToken(token);
76
+ const absoluteExpiry = this.absoluteExpiry(now);
77
+ const requestedExpiry = input.expiresAt ?? absoluteExpiry;
78
+ if (!(requestedExpiry instanceof Date) || !Number.isFinite(requestedExpiry.getTime()) || requestedExpiry.getTime() <= now.getTime()) throw new require_session_errors.SessionConfigurationError("Session expiration must be a valid future date.");
79
+ const record = {
80
+ id: require_session_crypto.generateSessionId(),
81
+ tokenHash,
82
+ userId: input.userId,
83
+ kind: input.kind,
84
+ createdAt: now,
85
+ updatedAt: now,
86
+ revision: 1,
87
+ lastSeenAt: now,
88
+ expiresAt: new Date(Math.min(requestedExpiry.getTime(), absoluteExpiry.getTime(), this.idleExpiry(now).getTime())),
89
+ revokedAt: null,
90
+ metadata: input.metadata
91
+ };
92
+ return {
93
+ session: toPublicSession(record),
94
+ token
95
+ };
96
+ }
97
+ toStorageRecord(session) {
98
+ return {
99
+ ...session,
100
+ updatedAt: session.lastSeenAt,
101
+ revision: 1
102
+ };
103
+ }
104
+ async create(input) {
105
+ const created = await this.createRecord(input, this.now());
106
+ if (!input.userId) {
107
+ await this.storage.insert(this.collection, this.toStorageRecord(created.session));
108
+ return created;
109
+ }
110
+ await this.createAuthenticatedSessionAtomically(input.userId, created);
111
+ return created;
112
+ }
113
+ async createAuthenticatedSessionAtomically(userId, created) {
114
+ const maximum = this.options.maximumSessionsPerUser;
115
+ for (let attempt = 0; attempt < 8; attempt++) {
116
+ const now = this.now();
117
+ const lock = await this.storage.findOne(require_session_storage.SESSION_USER_LOCK_COLLECTION, { where: { userId: {
118
+ operator: "eq",
119
+ value: userId
120
+ } } });
121
+ const active = (await this.storage.findMany(this.collection, {
122
+ where: {
123
+ userId: {
124
+ operator: "eq",
125
+ value: userId
126
+ },
127
+ revokedAt: {
128
+ operator: "eq",
129
+ value: null
130
+ }
131
+ },
132
+ orderBy: [{
133
+ field: "lastSeenAt",
134
+ direction: "desc"
135
+ }, {
136
+ field: "createdAt",
137
+ direction: "desc"
138
+ }]
139
+ })).filter((session) => isActive(session, now));
140
+ const excess = maximum === void 0 ? [] : active.slice(Math.max(0, maximum - 1));
141
+ const operations = [];
142
+ if (lock) operations.push({
143
+ type: "update",
144
+ collection: require_session_storage.SESSION_USER_LOCK_COLLECTION,
145
+ id: lock.id,
146
+ condition: { expectedRevision: lock.revision },
147
+ changes: {}
148
+ });
149
+ else operations.push({
150
+ type: "insert",
151
+ collection: require_session_storage.SESSION_USER_LOCK_COLLECTION,
152
+ record: {
153
+ id: require_session_crypto.generateSessionId(),
154
+ userId,
155
+ createdAt: now,
156
+ updatedAt: now,
157
+ revision: 1
158
+ }
159
+ });
160
+ operations.push({
161
+ type: "insert",
162
+ collection: this.collection,
163
+ record: this.toStorageRecord(created.session)
164
+ });
165
+ for (const session of excess) operations.push({
166
+ type: "update",
167
+ collection: this.collection,
168
+ id: session.id,
169
+ condition: { expectedRevision: session.revision },
170
+ changes: { revokedAt: now }
171
+ });
172
+ try {
173
+ await this.storage.atomicBatch(operations);
174
+ return;
175
+ } catch (error) {
176
+ if (error instanceof _puzzle_ui_storage.StorageConflictError || error instanceof _puzzle_ui_storage.StorageDuplicateError) continue;
177
+ throw error;
178
+ }
179
+ }
180
+ throw new require_session_errors.SessionConfigurationError("Could not acquire the user session concurrency lock after repeated conflicts.");
181
+ }
182
+ async validate(token, options = {}) {
183
+ if (!token) return null;
184
+ const tokenHash = await require_session_crypto.hashSessionToken(token);
185
+ const session = await this.storage.findOne(this.collection, { where: { tokenHash: {
186
+ operator: "eq",
187
+ value: tokenHash
188
+ } } });
189
+ if (!session) return null;
190
+ const now = this.now();
191
+ if (!isActive(session, now)) return null;
192
+ if (this.idleExpiry(session.lastSeenAt).getTime() <= now.getTime() || this.absoluteExpiry(session.createdAt).getTime() <= now.getTime()) {
193
+ await this.revoke(session.id);
194
+ return null;
195
+ }
196
+ if (!(options.touch !== false && now.getTime() - session.lastSeenAt.getTime() >= (this.options.touchIntervalSeconds ?? 60) * 1e3)) return toPublicSession(session);
197
+ try {
198
+ const updated = await this.storage.update(this.collection, session.id, {
199
+ lastSeenAt: now,
200
+ expiresAt: this.effectiveExpiry(session.createdAt, now)
201
+ }, { expectedRevision: session.revision });
202
+ return updated ? toPublicSession(updated) : null;
203
+ } catch (error) {
204
+ if (error instanceof _puzzle_ui_storage.StorageConflictError) return null;
205
+ throw error;
206
+ }
207
+ }
208
+ async get(sessionId) {
209
+ const session = await this.storage.findById(this.collection, sessionId);
210
+ if (!session || !isActive(session, this.now())) return null;
211
+ const now = this.now();
212
+ if (this.idleExpiry(session.lastSeenAt).getTime() <= now.getTime() || this.absoluteExpiry(session.createdAt).getTime() <= now.getTime()) {
213
+ await this.revoke(sessionId);
214
+ return null;
215
+ }
216
+ return toPublicSession(session);
217
+ }
218
+ async rotate(input) {
219
+ const current = await this.storage.findById(this.collection, input.sessionId);
220
+ const now = this.now();
221
+ if (!current || !isActive(current, now) || this.idleExpiry(current.lastSeenAt).getTime() <= now.getTime() || this.absoluteExpiry(current.createdAt).getTime() <= now.getTime()) throw new require_session_errors.SessionNotFoundError();
222
+ const created = await this.createRecord({
223
+ userId: current.userId,
224
+ kind: current.kind,
225
+ metadata: input.metadata ?? current.metadata
226
+ }, now);
227
+ const operations = [{
228
+ type: "insert",
229
+ collection: this.collection,
230
+ record: this.toStorageRecord(created.session)
231
+ }, {
232
+ type: "update",
233
+ collection: this.collection,
234
+ id: current.id,
235
+ condition: { expectedRevision: current.revision },
236
+ changes: { revokedAt: now }
237
+ }];
238
+ await this.storage.atomicBatch(operations);
239
+ return created;
240
+ }
241
+ async revoke(sessionId) {
242
+ const session = await this.storage.findById(this.collection, sessionId);
243
+ if (!session || session.revokedAt) return;
244
+ await this.storage.update(this.collection, sessionId, { revokedAt: this.now() }, { expectedRevision: session.revision });
245
+ }
246
+ async revokeAll(userId) {
247
+ if (!userId) throw new require_session_errors.SessionConfigurationError("A user ID is required to revoke sessions.");
248
+ await this.storage.updateMany(this.collection, { where: {
249
+ userId: {
250
+ operator: "eq",
251
+ value: userId
252
+ },
253
+ revokedAt: {
254
+ operator: "eq",
255
+ value: null
256
+ }
257
+ } }, { revokedAt: this.now() });
258
+ }
259
+ async list(userId, currentSessionId) {
260
+ const now = this.now();
261
+ return (await this.storage.findMany(this.collection, {
262
+ where: {
263
+ userId: {
264
+ operator: "eq",
265
+ value: userId
266
+ },
267
+ revokedAt: {
268
+ operator: "eq",
269
+ value: null
270
+ }
271
+ },
272
+ orderBy: [{
273
+ field: "lastSeenAt",
274
+ direction: "desc"
275
+ }]
276
+ })).filter((session) => isActive(session, now) && this.idleExpiry(session.lastSeenAt).getTime() > now.getTime() && this.absoluteExpiry(session.createdAt).getTime() > now.getTime()).map((session) => ({
277
+ sessionId: session.id,
278
+ kind: session.kind,
279
+ createdAt: session.createdAt,
280
+ lastSeenAt: session.lastSeenAt,
281
+ expiresAt: session.expiresAt,
282
+ current: session.id === currentSessionId,
283
+ metadata: session.metadata
284
+ }));
285
+ }
286
+ };
287
+ exports.StorageSessionManager = StorageSessionManager;
288
+
289
+ //# sourceMappingURL=session.manager.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.manager.cjs","names":["SessionConfigurationError","sessionCollection","sessionUserLockCollection","generateSessionToken","hashSessionToken","generateSessionId","SESSION_USER_LOCK_COLLECTION","StorageConflictError","StorageDuplicateError","SessionNotFoundError"],"sources":["../src/session.manager.ts"],"sourcesContent":["import {\n StorageConflictError,\n StorageDuplicateError,\n type StorageAdapter,\n type StorageBatchOperation,\n} from \"@puzzle-ui/storage\";\n\nimport {\n SessionConfigurationError,\n SessionNotFoundError,\n} from \"./errors/session.errors\";\n\nimport {\n generateSessionId,\n generateSessionToken,\n hashSessionToken,\n} from \"./utils/session-crypto\";\n\nimport {\n SESSION_COLLECTION,\n SESSION_USER_LOCK_COLLECTION,\n sessionCollection,\n sessionUserLockCollection,\n} from \"./session.storage\";\n\nimport type {\n SessionDevice,\n SessionRecord,\n SessionStorageRecord,\n} from \"./types/session.types\";\n\nimport type { SessionUserLockRecord } from \"./types/session-lock.types\";\n\nimport type {\n CreatedSession,\n CreateSessionInput,\n SessionManager,\n SessionManagerOptions,\n ValidateSessionOptions,\n RotateSessionInput,\n} from \"./types/session-manager.types\";\n\nconst DEFAULT_OPTIONS: SessionManagerOptions = {\n lifetime: {\n idleTimeoutSeconds: 60 * 60 * 24 * 7,\n absoluteLifetimeSeconds: 60 * 60 * 24 * 30,\n },\n maximumSessionsPerUser: 10,\n touchIntervalSeconds: 60,\n};\n\nconst toPublicSession = (record: SessionStorageRecord): SessionRecord => ({\n id: record.id,\n tokenHash: record.tokenHash,\n userId: record.userId,\n kind: record.kind,\n createdAt: record.createdAt,\n lastSeenAt: record.lastSeenAt,\n expiresAt: record.expiresAt,\n revokedAt: record.revokedAt,\n metadata: record.metadata,\n});\n\nconst isActive = (session: SessionStorageRecord, now: Date): boolean =>\n session.revokedAt === null && session.expiresAt.getTime() > now.getTime();\n\nconst validateOptions = (options: SessionManagerOptions): void => {\n const { idleTimeoutSeconds, absoluteLifetimeSeconds } = options.lifetime;\n\n if (\n !Number.isFinite(idleTimeoutSeconds) ||\n !Number.isFinite(absoluteLifetimeSeconds) ||\n idleTimeoutSeconds <= 0 ||\n absoluteLifetimeSeconds <= 0 ||\n idleTimeoutSeconds > absoluteLifetimeSeconds\n ) {\n throw new SessionConfigurationError(\n \"Session lifetimes must be positive and the idle timeout cannot exceed the absolute lifetime.\",\n );\n }\n\n if (\n options.maximumSessionsPerUser !== undefined &&\n (!Number.isInteger(options.maximumSessionsPerUser) ||\n options.maximumSessionsPerUser < 1)\n ) {\n throw new SessionConfigurationError(\n \"maximumSessionsPerUser must be a positive integer.\",\n );\n }\n\n if (\n options.touchIntervalSeconds !== undefined &&\n (!Number.isFinite(options.touchIntervalSeconds) ||\n options.touchIntervalSeconds < 0)\n ) {\n throw new SessionConfigurationError(\n \"touchIntervalSeconds must be a non-negative number.\",\n );\n }\n};\n\nexport class StorageSessionManager implements SessionManager {\n private readonly options: SessionManagerOptions;\n private readonly collection: string;\n private readonly clock: () => Date;\n\n constructor(\n private readonly storage: StorageAdapter,\n options: Partial<SessionManagerOptions> = {},\n ) {\n this.options = {\n ...DEFAULT_OPTIONS,\n ...options,\n lifetime: {\n ...DEFAULT_OPTIONS.lifetime,\n ...options.lifetime,\n },\n };\n\n validateOptions(this.options);\n\n this.collection = options.collectionName ?? SESSION_COLLECTION;\n this.clock = options.clock ?? (() => new Date());\n\n if (\n !storage.capabilities.uniqueConstraints ||\n !storage.capabilities.conditionalWrites ||\n !storage.capabilities.atomicBatch\n ) {\n throw new SessionConfigurationError(\n \"The storage adapter does not support the atomicity required by session management.\",\n );\n }\n }\n\n async initialize(): Promise<void> {\n await this.storage.registerCollection({\n ...sessionCollection,\n name: this.collection,\n });\n\n await this.storage.registerCollection(sessionUserLockCollection);\n }\n\n private now(): Date {\n return this.clock();\n }\n\n private absoluteExpiry(createdAt: Date): Date {\n return new Date(\n createdAt.getTime() +\n this.options.lifetime.absoluteLifetimeSeconds * 1000,\n );\n }\n\n private idleExpiry(lastSeenAt: Date): Date {\n return new Date(\n lastSeenAt.getTime() + this.options.lifetime.idleTimeoutSeconds * 1000,\n );\n }\n\n private effectiveExpiry(createdAt: Date, lastSeenAt: Date): Date {\n return new Date(\n Math.min(\n this.absoluteExpiry(createdAt).getTime(),\n this.idleExpiry(lastSeenAt).getTime(),\n ),\n );\n }\n\n private async createRecord(\n input: CreateSessionInput,\n now: Date,\n ): Promise<CreatedSession> {\n if (input.kind === \"authenticated\" && !input.userId) {\n throw new SessionConfigurationError(\n \"Authenticated sessions require a user ID.\",\n );\n }\n\n if (input.kind === \"anonymous\" && input.userId !== null) {\n throw new SessionConfigurationError(\n \"Anonymous sessions cannot have a user ID.\",\n );\n }\n\n const token = generateSessionToken();\n const tokenHash = await hashSessionToken(token);\n\n const absoluteExpiry = this.absoluteExpiry(now);\n const requestedExpiry = input.expiresAt ?? absoluteExpiry;\n\n if (\n !(requestedExpiry instanceof Date) ||\n !Number.isFinite(requestedExpiry.getTime()) ||\n requestedExpiry.getTime() <= now.getTime()\n ) {\n throw new SessionConfigurationError(\n \"Session expiration must be a valid future date.\",\n );\n }\n\n const record: SessionStorageRecord = {\n id: generateSessionId(),\n tokenHash,\n userId: input.userId,\n kind: input.kind,\n createdAt: now,\n updatedAt: now,\n revision: 1,\n lastSeenAt: now,\n expiresAt: new Date(\n Math.min(\n requestedExpiry.getTime(),\n absoluteExpiry.getTime(),\n this.idleExpiry(now).getTime(),\n ),\n ),\n revokedAt: null,\n metadata: input.metadata,\n };\n\n return {\n session: toPublicSession(record),\n token,\n };\n }\n\n private toStorageRecord(session: SessionRecord): SessionStorageRecord {\n return {\n ...session,\n updatedAt: session.lastSeenAt,\n revision: 1,\n };\n }\n\n async create(input: CreateSessionInput): Promise<CreatedSession> {\n const created = await this.createRecord(input, this.now());\n\n if (!input.userId) {\n await this.storage.insert<SessionStorageRecord>(\n this.collection,\n this.toStorageRecord(created.session),\n );\n\n return created;\n }\n\n await this.createAuthenticatedSessionAtomically(input.userId, created);\n\n return created;\n }\n\n private async createAuthenticatedSessionAtomically(\n userId: string,\n created: CreatedSession,\n ): Promise<void> {\n const maximum = this.options.maximumSessionsPerUser;\n\n for (let attempt = 0; attempt < 8; attempt++) {\n const now = this.now();\n\n const lock = await this.storage.findOne<SessionUserLockRecord>(\n SESSION_USER_LOCK_COLLECTION,\n {\n where: {\n userId: { operator: \"eq\", value: userId },\n },\n },\n );\n\n const sessions = await this.storage.findMany<SessionStorageRecord>(\n this.collection,\n {\n where: {\n userId: { operator: \"eq\", value: userId },\n revokedAt: { operator: \"eq\", value: null },\n },\n orderBy: [\n { field: \"lastSeenAt\", direction: \"desc\" },\n { field: \"createdAt\", direction: \"desc\" },\n ],\n },\n );\n\n const active = sessions.filter((session) => isActive(session, now));\n\n const excess =\n maximum === undefined ? [] : active.slice(Math.max(0, maximum - 1));\n\n const operations: (\n | StorageBatchOperation<SessionStorageRecord>\n | StorageBatchOperation<SessionUserLockRecord>\n )[] = [];\n\n if (lock) {\n operations.push({\n type: \"update\",\n collection: SESSION_USER_LOCK_COLLECTION,\n id: lock.id,\n condition: {\n expectedRevision: lock.revision,\n },\n changes: {},\n });\n } else {\n operations.push({\n type: \"insert\",\n collection: SESSION_USER_LOCK_COLLECTION,\n record: {\n id: generateSessionId(),\n userId,\n createdAt: now,\n updatedAt: now,\n revision: 1,\n },\n });\n }\n\n operations.push({\n type: \"insert\",\n collection: this.collection,\n record: this.toStorageRecord(created.session),\n });\n\n for (const session of excess) {\n operations.push({\n type: \"update\",\n collection: this.collection,\n id: session.id,\n condition: {\n expectedRevision: session.revision,\n },\n changes: {\n revokedAt: now,\n },\n });\n }\n\n try {\n await this.storage.atomicBatch<\n SessionStorageRecord | SessionUserLockRecord\n >(operations);\n\n return;\n } catch (error) {\n if (\n error instanceof StorageConflictError ||\n error instanceof StorageDuplicateError\n ) {\n continue;\n }\n\n throw error;\n }\n }\n\n throw new SessionConfigurationError(\n \"Could not acquire the user session concurrency lock after repeated conflicts.\",\n );\n }\n\n async validate(\n token: string,\n options: ValidateSessionOptions = {},\n ): Promise<SessionRecord | null> {\n if (!token) return null;\n\n const tokenHash = await hashSessionToken(token);\n\n const session = await this.storage.findOne<SessionStorageRecord>(\n this.collection,\n {\n where: {\n tokenHash: { operator: \"eq\", value: tokenHash },\n },\n },\n );\n\n if (!session) return null;\n\n const now = this.now();\n\n if (!isActive(session, now)) return null;\n\n if (\n this.idleExpiry(session.lastSeenAt).getTime() <= now.getTime() ||\n this.absoluteExpiry(session.createdAt).getTime() <= now.getTime()\n ) {\n await this.revoke(session.id);\n return null;\n }\n\n const touch =\n options.touch !== false &&\n now.getTime() - session.lastSeenAt.getTime() >=\n (this.options.touchIntervalSeconds ?? 60) * 1000;\n\n if (!touch) return toPublicSession(session);\n\n try {\n const updated = await this.storage.update<SessionStorageRecord>(\n this.collection,\n session.id,\n {\n lastSeenAt: now,\n expiresAt: this.effectiveExpiry(session.createdAt, now),\n },\n { expectedRevision: session.revision },\n );\n\n return updated ? toPublicSession(updated) : null;\n } catch (error) {\n if (error instanceof StorageConflictError) {\n return null;\n }\n\n throw error;\n }\n }\n\n async get(sessionId: string): Promise<SessionRecord | null> {\n const session = await this.storage.findById<SessionStorageRecord>(\n this.collection,\n sessionId,\n );\n\n if (!session || !isActive(session, this.now())) return null;\n\n const now = this.now();\n\n if (\n this.idleExpiry(session.lastSeenAt).getTime() <= now.getTime() ||\n this.absoluteExpiry(session.createdAt).getTime() <= now.getTime()\n ) {\n await this.revoke(sessionId);\n return null;\n }\n\n return toPublicSession(session);\n }\n\n async rotate(input: RotateSessionInput): Promise<CreatedSession> {\n const current = await this.storage.findById<SessionStorageRecord>(\n this.collection,\n input.sessionId,\n );\n\n const now = this.now();\n\n if (\n !current ||\n !isActive(current, now) ||\n this.idleExpiry(current.lastSeenAt).getTime() <= now.getTime() ||\n this.absoluteExpiry(current.createdAt).getTime() <= now.getTime()\n ) {\n throw new SessionNotFoundError();\n }\n\n const created = await this.createRecord(\n {\n userId: current.userId,\n kind: current.kind,\n metadata: input.metadata ?? current.metadata,\n },\n now,\n );\n\n const operations: StorageBatchOperation<SessionStorageRecord>[] = [\n {\n type: \"insert\",\n collection: this.collection,\n record: this.toStorageRecord(created.session),\n },\n {\n type: \"update\",\n collection: this.collection,\n id: current.id,\n condition: {\n expectedRevision: current.revision,\n },\n changes: {\n revokedAt: now,\n },\n },\n ];\n\n await this.storage.atomicBatch(operations);\n\n return created;\n }\n\n async revoke(sessionId: string): Promise<void> {\n const session = await this.storage.findById<SessionStorageRecord>(\n this.collection,\n sessionId,\n );\n\n if (!session || session.revokedAt) return;\n\n await this.storage.update<SessionStorageRecord>(\n this.collection,\n sessionId,\n { revokedAt: this.now() },\n { expectedRevision: session.revision },\n );\n }\n\n async revokeAll(userId: string): Promise<void> {\n if (!userId) {\n throw new SessionConfigurationError(\n \"A user ID is required to revoke sessions.\",\n );\n }\n\n await this.storage.updateMany<SessionStorageRecord>(\n this.collection,\n {\n where: {\n userId: { operator: \"eq\", value: userId },\n revokedAt: { operator: \"eq\", value: null },\n },\n },\n { revokedAt: this.now() },\n );\n }\n\n async list(\n userId: string,\n currentSessionId?: string,\n ): Promise<readonly SessionDevice[]> {\n const now = this.now();\n\n const sessions = await this.storage.findMany<SessionStorageRecord>(\n this.collection,\n {\n where: {\n userId: { operator: \"eq\", value: userId },\n revokedAt: { operator: \"eq\", value: null },\n },\n orderBy: [{ field: \"lastSeenAt\", direction: \"desc\" }],\n },\n );\n\n return sessions\n .filter(\n (session) =>\n isActive(session, now) &&\n this.idleExpiry(session.lastSeenAt).getTime() > now.getTime() &&\n this.absoluteExpiry(session.createdAt).getTime() > now.getTime(),\n )\n .map((session) => ({\n sessionId: session.id,\n kind: session.kind,\n createdAt: session.createdAt,\n lastSeenAt: session.lastSeenAt,\n expiresAt: session.expiresAt,\n current: session.id === currentSessionId,\n metadata: session.metadata,\n }));\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,25 @@
1
+ import { SessionDevice, SessionRecord } from "./types/session.types.cjs";
2
+ import { CreateSessionInput, CreatedSession, RotateSessionInput, SessionManager, SessionManagerOptions, ValidateSessionOptions } from "./types/session-manager.types.cjs";
3
+ import { StorageAdapter } from "@puzzle-ui/storage";
4
+ export declare class StorageSessionManager implements SessionManager {
5
+ private readonly storage;
6
+ private readonly options;
7
+ private readonly collection;
8
+ private readonly clock;
9
+ constructor(storage: StorageAdapter, options?: Partial<SessionManagerOptions>);
10
+ initialize(): Promise<void>;
11
+ private now;
12
+ private absoluteExpiry;
13
+ private idleExpiry;
14
+ private effectiveExpiry;
15
+ private createRecord;
16
+ private toStorageRecord;
17
+ create(input: CreateSessionInput): Promise<CreatedSession>;
18
+ private createAuthenticatedSessionAtomically;
19
+ validate(token: string, options?: ValidateSessionOptions): Promise<SessionRecord | null>;
20
+ get(sessionId: string): Promise<SessionRecord | null>;
21
+ rotate(input: RotateSessionInput): Promise<CreatedSession>;
22
+ revoke(sessionId: string): Promise<void>;
23
+ revokeAll(userId: string): Promise<void>;
24
+ list(userId: string, currentSessionId?: string): Promise<readonly SessionDevice[]>;
25
+ }
@@ -0,0 +1,25 @@
1
+ import { SessionDevice, SessionRecord } from "./types/session.types.js";
2
+ import { CreateSessionInput, CreatedSession, RotateSessionInput, SessionManager, SessionManagerOptions, ValidateSessionOptions } from "./types/session-manager.types.js";
3
+ import { StorageAdapter } from "@puzzle-ui/storage";
4
+ export declare class StorageSessionManager implements SessionManager {
5
+ private readonly storage;
6
+ private readonly options;
7
+ private readonly collection;
8
+ private readonly clock;
9
+ constructor(storage: StorageAdapter, options?: Partial<SessionManagerOptions>);
10
+ initialize(): Promise<void>;
11
+ private now;
12
+ private absoluteExpiry;
13
+ private idleExpiry;
14
+ private effectiveExpiry;
15
+ private createRecord;
16
+ private toStorageRecord;
17
+ create(input: CreateSessionInput): Promise<CreatedSession>;
18
+ private createAuthenticatedSessionAtomically;
19
+ validate(token: string, options?: ValidateSessionOptions): Promise<SessionRecord | null>;
20
+ get(sessionId: string): Promise<SessionRecord | null>;
21
+ rotate(input: RotateSessionInput): Promise<CreatedSession>;
22
+ revoke(sessionId: string): Promise<void>;
23
+ revokeAll(userId: string): Promise<void>;
24
+ list(userId: string, currentSessionId?: string): Promise<readonly SessionDevice[]>;
25
+ }
@@ -0,0 +1,289 @@
1
+ import { SessionConfigurationError, SessionNotFoundError } from "./errors/session.errors.js";
2
+ import { generateSessionId, generateSessionToken, hashSessionToken } from "./utils/session-crypto.js";
3
+ import { SESSION_USER_LOCK_COLLECTION, sessionCollection, sessionUserLockCollection } from "./session.storage.js";
4
+ import { StorageConflictError, StorageDuplicateError } from "@puzzle-ui/storage";
5
+ // src/session.manager.ts
6
+ const DEFAULT_OPTIONS = {
7
+ lifetime: {
8
+ idleTimeoutSeconds: 604800,
9
+ absoluteLifetimeSeconds: 2592e3
10
+ },
11
+ maximumSessionsPerUser: 10,
12
+ touchIntervalSeconds: 60
13
+ };
14
+ const toPublicSession = (record) => ({
15
+ id: record.id,
16
+ tokenHash: record.tokenHash,
17
+ userId: record.userId,
18
+ kind: record.kind,
19
+ createdAt: record.createdAt,
20
+ lastSeenAt: record.lastSeenAt,
21
+ expiresAt: record.expiresAt,
22
+ revokedAt: record.revokedAt,
23
+ metadata: record.metadata
24
+ });
25
+ const isActive = (session, now) => session.revokedAt === null && session.expiresAt.getTime() > now.getTime();
26
+ const validateOptions = (options) => {
27
+ const { idleTimeoutSeconds, absoluteLifetimeSeconds } = options.lifetime;
28
+ if (!Number.isFinite(idleTimeoutSeconds) || !Number.isFinite(absoluteLifetimeSeconds) || idleTimeoutSeconds <= 0 || absoluteLifetimeSeconds <= 0 || idleTimeoutSeconds > absoluteLifetimeSeconds) throw new SessionConfigurationError("Session lifetimes must be positive and the idle timeout cannot exceed the absolute lifetime.");
29
+ if (options.maximumSessionsPerUser !== void 0 && (!Number.isInteger(options.maximumSessionsPerUser) || options.maximumSessionsPerUser < 1)) throw new SessionConfigurationError("maximumSessionsPerUser must be a positive integer.");
30
+ if (options.touchIntervalSeconds !== void 0 && (!Number.isFinite(options.touchIntervalSeconds) || options.touchIntervalSeconds < 0)) throw new SessionConfigurationError("touchIntervalSeconds must be a non-negative number.");
31
+ };
32
+ var StorageSessionManager = class {
33
+ storage;
34
+ options;
35
+ collection;
36
+ clock;
37
+ constructor(storage, options = {}) {
38
+ this.storage = storage;
39
+ this.options = {
40
+ ...DEFAULT_OPTIONS,
41
+ ...options,
42
+ lifetime: {
43
+ ...DEFAULT_OPTIONS.lifetime,
44
+ ...options.lifetime
45
+ }
46
+ };
47
+ validateOptions(this.options);
48
+ this.collection = options.collectionName ?? "sessions";
49
+ this.clock = options.clock ?? (() => /* @__PURE__ */ new Date());
50
+ if (!storage.capabilities.uniqueConstraints || !storage.capabilities.conditionalWrites || !storage.capabilities.atomicBatch) throw new SessionConfigurationError("The storage adapter does not support the atomicity required by session management.");
51
+ }
52
+ async initialize() {
53
+ await this.storage.registerCollection({
54
+ ...sessionCollection,
55
+ name: this.collection
56
+ });
57
+ await this.storage.registerCollection(sessionUserLockCollection);
58
+ }
59
+ now() {
60
+ return this.clock();
61
+ }
62
+ absoluteExpiry(createdAt) {
63
+ return new Date(createdAt.getTime() + this.options.lifetime.absoluteLifetimeSeconds * 1e3);
64
+ }
65
+ idleExpiry(lastSeenAt) {
66
+ return new Date(lastSeenAt.getTime() + this.options.lifetime.idleTimeoutSeconds * 1e3);
67
+ }
68
+ effectiveExpiry(createdAt, lastSeenAt) {
69
+ return new Date(Math.min(this.absoluteExpiry(createdAt).getTime(), this.idleExpiry(lastSeenAt).getTime()));
70
+ }
71
+ async createRecord(input, now) {
72
+ if (input.kind === "authenticated" && !input.userId) throw new SessionConfigurationError("Authenticated sessions require a user ID.");
73
+ if (input.kind === "anonymous" && input.userId !== null) throw new SessionConfigurationError("Anonymous sessions cannot have a user ID.");
74
+ const token = generateSessionToken();
75
+ const tokenHash = await hashSessionToken(token);
76
+ const absoluteExpiry = this.absoluteExpiry(now);
77
+ const requestedExpiry = input.expiresAt ?? absoluteExpiry;
78
+ if (!(requestedExpiry instanceof Date) || !Number.isFinite(requestedExpiry.getTime()) || requestedExpiry.getTime() <= now.getTime()) throw new SessionConfigurationError("Session expiration must be a valid future date.");
79
+ const record = {
80
+ id: generateSessionId(),
81
+ tokenHash,
82
+ userId: input.userId,
83
+ kind: input.kind,
84
+ createdAt: now,
85
+ updatedAt: now,
86
+ revision: 1,
87
+ lastSeenAt: now,
88
+ expiresAt: new Date(Math.min(requestedExpiry.getTime(), absoluteExpiry.getTime(), this.idleExpiry(now).getTime())),
89
+ revokedAt: null,
90
+ metadata: input.metadata
91
+ };
92
+ return {
93
+ session: toPublicSession(record),
94
+ token
95
+ };
96
+ }
97
+ toStorageRecord(session) {
98
+ return {
99
+ ...session,
100
+ updatedAt: session.lastSeenAt,
101
+ revision: 1
102
+ };
103
+ }
104
+ async create(input) {
105
+ const created = await this.createRecord(input, this.now());
106
+ if (!input.userId) {
107
+ await this.storage.insert(this.collection, this.toStorageRecord(created.session));
108
+ return created;
109
+ }
110
+ await this.createAuthenticatedSessionAtomically(input.userId, created);
111
+ return created;
112
+ }
113
+ async createAuthenticatedSessionAtomically(userId, created) {
114
+ const maximum = this.options.maximumSessionsPerUser;
115
+ for (let attempt = 0; attempt < 8; attempt++) {
116
+ const now = this.now();
117
+ const lock = await this.storage.findOne(SESSION_USER_LOCK_COLLECTION, { where: { userId: {
118
+ operator: "eq",
119
+ value: userId
120
+ } } });
121
+ const active = (await this.storage.findMany(this.collection, {
122
+ where: {
123
+ userId: {
124
+ operator: "eq",
125
+ value: userId
126
+ },
127
+ revokedAt: {
128
+ operator: "eq",
129
+ value: null
130
+ }
131
+ },
132
+ orderBy: [{
133
+ field: "lastSeenAt",
134
+ direction: "desc"
135
+ }, {
136
+ field: "createdAt",
137
+ direction: "desc"
138
+ }]
139
+ })).filter((session) => isActive(session, now));
140
+ const excess = maximum === void 0 ? [] : active.slice(Math.max(0, maximum - 1));
141
+ const operations = [];
142
+ if (lock) operations.push({
143
+ type: "update",
144
+ collection: SESSION_USER_LOCK_COLLECTION,
145
+ id: lock.id,
146
+ condition: { expectedRevision: lock.revision },
147
+ changes: {}
148
+ });
149
+ else operations.push({
150
+ type: "insert",
151
+ collection: SESSION_USER_LOCK_COLLECTION,
152
+ record: {
153
+ id: generateSessionId(),
154
+ userId,
155
+ createdAt: now,
156
+ updatedAt: now,
157
+ revision: 1
158
+ }
159
+ });
160
+ operations.push({
161
+ type: "insert",
162
+ collection: this.collection,
163
+ record: this.toStorageRecord(created.session)
164
+ });
165
+ for (const session of excess) operations.push({
166
+ type: "update",
167
+ collection: this.collection,
168
+ id: session.id,
169
+ condition: { expectedRevision: session.revision },
170
+ changes: { revokedAt: now }
171
+ });
172
+ try {
173
+ await this.storage.atomicBatch(operations);
174
+ return;
175
+ } catch (error) {
176
+ if (error instanceof StorageConflictError || error instanceof StorageDuplicateError) continue;
177
+ throw error;
178
+ }
179
+ }
180
+ throw new SessionConfigurationError("Could not acquire the user session concurrency lock after repeated conflicts.");
181
+ }
182
+ async validate(token, options = {}) {
183
+ if (!token) return null;
184
+ const tokenHash = await hashSessionToken(token);
185
+ const session = await this.storage.findOne(this.collection, { where: { tokenHash: {
186
+ operator: "eq",
187
+ value: tokenHash
188
+ } } });
189
+ if (!session) return null;
190
+ const now = this.now();
191
+ if (!isActive(session, now)) return null;
192
+ if (this.idleExpiry(session.lastSeenAt).getTime() <= now.getTime() || this.absoluteExpiry(session.createdAt).getTime() <= now.getTime()) {
193
+ await this.revoke(session.id);
194
+ return null;
195
+ }
196
+ if (!(options.touch !== false && now.getTime() - session.lastSeenAt.getTime() >= (this.options.touchIntervalSeconds ?? 60) * 1e3)) return toPublicSession(session);
197
+ try {
198
+ const updated = await this.storage.update(this.collection, session.id, {
199
+ lastSeenAt: now,
200
+ expiresAt: this.effectiveExpiry(session.createdAt, now)
201
+ }, { expectedRevision: session.revision });
202
+ return updated ? toPublicSession(updated) : null;
203
+ } catch (error) {
204
+ if (error instanceof StorageConflictError) return null;
205
+ throw error;
206
+ }
207
+ }
208
+ async get(sessionId) {
209
+ const session = await this.storage.findById(this.collection, sessionId);
210
+ if (!session || !isActive(session, this.now())) return null;
211
+ const now = this.now();
212
+ if (this.idleExpiry(session.lastSeenAt).getTime() <= now.getTime() || this.absoluteExpiry(session.createdAt).getTime() <= now.getTime()) {
213
+ await this.revoke(sessionId);
214
+ return null;
215
+ }
216
+ return toPublicSession(session);
217
+ }
218
+ async rotate(input) {
219
+ const current = await this.storage.findById(this.collection, input.sessionId);
220
+ const now = this.now();
221
+ if (!current || !isActive(current, now) || this.idleExpiry(current.lastSeenAt).getTime() <= now.getTime() || this.absoluteExpiry(current.createdAt).getTime() <= now.getTime()) throw new SessionNotFoundError();
222
+ const created = await this.createRecord({
223
+ userId: current.userId,
224
+ kind: current.kind,
225
+ metadata: input.metadata ?? current.metadata
226
+ }, now);
227
+ const operations = [{
228
+ type: "insert",
229
+ collection: this.collection,
230
+ record: this.toStorageRecord(created.session)
231
+ }, {
232
+ type: "update",
233
+ collection: this.collection,
234
+ id: current.id,
235
+ condition: { expectedRevision: current.revision },
236
+ changes: { revokedAt: now }
237
+ }];
238
+ await this.storage.atomicBatch(operations);
239
+ return created;
240
+ }
241
+ async revoke(sessionId) {
242
+ const session = await this.storage.findById(this.collection, sessionId);
243
+ if (!session || session.revokedAt) return;
244
+ await this.storage.update(this.collection, sessionId, { revokedAt: this.now() }, { expectedRevision: session.revision });
245
+ }
246
+ async revokeAll(userId) {
247
+ if (!userId) throw new SessionConfigurationError("A user ID is required to revoke sessions.");
248
+ await this.storage.updateMany(this.collection, { where: {
249
+ userId: {
250
+ operator: "eq",
251
+ value: userId
252
+ },
253
+ revokedAt: {
254
+ operator: "eq",
255
+ value: null
256
+ }
257
+ } }, { revokedAt: this.now() });
258
+ }
259
+ async list(userId, currentSessionId) {
260
+ const now = this.now();
261
+ return (await this.storage.findMany(this.collection, {
262
+ where: {
263
+ userId: {
264
+ operator: "eq",
265
+ value: userId
266
+ },
267
+ revokedAt: {
268
+ operator: "eq",
269
+ value: null
270
+ }
271
+ },
272
+ orderBy: [{
273
+ field: "lastSeenAt",
274
+ direction: "desc"
275
+ }]
276
+ })).filter((session) => isActive(session, now) && this.idleExpiry(session.lastSeenAt).getTime() > now.getTime() && this.absoluteExpiry(session.createdAt).getTime() > now.getTime()).map((session) => ({
277
+ sessionId: session.id,
278
+ kind: session.kind,
279
+ createdAt: session.createdAt,
280
+ lastSeenAt: session.lastSeenAt,
281
+ expiresAt: session.expiresAt,
282
+ current: session.id === currentSessionId,
283
+ metadata: session.metadata
284
+ }));
285
+ }
286
+ };
287
+ export { StorageSessionManager };
288
+
289
+ //# sourceMappingURL=session.manager.js.map