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, AuthConnectClientStatus } from "@t3tools/contracts"; import { type AuthConnectClientRepositoryError, PersistenceDecodeError, type PersistenceErrorCorrelation, PersistenceSqlError, } from "./Errors.ts"; export const AuthConnectClientMetadataRecord = 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 AuthConnectClientMetadataRecord = typeof AuthConnectClientMetadataRecord.Type; export const AuthConnectClientRecord = Schema.Struct({ clientProofKeyThumbprint: Schema.String, cloudUserId: Schema.String, deviceId: Schema.NullOr(Schema.String), status: AuthConnectClientStatus, client: AuthConnectClientMetadataRecord, requestedAt: Schema.DateTimeUtcFromString, updatedAt: Schema.DateTimeUtcFromString, approvedAt: Schema.NullOr(Schema.DateTimeUtcFromString), rejectedAt: Schema.NullOr(Schema.DateTimeUtcFromString), revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), lastSeenAt: Schema.NullOr(Schema.DateTimeUtcFromString), }); export type AuthConnectClientRecord = typeof AuthConnectClientRecord.Type; export const UpsertAuthConnectClientRequestInput = Schema.Struct({ clientProofKeyThumbprint: Schema.String, cloudUserId: Schema.String, deviceId: Schema.NullOr(Schema.String), client: AuthConnectClientMetadataRecord, requestedAt: Schema.DateTimeUtcFromString, }); export type UpsertAuthConnectClientRequestInput = typeof UpsertAuthConnectClientRequestInput.Type; export const UpdateAuthConnectClientStatusInput = Schema.Struct({ clientProofKeyThumbprint: Schema.String, status: Schema.Literals(["approved", "rejected"]), decidedAt: Schema.DateTimeUtcFromString, }); export type UpdateAuthConnectClientStatusInput = typeof UpdateAuthConnectClientStatusInput.Type; export const RevokeAuthConnectClientInput = Schema.Struct({ clientProofKeyThumbprint: Schema.String, revokedAt: Schema.DateTimeUtcFromString, }); export type RevokeAuthConnectClientInput = typeof RevokeAuthConnectClientInput.Type; export const MarkAuthConnectClientSeenInput = Schema.Struct({ clientProofKeyThumbprint: Schema.String, seenAt: Schema.DateTimeUtcFromString, }); export type MarkAuthConnectClientSeenInput = typeof MarkAuthConnectClientSeenInput.Type; export class AuthConnectClientRepository extends Context.Service< AuthConnectClientRepository, { readonly upsertRequest: ( input: UpsertAuthConnectClientRequestInput, ) => Effect.Effect; readonly updateStatus: ( input: UpdateAuthConnectClientStatusInput, ) => Effect.Effect, AuthConnectClientRepositoryError>; readonly revoke: ( input: RevokeAuthConnectClientInput, ) => Effect.Effect; readonly markSeen: ( input: MarkAuthConnectClientSeenInput, ) => Effect.Effect, AuthConnectClientRepositoryError>; readonly listActive: () => Effect.Effect< ReadonlyArray, AuthConnectClientRepositoryError >; } >()("t3/persistence/AuthConnectClients/AuthConnectClientRepository") {} const AuthConnectClientDbRow = Schema.Struct({ clientProofKeyThumbprint: Schema.String, cloudUserId: Schema.String, deviceId: Schema.NullOr(Schema.String), status: AuthConnectClientStatus, 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), requestedAt: Schema.DateTimeUtcFromString, updatedAt: Schema.DateTimeUtcFromString, approvedAt: Schema.NullOr(Schema.DateTimeUtcFromString), rejectedAt: Schema.NullOr(Schema.DateTimeUtcFromString), revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), lastSeenAt: Schema.NullOr(Schema.DateTimeUtcFromString), }); const AuthConnectClientRawDbRow = Schema.Struct({ clientProofKeyThumbprint: Schema.String, cloudUserId: Schema.Unknown, deviceId: Schema.Unknown, status: Schema.Unknown, clientLabel: Schema.Unknown, clientIpAddress: Schema.Unknown, clientUserAgent: Schema.Unknown, clientDeviceType: Schema.Unknown, clientOs: Schema.Unknown, clientBrowser: Schema.Unknown, requestedAt: Schema.Unknown, updatedAt: Schema.Unknown, approvedAt: Schema.Unknown, rejectedAt: Schema.Unknown, revokedAt: Schema.Unknown, lastSeenAt: Schema.Unknown, }); const decodeAuthConnectClientDbRow = Schema.decodeUnknownEffect(AuthConnectClientDbRow); function toAuthConnectClientRecord( row: typeof AuthConnectClientDbRow.Type, ): AuthConnectClientRecord { return { clientProofKeyThumbprint: row.clientProofKeyThumbprint, cloudUserId: row.cloudUserId, deviceId: row.deviceId, status: row.status, client: { label: row.clientLabel, ipAddress: row.clientIpAddress, userAgent: row.clientUserAgent, deviceType: row.clientDeviceType, os: row.clientOs, browser: row.clientBrowser, }, requestedAt: row.requestedAt, updatedAt: row.updatedAt, approvedAt: row.approvedAt, rejectedAt: row.rejectedAt, revokedAt: row.revokedAt, lastSeenAt: row.lastSeenAt, }; } function toPersistenceSqlOrDecodeError( sqlOperation: string, decodeOperation: string, correlation?: PersistenceErrorCorrelation, ) { return (cause: unknown): AuthConnectClientRepositoryError => Schema.isSchemaError(cause) ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause, correlation) : new PersistenceSqlError({ operation: sqlOperation, ...(correlation === undefined ? {} : { correlation }), cause, }); } const rowSelection = ` client_proof_key_thumbprint AS "clientProofKeyThumbprint", cloud_user_id AS "cloudUserId", device_id AS "deviceId", status AS "status", 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", requested_at AS "requestedAt", updated_at AS "updatedAt", approved_at AS "approvedAt", rejected_at AS "rejectedAt", revoked_at AS "revokedAt", last_seen_at AS "lastSeenAt" `; export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const upsertRequestRow = SqlSchema.findOne({ Request: UpsertAuthConnectClientRequestInput, Result: AuthConnectClientRawDbRow, execute: (input) => sql` INSERT INTO auth_connect_clients ( client_proof_key_thumbprint, cloud_user_id, device_id, status, client_label, client_ip_address, client_user_agent, client_device_type, client_os, client_browser, requested_at, updated_at, approved_at, rejected_at, revoked_at, last_seen_at ) VALUES ( ${input.clientProofKeyThumbprint}, ${input.cloudUserId}, ${input.deviceId}, 'pending', ${input.client.label}, ${input.client.ipAddress}, ${input.client.userAgent}, ${input.client.deviceType}, ${input.client.os}, ${input.client.browser}, ${input.requestedAt}, ${input.requestedAt}, NULL, NULL, NULL, NULL ) ON CONFLICT(client_proof_key_thumbprint) DO UPDATE SET cloud_user_id = excluded.cloud_user_id, device_id = excluded.device_id, status = CASE WHEN auth_connect_clients.revoked_at IS NULL AND auth_connect_clients.cloud_user_id = excluded.cloud_user_id THEN auth_connect_clients.status ELSE 'pending' END, client_label = excluded.client_label, client_ip_address = excluded.client_ip_address, client_user_agent = excluded.client_user_agent, client_device_type = excluded.client_device_type, client_os = excluded.client_os, client_browser = excluded.client_browser, requested_at = excluded.requested_at, updated_at = excluded.updated_at, approved_at = CASE WHEN auth_connect_clients.revoked_at IS NULL AND auth_connect_clients.cloud_user_id = excluded.cloud_user_id THEN auth_connect_clients.approved_at ELSE NULL END, rejected_at = CASE WHEN auth_connect_clients.revoked_at IS NULL AND auth_connect_clients.cloud_user_id = excluded.cloud_user_id THEN auth_connect_clients.rejected_at ELSE NULL END, last_seen_at = CASE WHEN auth_connect_clients.revoked_at IS NULL AND auth_connect_clients.cloud_user_id = excluded.cloud_user_id THEN auth_connect_clients.last_seen_at ELSE NULL END, revoked_at = NULL RETURNING ${sql.unsafe(rowSelection)} `, }); const updateStatusRow = SqlSchema.findOneOption({ Request: UpdateAuthConnectClientStatusInput, Result: AuthConnectClientRawDbRow, execute: ({ clientProofKeyThumbprint, status, decidedAt }) => sql` UPDATE auth_connect_clients SET status = ${status}, updated_at = ${decidedAt}, approved_at = CASE WHEN ${status} = 'approved' THEN ${decidedAt} ELSE NULL END, rejected_at = CASE WHEN ${status} = 'rejected' THEN ${decidedAt} ELSE NULL END, last_seen_at = CASE WHEN ${status} = 'approved' AND status = 'approved' THEN last_seen_at ELSE NULL END, revoked_at = NULL WHERE client_proof_key_thumbprint = ${clientProofKeyThumbprint} AND revoked_at IS NULL RETURNING ${sql.unsafe(rowSelection)} `, }); const revokeRow = SqlSchema.findAll({ Request: RevokeAuthConnectClientInput, Result: Schema.Struct({ clientProofKeyThumbprint: Schema.String }), execute: ({ clientProofKeyThumbprint, revokedAt }) => sql` UPDATE auth_connect_clients SET revoked_at = ${revokedAt}, updated_at = ${revokedAt} WHERE client_proof_key_thumbprint = ${clientProofKeyThumbprint} AND revoked_at IS NULL RETURNING client_proof_key_thumbprint AS "clientProofKeyThumbprint" `, }); const markSeenRow = SqlSchema.findOneOption({ Request: MarkAuthConnectClientSeenInput, Result: AuthConnectClientRawDbRow, execute: ({ clientProofKeyThumbprint, seenAt }) => sql` UPDATE auth_connect_clients SET last_seen_at = CASE WHEN status = 'approved' THEN ${seenAt} ELSE last_seen_at END, updated_at = CASE WHEN status = 'approved' THEN ${seenAt} ELSE updated_at END WHERE client_proof_key_thumbprint = ${clientProofKeyThumbprint} AND revoked_at IS NULL RETURNING ${sql.unsafe(rowSelection)} `, }); const listRows = SqlSchema.findAll({ Request: Schema.Void, Result: AuthConnectClientRawDbRow, execute: () => sql` SELECT ${sql.unsafe(rowSelection)} FROM auth_connect_clients WHERE revoked_at IS NULL ORDER BY CASE status WHEN 'pending' THEN 0 WHEN 'approved' THEN 1 ELSE 2 END, updated_at DESC, client_proof_key_thumbprint DESC `, }); const decodeRow = ( row: typeof AuthConnectClientRawDbRow.Type, operation: string, ): Effect.Effect => decodeAuthConnectClientDbRow(row).pipe( Effect.mapError((cause) => PersistenceDecodeError.fromSchemaError(operation, cause, { clientProofKeyThumbprint: row.clientProofKeyThumbprint, }), ), Effect.map(toAuthConnectClientRecord), ); const upsertRequest: AuthConnectClientRepository["Service"]["upsertRequest"] = (input) => upsertRequestRow(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "AuthConnectClientRepository.upsertRequest:query", "AuthConnectClientRepository.upsertRequest:decodeRow", { clientProofKeyThumbprint: input.clientProofKeyThumbprint }, ), ), Effect.flatMap((row) => decodeRow(row, "AuthConnectClientRepository.upsertRequest:decodeRow"), ), ); const updateStatus: AuthConnectClientRepository["Service"]["updateStatus"] = (input) => updateStatusRow(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "AuthConnectClientRepository.updateStatus:query", "AuthConnectClientRepository.updateStatus:decodeRow", { clientProofKeyThumbprint: input.clientProofKeyThumbprint }, ), ), Effect.flatMap((rowOption) => Option.match(rowOption, { onNone: () => Effect.succeed(Option.none()), onSome: (row) => decodeRow(row, "AuthConnectClientRepository.updateStatus:decodeRow").pipe( Effect.map(Option.some), ), }), ), ); const revoke: AuthConnectClientRepository["Service"]["revoke"] = (input) => revokeRow(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "AuthConnectClientRepository.revoke:query", "AuthConnectClientRepository.revoke:decodeRows", { clientProofKeyThumbprint: input.clientProofKeyThumbprint }, ), ), Effect.map((rows) => rows.length > 0), ); const markSeen: AuthConnectClientRepository["Service"]["markSeen"] = (input) => markSeenRow(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "AuthConnectClientRepository.markSeen:query", "AuthConnectClientRepository.markSeen:decodeRow", { clientProofKeyThumbprint: input.clientProofKeyThumbprint }, ), ), Effect.flatMap((rowOption) => Option.match(rowOption, { onNone: () => Effect.succeed(Option.none()), onSome: (row) => decodeRow(row, "AuthConnectClientRepository.markSeen:decodeRow").pipe( Effect.map(Option.some), ), }), ), ); const listActive: AuthConnectClientRepository["Service"]["listActive"] = () => listRows().pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "AuthConnectClientRepository.listActive:query", "AuthConnectClientRepository.listActive:decodeRows", ), ), Effect.flatMap((rows) => Effect.forEach(rows, (row) => decodeRow(row, "AuthConnectClientRepository.listActive:decodeRows"), ), ), ); return { upsertRequest, updateStatus, revoke, markSeen, listActive, } satisfies AuthConnectClientRepository["Service"]; }); export const layer = Layer.effect(AuthConnectClientRepository, make);