505 lines · 16.6 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,  ClientSurface,  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 CreateReplacingActiveAuthSessionInput = Schema.Struct({  session: CreateAuthSessionInput,  revokedAt: Schema.DateTimeUtcFromString,});export type CreateReplacingActiveAuthSessionInput =  typeof CreateReplacingActiveAuthSessionInput.Type; export const GetAuthSessionByIdInput = Schema.Struct({  sessionId: AuthSessionId,});export type GetAuthSessionByIdInput = typeof GetAuthSessionByIdInput.Type; export const ListActiveAuthSessionsInput = Schema.Struct({  now: Schema.DateTimeUtcFromString,  connectedSessionIds: Schema.optionalKey(Schema.Array(AuthSessionId)),});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 const SetAuthSessionClientConnectionInput = Schema.Struct({  sessionId: AuthSessionId,  surface: Schema.NullOr(ClientSurface),  appVersion: Schema.NullOr(Schema.String),});export type SetAuthSessionClientConnectionInput = typeof SetAuthSessionClientConnectionInput.Type; export class AuthSessionRepository extends Context.Service<  AuthSessionRepository,  {    readonly create: (      input: CreateAuthSessionInput,    ) => Effect.Effect<void, AuthSessionRepositoryError>;    readonly createReplacingActive: (      input: CreateReplacingActiveAuthSessionInput,    ) => Effect.Effect<ReadonlyArray<AuthSessionId>, 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>;    readonly setClientConnection: (      input: SetAuthSessionClientConnectionInput,    ) => 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,        });} /** @public Service construction is part of the canonical Effect module API. */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 revokeActiveSessionsForReplacement = SqlSchema.findAll({    Request: CreateReplacingActiveAuthSessionInput,    Result: Schema.Struct({ sessionId: AuthSessionId }),    execute: ({ session, revokedAt }) =>      sql`        UPDATE auth_sessions        SET revoked_at = ${revokedAt}        WHERE subject = ${session.subject}          AND method = ${session.method}          AND revoked_at IS NULL          AND expires_at > ${revokedAt}        RETURNING session_id AS "sessionId"      `,  });   const listActiveSessionRows = SqlSchema.findAll({    Request: ListActiveAuthSessionsInput,    Result: AuthSessionRawDbRow,    execute: ({ now, connectedSessionIds = [] }) =>      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} OR ${sql.in("session_id", connectedSessionIds)})        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      `,  });   // COALESCE keeps the previous value when a client reports only one field, so  // a partial report never nulls out data a fuller client stored earlier.  const setClientConnectionRow = SqlSchema.void({    Request: SetAuthSessionClientConnectionInput,    execute: ({ sessionId, surface, appVersion }) =>      sql`        UPDATE auth_sessions        SET client_surface = COALESCE(${surface}, client_surface),            client_app_version = COALESCE(${appVersion}, client_app_version)        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 createReplacingActive: AuthSessionRepository["Service"]["createReplacingActive"] = (    input,  ) =>    sql      .withTransaction(        revokeActiveSessionsForReplacement(input).pipe(          Effect.flatMap((revokedRows) =>            createSessionRow(input.session).pipe(              Effect.as(revokedRows.map((row) => row.sessionId)),            ),          ),        ),      )      .pipe(        Effect.mapError(          toPersistenceSqlOrDecodeError(            "AuthSessionRepository.createReplacingActive:query",            "AuthSessionRepository.createReplacingActive:encodeRequest",            { sessionId: input.session.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 },        ),      ),    );   const setClientConnection: AuthSessionRepository["Service"]["setClientConnection"] = (input) =>    setClientConnectionRow(input).pipe(      Effect.mapError(        toPersistenceSqlOrDecodeError(          "AuthSessionRepository.setClientConnection:query",          "AuthSessionRepository.setClientConnection:encodeRequest",          { sessionId: input.sessionId },        ),      ),    );   return {    create,    createReplacingActive,    getById,    listActive,    revoke,    revokeAllExcept,    setLastConnectedAt,

Lines 1–500 of 505.