import { AuthConnectSecurityMode, type AuthClientMetadata, type AuthClientPresentationMetadata, type AuthConnectClient, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as AuthConnectClients from "../persistence/AuthConnectClients.ts"; import type { AuthConnectClientRepositoryError } from "../persistence/Errors.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; const CONNECT_SECURITY_MODE_SECRET = "connect-security-mode"; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); const connectClientInternalErrorContext = { cause: Schema.Defect(), }; export class ConnectSecurityModeLoadError extends Schema.TaggedErrorClass()( "ConnectSecurityModeLoadError", { ...connectClientInternalErrorContext, }, ) { override get message(): string { return "Failed to load Connect security mode."; } } export class ConnectSecurityModeUpdateError extends Schema.TaggedErrorClass()( "ConnectSecurityModeUpdateError", { ...connectClientInternalErrorContext, }, ) { override get message(): string { return "Failed to update Connect security mode."; } } export class ConnectClientsLoadError extends Schema.TaggedErrorClass()( "ConnectClientsLoadError", { ...connectClientInternalErrorContext, }, ) { override get message(): string { return "Failed to load Connect clients."; } } export class ConnectClientRequestError extends Schema.TaggedErrorClass()( "ConnectClientRequestError", { clientProofKeyThumbprint: Schema.String, ...connectClientInternalErrorContext, }, ) { override get message(): string { return `Failed to record Connect client '${this.clientProofKeyThumbprint}'.`; } } export class ConnectClientApprovalError extends Schema.TaggedErrorClass()( "ConnectClientApprovalError", { clientProofKeyThumbprint: Schema.String, ...connectClientInternalErrorContext, }, ) { override get message(): string { return `Failed to approve Connect client '${this.clientProofKeyThumbprint}'.`; } } export class ConnectClientRejectionError extends Schema.TaggedErrorClass()( "ConnectClientRejectionError", { clientProofKeyThumbprint: Schema.String, ...connectClientInternalErrorContext, }, ) { override get message(): string { return `Failed to reject Connect client '${this.clientProofKeyThumbprint}'.`; } } export class ConnectClientRevocationError extends Schema.TaggedErrorClass()( "ConnectClientRevocationError", { clientProofKeyThumbprint: Schema.String, ...connectClientInternalErrorContext, }, ) { override get message(): string { return `Failed to revoke Connect client '${this.clientProofKeyThumbprint}'.`; } } export const ConnectClientStoreError = Schema.Union([ ConnectSecurityModeLoadError, ConnectSecurityModeUpdateError, ConnectClientsLoadError, ConnectClientRequestError, ConnectClientApprovalError, ConnectClientRejectionError, ConnectClientRevocationError, ]); export type ConnectClientStoreError = typeof ConnectClientStoreError.Type; export const isConnectClientStoreError = Schema.is(ConnectClientStoreError); export type ConnectClientChange = | { readonly type: "connectSecurityModeUpdated"; readonly mode: AuthConnectSecurityMode; } | { readonly type: "connectClientUpserted"; readonly client: AuthConnectClient; } | { readonly type: "connectClientRemoved"; readonly clientProofKeyThumbprint: string; }; export type ConnectClientAuthorization = | { readonly mode: "account"; readonly status: "approved"; } | { readonly mode: "client-approval"; readonly status: "pending" | "approved" | "rejected"; readonly client: AuthConnectClient; }; export class ConnectClientStore extends Context.Service< ConnectClientStore, { readonly getSecurityMode: () => Effect.Effect< AuthConnectSecurityMode, ConnectSecurityModeLoadError >; readonly setSecurityMode: ( mode: AuthConnectSecurityMode, ) => Effect.Effect; readonly listClients: () => Effect.Effect< ReadonlyArray, ConnectClientsLoadError >; readonly requestClient: (input: { readonly cloudUserId: string; readonly clientProofKeyThumbprint: string; readonly deviceId?: string; readonly client?: AuthClientPresentationMetadata; }) => Effect.Effect< ConnectClientAuthorization, ConnectClientRequestError | ConnectSecurityModeLoadError >; readonly approve: ( clientProofKeyThumbprint: string, ) => Effect.Effect, ConnectClientApprovalError>; readonly reject: ( clientProofKeyThumbprint: string, ) => Effect.Effect, ConnectClientRejectionError>; readonly revoke: ( clientProofKeyThumbprint: string, ) => Effect.Effect; readonly streamChanges: Stream.Stream; } >()("t3/auth/ConnectClientStore") {} function toAuthClientMetadata( record: AuthConnectClients.AuthConnectClientMetadataRecord, ): AuthClientMetadata { return { ...(record.label ? { label: record.label } : {}), ...(record.ipAddress ? { ipAddress: record.ipAddress } : {}), ...(record.userAgent ? { userAgent: record.userAgent } : {}), deviceType: record.deviceType, ...(record.os ? { os: record.os } : {}), ...(record.browser ? { browser: record.browser } : {}), }; } function toAuthConnectClient( record: AuthConnectClients.AuthConnectClientRecord, ): AuthConnectClient { return { clientProofKeyThumbprint: record.clientProofKeyThumbprint, cloudUserId: record.cloudUserId, ...(record.deviceId ? { deviceId: record.deviceId } : {}), status: record.status, client: toAuthClientMetadata(record.client), requestedAt: DateTime.toUtc(record.requestedAt), updatedAt: DateTime.toUtc(record.updatedAt), approvedAt: record.approvedAt === null ? null : DateTime.toUtc(record.approvedAt), rejectedAt: record.rejectedAt === null ? null : DateTime.toUtc(record.rejectedAt), lastSeenAt: record.lastSeenAt === null ? null : DateTime.toUtc(record.lastSeenAt), }; } function fromPresentationMetadata( client: AuthClientPresentationMetadata | undefined, ): AuthConnectClients.AuthConnectClientMetadataRecord { return { label: client?.label ?? null, ipAddress: null, userAgent: null, deviceType: client?.deviceType ?? "unknown", os: client?.os ?? null, browser: null, }; } function decodeSecurityMode(bytes: Uint8Array): AuthConnectSecurityMode { const value = textDecoder.decode(bytes).trim(); return value === "client-approval" ? "client-approval" : "account"; } function encodeSecurityMode(mode: AuthConnectSecurityMode): Uint8Array { return textEncoder.encode(mode); } export const make = Effect.gen(function* () { const secrets = yield* ServerSecretStore.ServerSecretStore; const repository = yield* AuthConnectClients.AuthConnectClientRepository; const changesPubSub = yield* PubSub.unbounded(); const emitMode = (mode: AuthConnectSecurityMode) => PubSub.publish(changesPubSub, { type: "connectSecurityModeUpdated", mode, }).pipe(Effect.asVoid); const emitUpsert = (client: AuthConnectClient) => PubSub.publish(changesPubSub, { type: "connectClientUpserted", client, }).pipe(Effect.asVoid); const emitRemoved = (clientProofKeyThumbprint: string) => PubSub.publish(changesPubSub, { type: "connectClientRemoved", clientProofKeyThumbprint, }).pipe(Effect.asVoid); const getSecurityMode: ConnectClientStore["Service"]["getSecurityMode"] = () => secrets.get(CONNECT_SECURITY_MODE_SECRET).pipe( Effect.map((mode) => Option.isSome(mode) ? decodeSecurityMode(mode.value) : ("account" as const), ), Effect.mapError((cause) => new ConnectSecurityModeLoadError({ cause })), Effect.withSpan("ConnectClientStore.getSecurityMode"), ); const setSecurityMode: ConnectClientStore["Service"]["setSecurityMode"] = (mode) => secrets.set(CONNECT_SECURITY_MODE_SECRET, encodeSecurityMode(mode)).pipe( Effect.as(mode), Effect.tap(emitMode), Effect.mapError((cause) => new ConnectSecurityModeUpdateError({ cause })), Effect.withSpan("ConnectClientStore.setSecurityMode"), ); const listClients: ConnectClientStore["Service"]["listClients"] = () => repository.listActive().pipe( Effect.map((clients) => clients.map(toAuthConnectClient)), Effect.mapError((cause) => new ConnectClientsLoadError({ cause })), Effect.withSpan("ConnectClientStore.listClients"), ); const requestClient: ConnectClientStore["Service"]["requestClient"] = Effect.fn( "ConnectClientStore.requestClient", )(function* (input) { const mode = yield* getSecurityMode(); if (mode === "account") { return { mode, status: "approved" as const }; } const requestedAt = yield* DateTime.now; const record = yield* repository .upsertRequest({ clientProofKeyThumbprint: input.clientProofKeyThumbprint, cloudUserId: input.cloudUserId, deviceId: input.deviceId ?? null, client: fromPresentationMetadata(input.client), requestedAt, }) .pipe( Effect.mapError( (cause) => new ConnectClientRequestError({ clientProofKeyThumbprint: input.clientProofKeyThumbprint, cause, }), ), ); const visibleClient = toAuthConnectClient(record); yield* emitUpsert(visibleClient); if (record.status !== "approved") { return { mode, status: record.status, client: visibleClient }; } const seenAt = yield* DateTime.now; const seen = yield* repository .markSeen({ clientProofKeyThumbprint: input.clientProofKeyThumbprint, seenAt, }) .pipe( Effect.mapError( (cause) => new ConnectClientRequestError({ clientProofKeyThumbprint: input.clientProofKeyThumbprint, cause, }), ), ); if (Option.isSome(seen)) { const seenClient = toAuthConnectClient(seen.value); yield* emitUpsert(seenClient); return { mode, status: "approved" as const, client: seenClient }; } return { mode, status: "rejected" as const, client: visibleClient }; }); const updateDecision = ( clientProofKeyThumbprint: string, status: "approved" | "rejected", toError: (cause: AuthConnectClientRepositoryError) => E, ): Effect.Effect, E> => DateTime.now.pipe( Effect.flatMap((decidedAt) => repository.updateStatus({ clientProofKeyThumbprint, status, decidedAt, }), ), Effect.mapError(toError), Effect.map((updated) => Option.map(updated, toAuthConnectClient)), Effect.tap((updated) => (Option.isSome(updated) ? emitUpsert(updated.value) : Effect.void)), ); const approve: ConnectClientStore["Service"]["approve"] = (clientProofKeyThumbprint) => updateDecision( clientProofKeyThumbprint, "approved", (cause) => new ConnectClientApprovalError({ clientProofKeyThumbprint, cause, }), ).pipe(Effect.withSpan("ConnectClientStore.approve")); const reject: ConnectClientStore["Service"]["reject"] = (clientProofKeyThumbprint) => updateDecision( clientProofKeyThumbprint, "rejected", (cause) => new ConnectClientRejectionError({ clientProofKeyThumbprint, cause, }), ).pipe(Effect.withSpan("ConnectClientStore.reject")); const revoke: ConnectClientStore["Service"]["revoke"] = (clientProofKeyThumbprint) => DateTime.now.pipe( Effect.flatMap((revokedAt) => repository.revoke({ clientProofKeyThumbprint, revokedAt, }), ), Effect.tap((revoked) => (revoked ? emitRemoved(clientProofKeyThumbprint) : Effect.void)), Effect.mapError( (cause) => new ConnectClientRevocationError({ clientProofKeyThumbprint, cause, }), ), Effect.withSpan("ConnectClientStore.revoke"), ); return ConnectClientStore.of({ getSecurityMode, setSecurityMode, listClients, requestClient, approve, reject, revoke, get streamChanges() { return Stream.fromPubSub(changesPubSub); }, }); }); export const layer = Layer.effect(ConnectClientStore, make).pipe( Layer.provideMerge(AuthConnectClients.layer), );