apps/server/src/telemetry/Identify.ts

import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
import * as Base64Url from "effect/encoding/Base64Url";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";
import * as Result from "effect/Result";
import * as Schema from "effect/Schema";

import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
import * as HostProcess from "@t3tools/shared/HostProcess";
import { hashTelemetryId } from "@t3tools/shared/telemetryId";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import { CLOUD_LINKED_USER_ID } from "../cloud/config.ts";
import * as ServerConfig from "../config.ts";

/**
 * Codex writes `tokens` only for ChatGPT logins and omits the key for API-key,
 * agent-identity, and personal-access-token logins, so its absence is a
 * supported install and not a malformed file. `account_id` is the ChatGPT
 * workspace, which a whole Team shares; the `id_token` names the person.
 */
const CodexAuthJsonSchema = Schema.Struct({
  tokens: Schema.optionalKey(
    Schema.Struct({
      account_id: Schema.String,
      id_token: Schema.optionalKey(Schema.String),
    }),
  ),
});

/** `userID` is random per machine. `oauthAccount` is present only while Claude Code is logged in. */
const ClaudeJsonSchema = Schema.Struct({
  userID: Schema.optionalKey(Schema.String),
  oauthAccount: Schema.optionalKey(
    Schema.Struct({
      accountUuid: Schema.optionalKey(Schema.String),
    }),
  ),
});

// `sub` names the login method (`google-oauth2|…`), so one person can have several.
// `user_id` (`user-…`) is the OpenAI account and is the same for every login.
const IdTokenClaims = Schema.Struct({
  "https://api.openai.com/auth": Schema.Struct({ user_id: Schema.NonEmptyString }),
});
const decodeIdTokenClaims = Schema.decodeUnknownOption(Schema.fromJsonString(IdTokenClaims));

/**
 * Who an install belongs to, from strongest to weakest. `connect` is the T3
 * Connect account. `openai` and `claude` are provider accounts. `device` is a
 * client device that connected.
 */
export const TelemetryOwnerSource = Schema.Literals(["connect", "openai", "claude", "device"]);
export type TelemetryOwnerSource = typeof TelemetryOwnerSource.Type;

export interface TelemetryOwner {
  readonly source: TelemetryOwnerSource;
  /** `hashTelemetryId` of the source's raw id. */
  readonly ownerId: string;
}

export const TelemetryIdentitySource = Schema.Literals(["codex", "claude", "anonymous"]);
export type TelemetryIdentitySource = typeof TelemetryIdentitySource.Type;

class TelemetryIdentityReadError extends Schema.TaggedError<TelemetryIdentityReadError>()(
  "TelemetryIdentityReadError",
  {
    source: TelemetryIdentitySource,
    filePath: Schema.String,
    cause: Schema.Defect(),
  },
) {
  override get message(): string {
    return `Failed to read ${this.source} telemetry identity at '${this.filePath}'.`;
  }
}

class TelemetryIdentityDecodeError extends Schema.TaggedError<TelemetryIdentityDecodeError>()(
  "TelemetryIdentityDecodeError",
  {
    source: Schema.Literals(["codex", "claude"]),
    filePath: Schema.String,
    cause: Schema.Defect(),
  },
) {
  override get message(): string {
    return `Failed to decode ${this.source} telemetry identity at '${this.filePath}'.`;
  }
}

export class TelemetryAnonymousIdGenerationError extends Schema.TaggedError<TelemetryAnonymousIdGenerationError>()(
  "TelemetryAnonymousIdGenerationError",
  {
    source: Schema.Literal("anonymous"),
    filePath: Schema.String,
    cause: Schema.Defect(),
  },
) {
  override get message(): string {
    return `Failed to generate anonymous telemetry identity for '${this.filePath}'.`;
  }
}

export class TelemetryAnonymousIdPersistenceError extends Schema.TaggedError<TelemetryAnonymousIdPersistenceError>()(
  "TelemetryAnonymousIdPersistenceError",
  {
    source: Schema.Literal("anonymous"),
    filePath: Schema.String,
    cause: Schema.Defect(),
  },
) {
  override get message(): string {
    return `Failed to persist anonymous telemetry identity at '${this.filePath}'.`;
  }
}

