424 lines · 13.3 KB · TypeScript
RawDownload
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>()(  "ConnectSecurityModeLoadError",  {    ...connectClientInternalErrorContext,  },) {  override get message(): string {    return "Failed to load Connect security mode.";  }} export class ConnectSecurityModeUpdateError extends Schema.TaggedErrorClass<ConnectSecurityModeUpdateError>()(  "ConnectSecurityModeUpdateError",  {    ...connectClientInternalErrorContext,  },) {  override get message(): string {    return "Failed to update Connect security mode.";  }} export class ConnectClientsLoadError extends Schema.TaggedErrorClass<ConnectClientsLoadError>()(  "ConnectClientsLoadError",  {    ...connectClientInternalErrorContext,  },) {  override get message(): string {    return "Failed to load Connect clients.";  }} export class ConnectClientRequestError extends Schema.TaggedErrorClass<ConnectClientRequestError>()(  "ConnectClientRequestError",  {    clientProofKeyThumbprint: Schema.String,    ...connectClientInternalErrorContext,  },) {  override get message(): string {    return `Failed to record Connect client '${this.clientProofKeyThumbprint}'.`;  }} export class ConnectClientApprovalError extends Schema.TaggedErrorClass<ConnectClientApprovalError>()(  "ConnectClientApprovalError",  {    clientProofKeyThumbprint: Schema.String,    ...connectClientInternalErrorContext,  },) {  override get message(): string {    return `Failed to approve Connect client '${this.clientProofKeyThumbprint}'.`;  }} export class ConnectClientRejectionError extends Schema.TaggedErrorClass<ConnectClientRejectionError>()(  "ConnectClientRejectionError",  {    clientProofKeyThumbprint: Schema.String,    ...connectClientInternalErrorContext,  },) {  override get message(): string {    return `Failed to reject Connect client '${this.clientProofKeyThumbprint}'.`;  }} export class ConnectClientRevocationError extends Schema.TaggedErrorClass<ConnectClientRevocationError>()(  "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<AuthConnectSecurityMode, ConnectSecurityModeUpdateError>;    readonly listClients: () => Effect.Effect<      ReadonlyArray<AuthConnectClient>,      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<Option.Option<AuthConnectClient>, ConnectClientApprovalError>;    readonly reject: (      clientProofKeyThumbprint: string,    ) => Effect.Effect<Option.Option<AuthConnectClient>, ConnectClientRejectionError>;    readonly revoke: (      clientProofKeyThumbprint: string,    ) => Effect.Effect<boolean, ConnectClientRevocationError>;    readonly streamChanges: Stream.Stream<ConnectClientChange>;  }>()("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 === "account" || value === "client-approval" ? value : "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<ConnectClientChange>();   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: seen.value.status, client: seenClient };    }     return {      mode,      status: "pending" as const,      client: {        ...visibleClient,        status: "pending" as const,        updatedAt: DateTime.toUtc(seenAt),        approvedAt: null,        lastSeenAt: null,      },    };  });   const updateDecision = <E extends ConnectClientApprovalError | ConnectClientRejectionError>(    clientProofKeyThumbprint: string,    status: "approved" | "rejected",    toError: (cause: AuthConnectClientRepositoryError) => E,  ): Effect.Effect<Option.Option<AuthConnectClient>, 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),);