417 lines · 13.3 KB · TypeScript
RawDownload
import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as Schema from "effect/Schema";import * as SqlClient from "effect/unstable/sql/SqlClient";import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import {  AuthClientMetadataDeviceType,  AuthEnvironmentScopes,  AuthSessionId,  ServerAuthSessionMethod,} from "@t3tools/contracts"; import {  type AuthSessionRepositoryError,  PersistenceDecodeError,  type PersistenceErrorCorrelation,  PersistenceSqlError,} from "./Errors.ts"; export const AuthSessionClientMetadataRecord = Schema.Struct({  label: Schema.NullOr(Schema.String),  ipAddress: Schema.NullOr(Schema.String),  userAgent: Schema.NullOr(Schema.String),  deviceType: AuthClientMetadataDeviceType,  os: Schema.NullOr(Schema.String),  browser: Schema.NullOr(Schema.String),});export type AuthSessionClientMetadataRecord = typeof AuthSessionClientMetadataRecord.Type; export const AuthSessionRecord = Schema.Struct({  sessionId: AuthSessionId,  subject: Schema.String,  scopes: AuthEnvironmentScopes,  method: ServerAuthSessionMethod,  client: AuthSessionClientMetadataRecord,  issuedAt: Schema.DateTimeUtcFromString,  expiresAt: Schema.DateTimeUtcFromString,  lastConnectedAt: Schema.NullOr(Schema.DateTimeUtcFromString),  revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString),});export type AuthSessionRecord = typeof AuthSessionRecord.Type; export const CreateAuthSessionInput = Schema.Struct({  sessionId: AuthSessionId,  subject: Schema.String,  scopes: AuthEnvironmentScopes,  method: ServerAuthSessionMethod,  client: AuthSessionClientMetadataRecord,  issuedAt: Schema.DateTimeUtcFromString,  expiresAt: Schema.DateTimeUtcFromString,});export type CreateAuthSessionInput = typeof CreateAuthSessionInput.Type; export const GetAuthSessionByIdInput = Schema.Struct({  sessionId: AuthSessionId,});export type GetAuthSessionByIdInput = typeof GetAuthSessionByIdInput.Type; export const ListActiveAuthSessionsInput = Schema.Struct({  now: Schema.DateTimeUtcFromString,});export type ListActiveAuthSessionsInput = typeof ListActiveAuthSessionsInput.Type; export const RevokeAuthSessionInput = Schema.Struct({  sessionId: AuthSessionId,  revokedAt: Schema.DateTimeUtcFromString,});export type RevokeAuthSessionInput = typeof RevokeAuthSessionInput.Type; export const RevokeOtherAuthSessionsInput = Schema.Struct({  currentSessionId: AuthSessionId,  revokedAt: Schema.DateTimeUtcFromString,});export type RevokeOtherAuthSessionsInput = typeof RevokeOtherAuthSessionsInput.Type; export const SetAuthSessionLastConnectedAtInput = Schema.Struct({  sessionId: AuthSessionId,  lastConnectedAt: Schema.DateTimeUtcFromString,});export type SetAuthSessionLastConnectedAtInput = typeof SetAuthSessionLastConnectedAtInput.Type; export class AuthSessionRepository extends Context.Service<  AuthSessionRepository,  {    readonly create: (      input: CreateAuthSessionInput,    ) => Effect.Effect<void, AuthSessionRepositoryError>;    readonly getById: (      input: GetAuthSessionByIdInput,    ) => Effect.Effect<Option.Option<AuthSessionRecord>, AuthSessionRepositoryError>;    readonly listActive: (      input: ListActiveAuthSessionsInput,    ) => Effect.Effect<ReadonlyArray<AuthSessionRecord>, AuthSessionRepositoryError>;    readonly revoke: (      input: RevokeAuthSessionInput,    ) => Effect.Effect<boolean, AuthSessionRepositoryError>;    readonly revokeAllExcept: (      input: RevokeOtherAuthSessionsInput,    ) => Effect.Effect<ReadonlyArray<AuthSessionId>, AuthSessionRepositoryError>;    readonly setLastConnectedAt: (      input: SetAuthSessionLastConnectedAtInput,    ) => Effect.Effect<void, AuthSessionRepositoryError>;  }>()("t3/persistence/AuthSessions/AuthSessionRepository") {} const AuthSessionDbRow = Schema.Struct({  sessionId: AuthSessionId,  subject: Schema.String,  scopes: Schema.fromJsonString(AuthEnvironmentScopes),  method: ServerAuthSessionMethod,  clientLabel: Schema.NullOr(Schema.String),  clientIpAddress: Schema.NullOr(Schema.String),  clientUserAgent: Schema.NullOr(Schema.String),  clientDeviceType: Schema.Literals(["desktop", "mobile", "tablet", "bot", "unknown"]),  clientOs: Schema.NullOr(Schema.String),  clientBrowser: Schema.NullOr(Schema.String),  issuedAt: Schema.DateTimeUtcFromString,  expiresAt: Schema.DateTimeUtcFromString,  lastConnectedAt: Schema.NullOr(Schema.DateTimeUtcFromString),  revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString),}); const AuthSessionRawDbRow = Schema.Struct({  sessionId: Schema.String,  subject: Schema.Unknown,  scopes: Schema.Unknown,  method: Schema.Unknown,  clientLabel: Schema.Unknown,  clientIpAddress: Schema.Unknown,  clientUserAgent: Schema.Unknown,  clientDeviceType: Schema.Unknown,  clientOs: Schema.Unknown,  clientBrowser: Schema.Unknown,  issuedAt: Schema.Unknown,  expiresAt: Schema.Unknown,  lastConnectedAt: Schema.Unknown,  revokedAt: Schema.Unknown,}); const decodeAuthSessionDbRow = Schema.decodeUnknownEffect(AuthSessionDbRow); function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionRecord {  return {    sessionId: row.sessionId,    subject: row.subject,    scopes: row.scopes,    method: row.method,    client: {      label: row.clientLabel,      ipAddress: row.clientIpAddress,      userAgent: row.clientUserAgent,      deviceType: row.clientDeviceType,      os: row.clientOs,      browser: row.clientBrowser,    },    issuedAt: row.issuedAt,    expiresAt: row.expiresAt,    lastConnectedAt: row.lastConnectedAt,    revokedAt: row.revokedAt,  };} function toPersistenceSqlOrDecodeError(  sqlOperation: string,  decodeOperation: string,  correlation?: PersistenceErrorCorrelation,) {  return (cause: unknown): AuthSessionRepositoryError =>    Schema.isSchemaError(cause)      ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause, correlation)      : new PersistenceSqlError({          operation: sqlOperation,          ...(correlation === undefined ? {} : { correlation }),          cause,        });} export const make = Effect.gen(function* () {  const sql = yield* SqlClient.SqlClient;   const createSessionRow = SqlSchema.void({    Request: CreateAuthSessionInput,    execute: (input) =>      sql`        INSERT INTO auth_sessions (          session_id,          subject,          scopes,          method,          client_label,          client_ip_address,          client_user_agent,          client_device_type,          client_os,          client_browser,          issued_at,          expires_at,          revoked_at        )        VALUES (          ${input.sessionId},          ${input.subject},          ${JSON.stringify(input.scopes)},          ${input.method},          ${input.client.label},          ${input.client.ipAddress},          ${input.client.userAgent},          ${input.client.deviceType},          ${input.client.os},          ${input.client.browser},          ${input.issuedAt},          ${input.expiresAt},          NULL        )      `,  });   const getSessionRowById = SqlSchema.findOneOption({    Request: GetAuthSessionByIdInput,    Result: AuthSessionRawDbRow,    execute: ({ sessionId }) =>      sql`        SELECT          session_id AS "sessionId",          subject AS "subject",          scopes AS "scopes",          method AS "method",          client_label AS "clientLabel",          client_ip_address AS "clientIpAddress",          client_user_agent AS "clientUserAgent",          client_device_type AS "clientDeviceType",          client_os AS "clientOs",          client_browser AS "clientBrowser",          issued_at AS "issuedAt",          expires_at AS "expiresAt",          last_connected_at AS "lastConnectedAt",          revoked_at AS "revokedAt"        FROM auth_sessions        WHERE session_id = ${sessionId}      `,  });   const listActiveSessionRows = SqlSchema.findAll({    Request: ListActiveAuthSessionsInput,    Result: AuthSessionRawDbRow,    execute: ({ now }) =>      sql`        SELECT          session_id AS "sessionId",          subject AS "subject",          scopes AS "scopes",          method AS "method",          client_label AS "clientLabel",          client_ip_address AS "clientIpAddress",          client_user_agent AS "clientUserAgent",          client_device_type AS "clientDeviceType",          client_os AS "clientOs",          client_browser AS "clientBrowser",          issued_at AS "issuedAt",          expires_at AS "expiresAt",          last_connected_at AS "lastConnectedAt",          revoked_at AS "revokedAt"        FROM auth_sessions        WHERE revoked_at IS NULL          AND expires_at > ${now}        ORDER BY issued_at DESC, session_id DESC      `,  });   const setLastConnectedAtRow = SqlSchema.void({    Request: SetAuthSessionLastConnectedAtInput,    execute: ({ sessionId, lastConnectedAt }) =>      sql`        UPDATE auth_sessions        SET last_connected_at = ${lastConnectedAt}        WHERE session_id = ${sessionId}          AND revoked_at IS NULL      `,  });   const revokeSessionRows = SqlSchema.findAll({    Request: RevokeAuthSessionInput,    Result: Schema.Struct({ sessionId: AuthSessionId }),    execute: ({ sessionId, revokedAt }) =>      sql`        UPDATE auth_sessions        SET revoked_at = ${revokedAt}        WHERE session_id = ${sessionId}          AND revoked_at IS NULL        RETURNING session_id AS "sessionId"      `,  });   const revokeOtherSessionRows = SqlSchema.findAll({    Request: RevokeOtherAuthSessionsInput,    Result: Schema.Struct({ sessionId: AuthSessionId }),    execute: ({ currentSessionId, revokedAt }) =>      sql`        UPDATE auth_sessions        SET revoked_at = ${revokedAt}        WHERE session_id <> ${currentSessionId}          AND revoked_at IS NULL        RETURNING session_id AS "sessionId"      `,  });   const create: AuthSessionRepository["Service"]["create"] = (input) =>    createSessionRow(input).pipe(      Effect.mapError(        toPersistenceSqlOrDecodeError(          "AuthSessionRepository.create:query",          "AuthSessionRepository.create:encodeRequest",          { sessionId: input.sessionId },        ),      ),    );   const getById: AuthSessionRepository["Service"]["getById"] = (input) =>    getSessionRowById(input).pipe(      Effect.mapError(        toPersistenceSqlOrDecodeError(          "AuthSessionRepository.getById:query",          "AuthSessionRepository.getById:decodeRow",          { sessionId: input.sessionId },        ),      ),      Effect.flatMap((rowOption) =>        Option.match(rowOption, {          onNone: () => Effect.succeed(Option.none()),          onSome: (row) =>            decodeAuthSessionDbRow(row).pipe(              Effect.mapError((cause) =>                PersistenceDecodeError.fromSchemaError(                  "AuthSessionRepository.getById:decodeRow",                  cause,                  { sessionId: input.sessionId },                ),              ),              Effect.map((decodedRow) => Option.some(toAuthSessionRecord(decodedRow))),            ),        }),      ),    );   const listActive: AuthSessionRepository["Service"]["listActive"] = (input) =>    listActiveSessionRows(input).pipe(      Effect.mapError(        toPersistenceSqlOrDecodeError(          "AuthSessionRepository.listActive:query",          "AuthSessionRepository.listActive:decodeRows",        ),      ),      Effect.flatMap((rows) =>        Effect.forEach(rows, (row) =>          decodeAuthSessionDbRow(row).pipe(            Effect.mapError((cause) =>              PersistenceDecodeError.fromSchemaError(                "AuthSessionRepository.listActive:decodeRows",                cause,                { sessionId: row.sessionId },              ),            ),            Effect.map(toAuthSessionRecord),          ),        ),      ),    );   const revoke: AuthSessionRepository["Service"]["revoke"] = (input) =>    revokeSessionRows(input).pipe(      Effect.mapError(        toPersistenceSqlOrDecodeError(          "AuthSessionRepository.revoke:query",          "AuthSessionRepository.revoke:decodeRows",          { sessionId: input.sessionId },        ),      ),      Effect.map((rows) => rows.length > 0),    );   const revokeAllExcept: AuthSessionRepository["Service"]["revokeAllExcept"] = (input) =>    revokeOtherSessionRows(input).pipe(      Effect.mapError(        toPersistenceSqlOrDecodeError(          "AuthSessionRepository.revokeAllExcept:query",          "AuthSessionRepository.revokeAllExcept:decodeRows",          { currentSessionId: input.currentSessionId },        ),      ),      Effect.map((rows) => rows.map((row) => row.sessionId)),    );   const setLastConnectedAt: AuthSessionRepository["Service"]["setLastConnectedAt"] = (input) =>    setLastConnectedAtRow(input).pipe(      Effect.mapError(        toPersistenceSqlOrDecodeError(          "AuthSessionRepository.setLastConnectedAt:query",          "AuthSessionRepository.setLastConnectedAt:encodeRequest",          { sessionId: input.sessionId },        ),      ),    );   return {    create,    getById,    listActive,    revoke,    revokeAllExcept,    setLastConnectedAt,  } satisfies AuthSessionRepository["Service"];}); export const layer = Layer.effect(AuthSessionRepository, make);