type TelemetryIdentityError =
  | TelemetryIdentityReadError
  | TelemetryIdentityDecodeError
  | TelemetryAnonymousIdGenerationError
  | TelemetryAnonymousIdPersistenceError;

const decodeCodexAuthJson = Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema));
const decodeClaudeJson = Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema));

function isNotFoundError(error: PlatformError.PlatformError): boolean {
  return error.reason._tag === "NotFound";
}

const getTelemetryIdentityCauseAnnotations = (cause: unknown) => {
  if (cause instanceof PlatformError.PlatformError) {
    return {
      causeKind: "platform",
      platformReason: cause.reason._tag,
    };
  }
  if (cause instanceof Schema.SchemaError) {
    return { causeKind: "schema" };
  }
  return { causeKind: "other" };
};

const logTelemetryIdentityError = (error: TelemetryIdentityError) =>
  Effect.logWarning(error.message).pipe(
    Effect.annotateLogs({
      errorTag: error._tag,
      source: error.source,
      ...("filePath" in error ? { filePath: error.filePath } : {}),
      ...getTelemetryIdentityCauseAnnotations(error.cause),
      ...(error.stack === undefined ? {} : { errorStack: error.stack }),
    }),
  );

const readIdentityFile = (
  fileSystem: FileSystem.FileSystem,
  source: TelemetryIdentitySource,
  filePath: string,
) =>
  fileSystem.readFileString(filePath).pipe(
    Effect.asSome,
    Effect.catchTags({
      PlatformError: (cause) =>
        isNotFoundError(cause)
          ? Effect.succeed(Option.none<string>())
          : Effect.fail(
              new TelemetryIdentityReadError({
                source,
                filePath,
                cause,
              }),
            ),
    }),
  );

/**
 * The OpenAI account id in a ChatGPT id_token, read without verifying it:
 * analytics needs only a stable id. Codex CLI and T3's own sign-in both use it.
 */
export const openAiUserId = (idToken: string) => {
  const payload = Base64Url.decodeString(idToken.split(".")[1] ?? "");
  return Result.isSuccess(payload)
    ? Option.map(
        decodeIdTokenClaims(payload.success),
        (claims) => claims["https://api.openai.com/auth"].user_id,
      )
    : Option.none<string>();
};

const readJsonIdentityFile = Effect.fn("TelemetryIdentity.readJsonIdentityFile")(function* <A>(
  source: "codex" | "claude",
  filePath: string,
  decode: (encoded: string) => Effect.Effect<A, Schema.SchemaError>,
) {
  const fileSystem = yield* FileSystem.FileSystem;
  const encoded = yield* readIdentityFile(fileSystem, source, filePath);
  if (Option.isNone(encoded)) {
    return Option.none<A>();
  }
  return yield* decode(encoded.value).pipe(
    Effect.asSome,
    Effect.mapError((cause) => new TelemetryIdentityDecodeError({ source, filePath, cause })),
  );
});

// A missing or unreadable file only removes that owner; it never blocks the others.
const skipOnError = <A, R>(
  effect: Effect.Effect<
    Option.Option<A>,
    TelemetryIdentityReadError | TelemetryIdentityDecodeError,
    R
  >,
) =>
  effect.pipe(
    Effect.catch((error) => logTelemetryIdentityError(error).pipe(Effect.as(Option.none<A>()))),
  );

const upsertAnonymousId = Effect.gen(function* () {
  const fileSystem = yield* FileSystem.FileSystem;
  const { anonymousIdPath } = yield* ServerConfig.ServerConfig;

  const existing = yield* readIdentityFile(fileSystem, "anonymous", anonymousIdPath);
  if (Option.isSome(existing)) {
    return existing.value;
  }

  const anonymousId = yield* Crypto.Crypto.pipe(
    Effect.flatMap((crypto) => crypto.randomUUIDv4()),
    Effect.mapError(
      (cause) =>
        new TelemetryAnonymousIdGenerationError({
          source: "anonymous",
          filePath: anonymousIdPath,
          cause,
        }),
    ),
  );
  yield* writeFileStringAtomically({ filePath: anonymousIdPath, contents: anonymousId }).pipe(
    Effect.mapError(
      (cause) =>
        new TelemetryAnonymousIdPersistenceError({
          source: "anonymous",
          filePath: anonymousIdPath,
          cause,
        }),
    ),
  );

  return anonymousId;
});

