1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
6import * as SqlClient from "effect/sql/SqlClient";
7import * as SqlSchema from "effect/sql/SqlSchema";
8
9import {
10 AuthClientMetadataDeviceType,
11 AuthEnvironmentScopes,
12 AuthSessionId,
13 ClientSurface,
14 ServerAuthSessionMethod,
15} from "@t3tools/contracts";
16
17import {
18 type AuthSessionRepositoryError,
19 PersistenceDecodeError,
20 type PersistenceErrorCorrelation,
21 PersistenceSqlError,
22} from "./Errors.ts";
23
24export const AuthSessionClientMetadataRecord = Schema.Struct({
25 label: Schema.NullOr(Schema.String),
26 ipAddress: Schema.NullOr(Schema.String),
27 userAgent: Schema.NullOr(Schema.String),
28 deviceType: AuthClientMetadataDeviceType,
29 os: Schema.NullOr(Schema.String),
30 browser: Schema.NullOr(Schema.String),
31});
32export type AuthSessionClientMetadataRecord = typeof AuthSessionClientMetadataRecord.Type;
33
34export const AuthSessionRecord = Schema.Struct({
35 sessionId: AuthSessionId,
36 parentSessionId: Schema.optionalKey(AuthSessionId),
37 deviceId: Schema.optionalKey(Schema.String),
38 subject: Schema.String,
39 scopes: AuthEnvironmentScopes,
40 method: ServerAuthSessionMethod,
41 client: AuthSessionClientMetadataRecord,
42 issuedAt: Schema.DateTimeUtcFromString,
43 expiresAt: Schema.DateTimeUtcFromString,
44 lastConnectedAt: Schema.NullOr(Schema.DateTimeUtcFromString),
45 revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString),
46});
47export type AuthSessionRecord = typeof AuthSessionRecord.Type;
48
49export const CreateAuthSessionInput = Schema.Struct({
50 sessionId: AuthSessionId,
51 parentSessionId: Schema.optionalKey(AuthSessionId),
52 deviceId: Schema.optionalKey(Schema.String),
53 subject: Schema.String,
54 scopes: AuthEnvironmentScopes,
55 method: ServerAuthSessionMethod,
56 client: AuthSessionClientMetadataRecord,
57 issuedAt: Schema.DateTimeUtcFromString,
58 expiresAt: Schema.DateTimeUtcFromString,
59});
60export type CreateAuthSessionInput = typeof CreateAuthSessionInput.Type;
61
62export const CreateReplacingActiveAuthSessionInput = Schema.Struct({
63 session: CreateAuthSessionInput,
64 revokedAt: Schema.DateTimeUtcFromString,
65 replaceSessionId: Schema.optionalKey(AuthSessionId),
66});
67export type CreateReplacingActiveAuthSessionInput =
68 typeof CreateReplacingActiveAuthSessionInput.Type;
69
70export const GetAuthSessionByIdInput = Schema.Struct({
71 sessionId: AuthSessionId,
72});
73export type GetAuthSessionByIdInput = typeof GetAuthSessionByIdInput.Type;
74
75export const ListActiveAuthSessionsInput = Schema.Struct({
76 now: Schema.DateTimeUtcFromString,
77 connectedSessionIds: Schema.optionalKey(Schema.Array(AuthSessionId)),
78});
79export type ListActiveAuthSessionsInput = typeof ListActiveAuthSessionsInput.Type;
80
81export const RevokeAuthSessionInput = Schema.Struct({
82 sessionId: AuthSessionId,
83 revokedAt: Schema.DateTimeUtcFromString,
84});
85export type RevokeAuthSessionInput = typeof RevokeAuthSessionInput.Type;
86
87export const RevokeOtherAuthSessionsInput = Schema.Struct({
88 currentSessionId: AuthSessionId,
89 revokedAt: Schema.DateTimeUtcFromString,
90});
91export type RevokeOtherAuthSessionsInput = typeof RevokeOtherAuthSessionsInput.Type;
92
93export const SetAuthSessionLastConnectedAtInput = Schema.Struct({
94 sessionId: AuthSessionId,
95 lastConnectedAt: Schema.DateTimeUtcFromString,
96});
97export type SetAuthSessionLastConnectedAtInput = typeof SetAuthSessionLastConnectedAtInput.Type;
98
99export const SetAuthSessionClientConnectionInput = Schema.Struct({
100 sessionId: AuthSessionId,
101 surface: Schema.NullOr(ClientSurface),
102 appVersion: Schema.NullOr(Schema.String),
103});
104export type SetAuthSessionClientConnectionInput = typeof SetAuthSessionClientConnectionInput.Type;
105
106export class AuthSessionRepository extends Context.Service<
107 AuthSessionRepository,
108 {
109 readonly create: (
110 input: CreateAuthSessionInput,
111 ) => Effect.Effect<void, AuthSessionRepositoryError>;
112 readonly createReplacingActive: (
113 input: CreateReplacingActiveAuthSessionInput,
114 ) => Effect.Effect<ReadonlyArray<AuthSessionId>, AuthSessionRepositoryError>;
115 readonly createIfAbsent: (
116 input: CreateAuthSessionInput,
117 ) => Effect.Effect<void, AuthSessionRepositoryError>;
118 readonly getById: (
119 input: GetAuthSessionByIdInput,
120 ) => Effect.Effect<Option.Option<AuthSessionRecord>, AuthSessionRepositoryError>;
121 readonly listActive: (
122 input: ListActiveAuthSessionsInput,
123 ) => Effect.Effect<ReadonlyArray<AuthSessionRecord>, AuthSessionRepositoryError>;
124 readonly revoke: (
125 input: RevokeAuthSessionInput,
126 ) => Effect.Effect<ReadonlyArray<AuthSessionId>, AuthSessionRepositoryError>;
127 readonly revokeAllExcept: (
128 input: RevokeOtherAuthSessionsInput,
129 ) => Effect.Effect<ReadonlyArray<AuthSessionId>, AuthSessionRepositoryError>;
130 readonly setLastConnectedAt: (
131 input: SetAuthSessionLastConnectedAtInput,
132 ) => Effect.Effect<void, AuthSessionRepositoryError>;
133 readonly setClientConnection: (
134 input: SetAuthSessionClientConnectionInput,
135 ) => Effect.Effect<void, AuthSessionRepositoryError>;
136 }
137>()("t3/persistence/AuthSessions/AuthSessionRepository") {}
138
139const AuthSessionDbRow = Schema.Struct({
140 sessionId: AuthSessionId,
141 parentSessionId: Schema.NullOr(AuthSessionId),
142 deviceId: Schema.NullOr(Schema.String),
143 subject: Schema.String,
144 scopes: Schema.fromJsonString(AuthEnvironmentScopes),
145 method: ServerAuthSessionMethod,
146 clientLabel: Schema.NullOr(Schema.String),
147 clientIpAddress: Schema.NullOr(Schema.String),
148 clientUserAgent: Schema.NullOr(Schema.String),
149 clientDeviceType: Schema.Literals(["desktop", "mobile", "tablet", "bot", "unknown"]),
150 clientOs: Schema.NullOr(Schema.String),
151 clientBrowser: Schema.NullOr(Schema.String),
152 issuedAt: Schema.DateTimeUtcFromString,
153 expiresAt: Schema.DateTimeUtcFromString,
154 lastConnectedAt: Schema.NullOr(Schema.DateTimeUtcFromString),
155 revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString),
156});
157
158const AuthSessionRawDbRow = Schema.Struct({
159 sessionId: Schema.String,
160 parentSessionId: Schema.Unknown,
161 deviceId: Schema.Unknown,
162 subject: Schema.Unknown,
163 scopes: Schema.Unknown,
164 method: Schema.Unknown,
165 clientLabel: Schema.Unknown,
166 clientIpAddress: Schema.Unknown,
167 clientUserAgent: Schema.Unknown,
168 clientDeviceType: Schema.Unknown,
169 clientOs: Schema.Unknown,
170 clientBrowser: Schema.Unknown,
171 issuedAt: Schema.Unknown,
172 expiresAt: Schema.Unknown,
173 lastConnectedAt: Schema.Unknown,
174 revokedAt: Schema.Unknown,
175});
176
177const decodeAuthSessionDbRow = Schema.decodeUnknownEffect(AuthSessionDbRow);
178
179function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionRecord {
180 return {
181 sessionId: row.sessionId,
182 ...(row.parentSessionId === null ? {} : { parentSessionId: row.parentSessionId }),
183 ...(row.deviceId === null ? {} : { deviceId: row.deviceId }),
184 subject: row.subject,
185 scopes: row.scopes,
186 method: row.method,
187 client: {
188 label: row.clientLabel,
189 ipAddress: row.clientIpAddress,
190 userAgent: row.clientUserAgent,
191 deviceType: row.clientDeviceType,
192 os: row.clientOs,
193 browser: row.clientBrowser,
194 },
195 issuedAt: row.issuedAt,
196 expiresAt: row.expiresAt,
197 lastConnectedAt: row.lastConnectedAt,
198 revokedAt: row.revokedAt,
199 };
200}
201
202function toPersistenceSqlOrDecodeError(
203 sqlOperation: string,
204 decodeOperation: string,
205 correlation?: PersistenceErrorCorrelation,
206) {
207 return (cause: unknown): AuthSessionRepositoryError =>
208 Schema.isSchemaError(cause)
209 ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause, correlation)
210 : new PersistenceSqlError({
211 operation: sqlOperation,
212 ...(correlation === undefined ? {} : { correlation }),
213 cause,
214 });
215}
216
217/** @public Service construction is part of the canonical Effect module API. */
218export const make = Effect.gen(function* () {
219 const sql = yield* SqlClient.SqlClient;
220
221 const insertSessionRow = (ignoreExisting: boolean) =>
222 SqlSchema.void({
223 Request: CreateAuthSessionInput,
224 execute: (input) =>
225 sql`
226 INSERT INTO auth_sessions (
227 session_id,
228 parent_session_id,
229 device_id,
230 subject,
231 scopes,
232 method,
233 client_label,
234 client_ip_address,
235 client_user_agent,
236 client_device_type,
237 client_os,
238 client_browser,
239 issued_at,
240 expires_at,
241 revoked_at
242 )
243 VALUES (
244 ${input.sessionId},
245 ${input.parentSessionId ?? null},
246 ${input.deviceId ?? null},
247 ${input.subject},
248 ${JSON.stringify(input.scopes)},
249 ${input.method},
250 ${input.client.label},
251 ${input.client.ipAddress},
252 ${input.client.userAgent},
253 ${input.client.deviceType},
254 ${input.client.os},
255 ${input.client.browser},
256 ${input.issuedAt},
257 ${input.expiresAt},
258 NULL
259 )
260 ${ignoreExisting ? sql`ON CONFLICT(session_id) DO NOTHING` : sql``}
261 `,
262 });
263 const createSessionRow = insertSessionRow(false);
264 const createSessionRowIfAbsent = insertSessionRow(true);
265
266 const getSessionRowById = SqlSchema.findOneOption({
267 Request: GetAuthSessionByIdInput,
268 Result: AuthSessionRawDbRow,
269 execute: ({ sessionId }) =>
270 sql`
271 SELECT
272 session_id AS "sessionId",
273 parent_session_id AS "parentSessionId",
274 device_id AS "deviceId",
275 subject AS "subject",
276 scopes AS "scopes",
277 method AS "method",
278 client_label AS "clientLabel",
279 client_ip_address AS "clientIpAddress",
280 client_user_agent AS "clientUserAgent",
281 client_device_type AS "clientDeviceType",
282 client_os AS "clientOs",
283 client_browser AS "clientBrowser",
284 issued_at AS "issuedAt",
285 expires_at AS "expiresAt",
286 last_connected_at AS "lastConnectedAt",
287 revoked_at AS "revokedAt"
288 FROM auth_sessions
289 WHERE session_id = ${sessionId}
290 AND (device_id IS NULL OR EXISTS (
291 SELECT 1 FROM auth_devices AS device
292 WHERE device.device_id = auth_sessions.device_id AND device.revoked_at IS NULL
293 ))
294 `,
295 });
296
297 const revokeActiveSessionsForReplacement = SqlSchema.findAll({
298 Request: CreateReplacingActiveAuthSessionInput,
299 Result: Schema.Struct({ sessionId: AuthSessionId }),
300 execute: ({ session, revokedAt, replaceSessionId }) =>
301 sql`
302 UPDATE auth_sessions
303 SET revoked_at = ${revokedAt}
304 WHERE ${
305 replaceSessionId === undefined
306 ? sql`subject = ${session.subject}`
307 : sql`session_id = ${replaceSessionId}`
308 }
309 AND method = ${session.method}
310 AND revoked_at IS NULL
311 AND expires_at > ${revokedAt}
312 RETURNING session_id AS "sessionId"
313 `,
314 });
315
316 const listActiveSessionRows = SqlSchema.findAll({
317 Request: ListActiveAuthSessionsInput,
318 Result: AuthSessionRawDbRow,
319 execute: ({ now, connectedSessionIds = [] }) =>
320 sql`
321 SELECT
322 session_id AS "sessionId",
323 parent_session_id AS "parentSessionId",
324 device_id AS "deviceId",
325 subject AS "subject",
326 scopes AS "scopes",
327 method AS "method",
328 client_label AS "clientLabel",
329 client_ip_address AS "clientIpAddress",
330 client_user_agent AS "clientUserAgent",
331 client_device_type AS "clientDeviceType",
332 client_os AS "clientOs",
333 client_browser AS "clientBrowser",
334 issued_at AS "issuedAt",
335 expires_at AS "expiresAt",
336 last_connected_at AS "lastConnectedAt",
337 revoked_at AS "revokedAt"
338 FROM auth_sessions
339 WHERE revoked_at IS NULL
340 AND (parent_session_id IS NULL OR EXISTS (
341 SELECT 1 FROM auth_sessions AS parent
342 WHERE parent.session_id = auth_sessions.parent_session_id
343 AND parent.revoked_at IS NULL
344 AND parent.expires_at > ${now}
345 ))
346 AND (expires_at > ${now} OR ${sql.in("session_id", connectedSessionIds)})
347 ORDER BY issued_at DESC, session_id DESC
348 `,
349 });
350
351 const setLastConnectedAtRow = SqlSchema.void({
352 Request: SetAuthSessionLastConnectedAtInput,
353 execute: ({ sessionId, lastConnectedAt }) =>
354 sql`
355 UPDATE auth_sessions
356 SET last_connected_at = ${lastConnectedAt}
357 WHERE session_id = ${sessionId}
358 AND revoked_at IS NULL
359 `,
360 });
361
362 // COALESCE keeps the previous value when a client reports only one field, so
363 // a partial report never nulls out data a fuller client stored earlier.
364 const setClientConnectionRow = SqlSchema.void({
365 Request: SetAuthSessionClientConnectionInput,
366 execute: ({ sessionId, surface, appVersion }) =>
367 sql`
368 UPDATE auth_sessions
369 SET client_surface = COALESCE(${surface}, client_surface),
370 client_app_version = COALESCE(${appVersion}, client_app_version)
371 WHERE session_id = ${sessionId}
372 AND revoked_at IS NULL
373 `,
374 });
375
376 const revokeSessionRows = SqlSchema.findAll({
377 Request: RevokeAuthSessionInput,
378 Result: Schema.Struct({ sessionId: AuthSessionId }),
379 execute: ({ sessionId, revokedAt }) =>
380 sql`
381 UPDATE auth_sessions
382 SET revoked_at = ${revokedAt}
383 WHERE (session_id = ${sessionId} OR parent_session_id = ${sessionId})
384 AND revoked_at IS NULL
385 RETURNING session_id AS "sessionId"
386 `,
387 });
388
389 const revokeOtherSessionRows = SqlSchema.findAll({
390 Request: RevokeOtherAuthSessionsInput,
391 Result: Schema.Struct({ sessionId: AuthSessionId }),
392 execute: ({ currentSessionId, revokedAt }) =>
393 sql`
394 UPDATE auth_sessions
395 SET revoked_at = ${revokedAt}
396 WHERE session_id <> ${currentSessionId}
397 AND revoked_at IS NULL
398 RETURNING session_id AS "sessionId"
399 `,
400 });
401
402 const create: AuthSessionRepository["Service"]["create"] = (input) =>
403 createSessionRow(input).pipe(
404 Effect.mapError(
405 toPersistenceSqlOrDecodeError(
406 "AuthSessionRepository.create:query",
407 "AuthSessionRepository.create:encodeRequest",
408 { sessionId: input.sessionId },
409 ),
410 ),
411 );
412
413 const createReplacingActive: AuthSessionRepository["Service"]["createReplacingActive"] = (
414 input,
415 ) =>
416 sql
417 .withTransaction(
418 revokeActiveSessionsForReplacement(input).pipe(
419 Effect.flatMap((revokedRows) =>
420 createSessionRow(input.session).pipe(
421 Effect.as(revokedRows.map((row) => row.sessionId)),
422 ),
423 ),
424 ),
425 )
426 .pipe(
427 Effect.mapError(
428 toPersistenceSqlOrDecodeError(
429 "AuthSessionRepository.createReplacingActive:query",
430 "AuthSessionRepository.createReplacingActive:encodeRequest",
431 { sessionId: input.session.sessionId },
432 ),
433 ),
434 );
435
436 const createIfAbsent: AuthSessionRepository["Service"]["createIfAbsent"] = (input) =>
437 createSessionRowIfAbsent(input).pipe(
438 Effect.mapError(
439 toPersistenceSqlOrDecodeError(
440 "AuthSessionRepository.createIfAbsent:query",
441 "AuthSessionRepository.createIfAbsent:encodeRequest",
442 { sessionId: input.sessionId },
443 ),
444 ),
445 );
446
447 const getById: AuthSessionRepository["Service"]["getById"] = (input) =>
448 getSessionRowById(input).pipe(
449 Effect.mapError(
450 toPersistenceSqlOrDecodeError(
451 "AuthSessionRepository.getById:query",
452 "AuthSessionRepository.getById:decodeRow",
453 { sessionId: input.sessionId },
454 ),
455 ),
456 Effect.flatMap((rowOption) =>
457 Option.match(rowOption, {
458 onNone: () => Effect.succeedNone,
459 onSome: (row) =>
460 decodeAuthSessionDbRow(row).pipe(
461 Effect.mapError((cause) =>
462 PersistenceDecodeError.fromSchemaError(
463 "AuthSessionRepository.getById:decodeRow",
464 cause,
465 { sessionId: input.sessionId },
466 ),
467 ),
468 Effect.map((decodedRow) => Option.some(toAuthSessionRecord(decodedRow))),
469 ),
470 }),
471 ),
472 );
473
474 const listActive: AuthSessionRepository["Service"]["listActive"] = (input) =>
475 listActiveSessionRows(input).pipe(
476 Effect.mapError(
477 toPersistenceSqlOrDecodeError(
478 "AuthSessionRepository.listActive:query",
479 "AuthSessionRepository.listActive:decodeRows",
480 ),
481 ),
482 Effect.flatMap((rows) =>
483 Effect.forEach(rows, (row) =>
484 decodeAuthSessionDbRow(row).pipe(
485 Effect.mapError((cause) =>
486 PersistenceDecodeError.fromSchemaError(
487 "AuthSessionRepository.listActive:decodeRows",
488 cause,
489 { sessionId: row.sessionId },
490 ),
491 ),
492 Effect.map(toAuthSessionRecord),
493 ),
494 ),
495 ),
496 );
497
498 const revoke: AuthSessionRepository["Service"]["revoke"] = (input) =>
499 revokeSessionRows(input).pipe(
500 Effect.mapError(
501 toPersistenceSqlOrDecodeError(
502 "AuthSessionRepository.revoke:query",
503 "AuthSessionRepository.revoke:decodeRows",
504 { sessionId: input.sessionId },
505 ),
506 ),
507 Effect.map((rows) => rows.map((row) => row.sessionId)),
508 );
509
510 const revokeAllExcept: AuthSessionRepository["Service"]["revokeAllExcept"] = (input) =>
511 revokeOtherSessionRows(input).pipe(
512 Effect.mapError(
513 toPersistenceSqlOrDecodeError(
514 "AuthSessionRepository.revokeAllExcept:query",
515 "AuthSessionRepository.revokeAllExcept:decodeRows",
516 { currentSessionId: input.currentSessionId },
517 ),
518 ),
519 Effect.map((rows) => rows.map((row) => row.sessionId)),
520 );
521
522 const setLastConnectedAt: AuthSessionRepository["Service"]["setLastConnectedAt"] = (input) =>
523 setLastConnectedAtRow(input).pipe(
524 Effect.mapError(
525 toPersistenceSqlOrDecodeError(
526 "AuthSessionRepository.setLastConnectedAt:query",
527 "AuthSessionRepository.setLastConnectedAt:encodeRequest",
528 { sessionId: input.sessionId },
529 ),
530 ),
531 );
532
533 const setClientConnection: AuthSessionRepository["Service"]["setClientConnection"] = (input) =>
534 setClientConnectionRow(input).pipe(
535 Effect.mapError(
536 toPersistenceSqlOrDecodeError(
537 "AuthSessionRepository.setClientConnection:query",
538 "AuthSessionRepository.setClientConnection:encodeRequest",
539 { sessionId: input.sessionId },
540 ),
541 ),
542 );
543
544 return {
545 create,
546 createReplacingActive,
547 createIfAbsent,
548 getById,
549 listActive,
550 revoke,
551 revokeAllExcept,
552 setLastConnectedAt,
553 setClientConnection,
554 } satisfies AuthSessionRepository["Service"];
555});
556
557export const layer = Layer.effect(AuthSessionRepository, make);