const readProviderLogins = Effect.fn("TelemetryIdentity.readProviderLogins")(function* (
  homeDirectory: string,
) {
  const path = yield* Path.Path;
  const codexTokens = yield* readJsonIdentityFile(
    "codex",
    path.join(homeDirectory, ".codex", "auth.json"),
    decodeCodexAuthJson,
  ).pipe(
    skipOnError,
    Effect.map(Option.flatMap((authJson) => Option.fromUndefinedOr(authJson.tokens))),
  );
  const claudeJson = yield* readJsonIdentityFile(
    "claude",
    path.join(homeDirectory, ".claude.json"),
    decodeClaudeJson,
  ).pipe(skipOnError);
  return { codexTokens, claudeJson };
});

/**
 * `distinctId` preserves legacy priority: hashed Codex account_id, Claude userID, then anonymous id.
 * It can change on login or be shared by a Codex Team; hashed `installId` stays stable per install.
 * A null `distinctId` disables telemetry. Missing files and API-key-only Codex auth fall through;
 * only unreadable or malformed files warn.
 */
export const getTelemetryIdentityForHome = Effect.fn("getTelemetryIdentityForHome")(function* (
  homeDirectory: string,
) {
  const { codexTokens, claudeJson } = yield* readProviderLogins(homeDirectory);
  const installId = yield* upsertAnonymousId.pipe(
    Effect.map(hashTelemetryId),
    Effect.catch((error) => logTelemetryIdentityError(error).pipe(Effect.as(null))),
  );
  const providerId = Option.orElse(
    Option.map(codexTokens, (tokens) => tokens.account_id),
    () => Option.flatMap(claudeJson, (json) => Option.fromUndefinedOr(json.userID)),
  );
  return {
    distinctId: Option.isSome(providerId) ? hashTelemetryId(providerId.value) : installId,
    installId,
  };
});

export const getTelemetryIdentity = Effect.flatMap(
  HostProcess.HomeDirectory,
  getTelemetryIdentityForHome,
);

/**
 * Owners the server can read without a client: the linked T3 Connect account
 * and the provider logins under `homeDirectory`. Clients add `connect` and
 * `device` owners when they connect.
 */
export const getServerOwnersForHome = Effect.fn("getServerOwnersForHome")(function* (
  homeDirectory: string,
) {
  const secrets = yield* ServerSecretStore.ServerSecretStore;
  const connectUser = yield* secrets.get(CLOUD_LINKED_USER_ID).pipe(
    Effect.map(Option.map((bytes) => new TextDecoder().decode(bytes))),
    Effect.catch((cause) =>
      Effect.logWarning("Failed to read the linked T3 Connect account for telemetry", {
        errorTag: cause._tag,
      }).pipe(Effect.as(Option.none<string>())),
    ),
  );
  const { codexTokens, claudeJson } = yield* readProviderLogins(homeDirectory);

  const raw: ReadonlyArray<readonly [TelemetryOwnerSource, Option.Option<string>]> = [
    ["connect", connectUser],
    [
      "openai",
      Option.flatMap(codexTokens, (tokens) =>
        Option.flatMap(Option.fromUndefinedOr(tokens.id_token), openAiUserId),
      ),
    ],
    [
      "claude",
      Option.flatMap(claudeJson, (json) => Option.fromUndefinedOr(json.oauthAccount?.accountUuid)),
    ],
  ];
  return raw.flatMap(([source, value]): ReadonlyArray<TelemetryOwner> =>
    Option.isSome(value) && value.value.trim() !== ""
      ? [{ source, ownerId: hashTelemetryId(value.value) }]
      : [],
  );
});

export const getServerOwners = Effect.flatMap(HostProcess.HomeDirectory, getServerOwnersForHome);