From 8f2391627de197e3da2f6969859be032534bbab4 Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Wed, 27 May 2026 18:54:06 +0800 Subject: [PATCH 1/3] fix(cli): skip identity stitch in CI to stop ephemeral-env identify spam The OnGotrueID hook in cmd/root.go calls StitchLogin once per process when NeedsIdentityStitch() returns true. SaveState persists distinct_id to ~/.supabase/telemetry.json synchronously, which works fine on a stable machine. In CI runners, Docker, and npx wrappers the home directory is wiped between invocations, so every fresh process sees an empty DistinctID and re-stitches. Daily $identify volume from posthog-go went from ~15K to ~640K/day after the Go CLI's first credentialed deploy and kept growing. Gate NeedsIdentityStitch on !isCI so the auto-stitch from the X-Gotrue-Id response header is suppressed in CI. canSend() is left alone, so cli_* capture events (cli_command_executed, cli_stack_started, cli_project_linked) still fire from CI, preserving the 31-85% of CLI usage that runs in CI and the dashboards built on it. login.go calls StitchLogin directly without the guard, so an explicit supabase login still identifies in CI. --- apps/cli-go/internal/telemetry/service.go | 2 +- apps/cli-go/internal/telemetry/service_test.go | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/cli-go/internal/telemetry/service.go b/apps/cli-go/internal/telemetry/service.go index 39e0b6c073..a8e727e67f 100644 --- a/apps/cli-go/internal/telemetry/service.go +++ b/apps/cli-go/internal/telemetry/service.go @@ -154,7 +154,7 @@ func (s *Service) ClearDistinctID() error { } func (s *Service) NeedsIdentityStitch() bool { - return s != nil && s.state.DistinctID == "" && s.canSend() + return s != nil && s.state.DistinctID == "" && s.canSend() && !s.isCI } func (s *Service) GroupIdentify(groupType string, groupKey string, properties map[string]any) error { diff --git a/apps/cli-go/internal/telemetry/service_test.go b/apps/cli-go/internal/telemetry/service_test.go index c46a793bed..d8be305872 100644 --- a/apps/cli-go/internal/telemetry/service_test.go +++ b/apps/cli-go/internal/telemetry/service_test.go @@ -233,6 +233,17 @@ func TestServiceNeedsIdentityStitch(t *testing.T) { require.NoError(t, service.StitchLogin("user-123")) assert.False(t, service.NeedsIdentityStitch()) }) + + t.Run("false in CI even with empty DistinctID", func(t *testing.T) { + ciFsys := afero.NewMemMapFs() + ciService, err := NewService(ciFsys, Options{ + Analytics: &fakeAnalytics{enabled: true}, + Now: func() time.Time { return now }, + IsCI: true, + }) + require.NoError(t, err) + assert.False(t, ciService.NeedsIdentityStitch()) + }) } func TestServiceCaptureHonorsConsentAndEnvOptOut(t *testing.T) { From f4972472cc3e99e2e9fa28832f9b22bdf18e3d3a Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 27 May 2026 16:25:50 +0200 Subject: [PATCH 2/3] fix(cli): mirror legacy telemetry CI stitch guard --- .../legacy/auth/legacy-platform-api.layer.ts | 121 ++++++++- .../legacy-platform-api.layer.unit.test.ts | 248 +++++++++++++++++- 2 files changed, 360 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts b/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts index 2735e82c30..3346655459 100644 --- a/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts @@ -1,7 +1,11 @@ import { makeApiClient } from "@supabase/api/effect"; -import { Effect, Layer, Option } from "effect"; +import { Effect, FileSystem, Layer, Option, Path } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; +import { Analytics } from "../../shared/telemetry/analytics.service.ts"; +import { TelemetryRuntime } from "../../shared/telemetry/runtime.service.ts"; import { LegacyCredentials } from "./legacy-credentials.service.ts"; import { LegacyPlatformAuthRequiredError } from "./legacy-errors.ts"; import { LegacyPlatformApi } from "./legacy-platform-api.service.ts"; @@ -9,9 +13,109 @@ import { LegacyPlatformApi } from "./legacy-platform-api.service.ts"; const MISSING_TOKEN_MESSAGE = "Access token not provided. Supply an access token by running `supabase login` or setting the SUPABASE_ACCESS_TOKEN environment variable."; +const HEADER_GOTRUE_ID = "x-gotrue-id"; +const TELEMETRY_SCHEMA_VERSION = 1; + +interface LegacyTelemetryState { + readonly enabled: boolean; + readonly device_id: string; + readonly session_id: string; + readonly session_last_active: string; + readonly distinct_id: string; + readonly schema_version: number; +} + +function gotrueIdFromResponse(response: HttpClientResponse.HttpClientResponse): string | undefined { + const value = response.headers[HEADER_GOTRUE_ID] ?? response.headers["X-Gotrue-Id"]; + if (value === undefined) return undefined; + const trimmed = value.trim(); + return trimmed.length === 0 ? undefined : trimmed; +} + +function fieldValue(value: unknown, key: string): unknown { + if (typeof value !== "object" || value === null) return undefined; + return Reflect.get(value, key); +} + +function stringField(value: unknown, key: string): string | undefined { + const field = fieldValue(value, key); + return typeof field === "string" && field.length > 0 ? field : undefined; +} + +function boolField(value: unknown, key: string): boolean | undefined { + const field = fieldValue(value, key); + return typeof field === "boolean" ? field : undefined; +} + +function numberField(value: unknown, key: string): number | undefined { + const field = fieldValue(value, key); + return typeof field === "number" && Number.isFinite(field) ? field : undefined; +} + const makeLegacyPlatformApiServices = Effect.gen(function* () { const cliConfig = yield* LegacyCliConfig; const credentials = yield* LegacyCredentials; + const analytics = yield* Analytics; + const runtime = yield* TelemetryRuntime; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + let stitchAttempted = false; + + const needsIdentityStitch = + runtime.consent === "granted" && + !runtime.isCi && + (runtime.distinctId === undefined || runtime.distinctId.length === 0); + + const stitchIdentity = (gotrueId: string) => + Effect.gen(function* () { + if (!needsIdentityStitch || stitchAttempted) return; + + const telemetryPath = path.join(runtime.configDir, "telemetry.json"); + const existing = yield* fs.readFileString(telemetryPath).pipe(Effect.option); + const prior = Option.match(existing, { + onNone: () => undefined, + onSome: (content) => { + try { + const parsed: unknown = JSON.parse(content); + return parsed; + } catch { + return undefined; + } + }, + }); + const enabled = boolField(prior, "enabled") ?? true; + if (!enabled) return; + + stitchAttempted = true; + + yield* analytics.alias(gotrueId, runtime.deviceId); + yield* analytics.identify(gotrueId); + + const state: LegacyTelemetryState = { + enabled, + device_id: stringField(prior, "device_id") ?? runtime.deviceId, + session_id: stringField(prior, "session_id") ?? runtime.sessionId, + session_last_active: new Date().toISOString(), + distinct_id: gotrueId, + schema_version: numberField(prior, "schema_version") ?? TELEMETRY_SCHEMA_VERSION, + }; + + yield* fs.makeDirectory(runtime.configDir, { recursive: true }); + yield* fs.writeFileString(telemetryPath, JSON.stringify(state)); + }); + + const transformClient = (client: HttpClient.HttpClient) => + Effect.succeed( + HttpClient.transform(client, (requestEffect) => + requestEffect.pipe( + Effect.tap((response) => { + const gotrueId = gotrueIdFromResponse(response); + if (gotrueId === undefined) return Effect.void; + return stitchIdentity(gotrueId).pipe(Effect.exit, Effect.asVoid); + }), + ), + ), + ); // Env takes precedence over keyring/file (already inside LegacyCredentials), but // LegacyCliConfig.accessToken is the env value alone — read in the same order Go uses. @@ -26,11 +130,16 @@ const makeLegacyPlatformApiServices = Effect.gen(function* () { ); } - const api = yield* makeApiClient({ - baseUrl: cliConfig.apiUrl, - accessToken: storedToken.value, - userAgent: cliConfig.userAgent, - }); + const api = yield* makeApiClient( + { + baseUrl: cliConfig.apiUrl, + accessToken: storedToken.value, + userAgent: cliConfig.userAgent, + }, + { + transformClient, + }, + ); return Layer.succeed(LegacyPlatformApi, api); }); diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts index 205034f58c..e0a672d49c 100644 --- a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts @@ -1,15 +1,22 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option, Redacted } from "effect"; +import { Effect, Exit, FileSystem, Layer, Option, Path, Redacted } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { Analytics } from "../../shared/telemetry/analytics.service.ts"; +import { TelemetryRuntime } from "../../shared/telemetry/runtime.service.ts"; import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; import { LegacyCredentials } from "./legacy-credentials.service.ts"; import { legacyPlatformApiLayer } from "./legacy-platform-api.layer.ts"; import { LegacyPlatformApi } from "./legacy-platform-api.service.ts"; const VALID_TOKEN = "sbp_" + "a".repeat(40); +const SESSION_LAST_ACTIVE = 1_777_200_000_000; function mockCliConfig(opts: { accessToken?: string; apiUrl?: string; userAgent?: string }) { return Layer.succeed(LegacyCliConfig, { @@ -31,7 +38,132 @@ function mockCredentials(token: Option.Option) { }); } -function captureRequests() { +function mockTelemetryRuntime( + opts: { + configDir?: string; + deviceId?: string; + distinctId?: string; + isCi?: boolean; + } = {}, +) { + return Layer.succeed( + TelemetryRuntime, + TelemetryRuntime.of({ + configDir: opts.configDir ?? "/tmp/supabase-cli-test-home", + tracesDir: path.join(opts.configDir ?? "/tmp/supabase-cli-test-home", "traces"), + consent: "granted", + showDebug: false, + deviceId: opts.deviceId ?? "device-123", + sessionId: "session-123", + ...(opts.distinctId === undefined ? {} : { distinctId: opts.distinctId }), + isFirstRun: false, + isTty: false, + isCi: opts.isCi ?? false, + os: "darwin", + arch: "arm64", + cliVersion: "0.0.0-test", + }), + ); +} + +function mockAnalytics() { + const aliases: Array<{ distinctId: string; alias: string }> = []; + const identifies: Array<{ distinctId: string; properties: Record }> = []; + + const layer = Layer.succeed( + Analytics, + Analytics.of({ + capture: () => Effect.void, + identify: (distinctId, properties = {}) => + Effect.sync(() => { + identifies.push({ distinctId, properties }); + }), + alias: (distinctId, alias) => + Effect.sync(() => { + aliases.push({ distinctId, alias }); + }), + groupIdentify: () => Effect.void, + }), + ); + + return { layer, aliases, identifies }; +} + +function nodeFileSystemLayer() { + return Layer.succeed(FileSystem.FileSystem, { + [FileSystem.FileSystem.key]: FileSystem.FileSystem.key, + exists: (filePath: string) => + Effect.promise(() => + access(filePath) + .then(() => true) + .catch(() => false), + ), + makeDirectory: (dirPath: string, opts?: { recursive?: boolean; mode?: number }) => + Effect.promise(() => + mkdir(dirPath, { recursive: opts?.recursive, mode: opts?.mode }).then(() => undefined), + ), + readFileString: (filePath: string) => Effect.promise(() => readFile(filePath, "utf8")), + writeFileString: (filePath: string, content: string, opts?: { mode?: number }) => + Effect.promise(() => writeFile(filePath, content, { mode: opts?.mode })), + } as unknown as FileSystem.FileSystem); +} + +function nodePathLayer() { + return Layer.succeed(Path.Path, { + [Path.Path.key]: Path.Path.key, + ...path, + } as unknown as Path.Path); +} + +function tempTelemetryConfig(opts: { distinctId?: string; enabled?: boolean } = {}) { + const dir = mkdtempSync(path.join(tmpdir(), "supabase-legacy-platform-api-")); + writeFileSync( + path.join(dir, "telemetry.json"), + JSON.stringify({ + enabled: opts.enabled ?? true, + device_id: "device-123", + session_id: "session-123", + session_last_active: new Date(SESSION_LAST_ACTIVE).toISOString(), + ...(opts.distinctId === undefined ? {} : { distinct_id: opts.distinctId }), + schema_version: 1, + }), + ); + return dir; +} + +function readTelemetryConfig(configDir: string) { + return JSON.parse(readFileSync(path.join(configDir, "telemetry.json"), "utf8")) as { + enabled?: boolean; + distinct_id?: string; + schema_version?: number; + }; +} + +function withBaseDeps( + opts: { + analytics?: ReturnType; + configDir?: string; + distinctId?: string; + isCi?: boolean; + } = {}, +) { + const analytics = opts.analytics ?? mockAnalytics(); + return (layer: Layer.Layer) => + layer.pipe( + Layer.provide(analytics.layer), + Layer.provide( + mockTelemetryRuntime({ + configDir: opts.configDir, + distinctId: opts.distinctId, + isCi: opts.isCi, + }), + ), + Layer.provide(nodeFileSystemLayer()), + Layer.provide(nodePathLayer()), + ); +} + +function captureRequests(responseHeaders: Record = {}) { const requests: Array<{ url: string; headers: Readonly>; @@ -43,7 +175,7 @@ function captureRequests() { request, new Response(JSON.stringify([]), { status: 200, - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", ...responseHeaders }, }), ), ); @@ -58,6 +190,7 @@ describe("legacyPlatformApiLayer", () => { Layer.provide(mockCliConfig({ accessToken: VALID_TOKEN })), Layer.provide(mockCredentials(Option.some("sbp_" + "9".repeat(40)))), Layer.provide(http.layer), + withBaseDeps(), ); return Effect.gen(function* () { const api = yield* LegacyPlatformApi; @@ -73,6 +206,7 @@ describe("legacyPlatformApiLayer", () => { Layer.provide(mockCliConfig({})), Layer.provide(mockCredentials(Option.some(VALID_TOKEN))), Layer.provide(http.layer), + withBaseDeps(), ); return Effect.gen(function* () { const api = yield* LegacyPlatformApi; @@ -87,6 +221,7 @@ describe("legacyPlatformApiLayer", () => { Layer.provide(mockCliConfig({})), Layer.provide(mockCredentials(Option.none())), Layer.provide(http.layer), + withBaseDeps(), ); return Effect.gen(function* () { const exit = yield* Effect.exit( @@ -110,6 +245,7 @@ describe("legacyPlatformApiLayer", () => { Layer.provide(mockCliConfig({ accessToken: VALID_TOKEN, userAgent: "SupabaseCLI/1.123.4" })), Layer.provide(mockCredentials(Option.none())), Layer.provide(http.layer), + withBaseDeps(), ); return Effect.gen(function* () { const api = yield* LegacyPlatformApi; @@ -128,6 +264,7 @@ describe("legacyPlatformApiLayer", () => { ), Layer.provide(mockCredentials(Option.none())), Layer.provide(http.layer), + withBaseDeps(), ); return Effect.gen(function* () { const api = yield* LegacyPlatformApi; @@ -135,4 +272,109 @@ describe("legacyPlatformApiLayer", () => { expect(http.requests[0]?.url).toContain("https://api.supabase.green/"); }).pipe(Effect.provide(layer)); }); + + it.effect("stitches identity from X-Gotrue-Id responses outside CI", () => { + const configDir = tempTelemetryConfig(); + const analytics = mockAnalytics(); + const http = captureRequests({ "X-Gotrue-Id": "user-123" }); + const layer = legacyPlatformApiLayer.pipe( + Layer.provide(mockCliConfig({ accessToken: VALID_TOKEN })), + Layer.provide(mockCredentials(Option.none())), + Layer.provide(http.layer), + withBaseDeps({ analytics, configDir }), + ); + + return Effect.gen(function* () { + try { + const api = yield* LegacyPlatformApi; + yield* api.v1.listAllProjects(); + + expect(analytics.aliases).toEqual([{ distinctId: "user-123", alias: "device-123" }]); + expect(analytics.identifies).toEqual([{ distinctId: "user-123", properties: {} }]); + const telemetry = readTelemetryConfig(configDir); + expect(telemetry.distinct_id).toBe("user-123"); + expect(telemetry.enabled).toBe(true); + expect(telemetry.schema_version).toBe(1); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } + }).pipe(Effect.provide(layer)); + }); + + it.effect("does not stitch identity from X-Gotrue-Id responses in CI", () => { + const configDir = tempTelemetryConfig(); + const analytics = mockAnalytics(); + const http = captureRequests({ "X-Gotrue-Id": "user-123" }); + const layer = legacyPlatformApiLayer.pipe( + Layer.provide(mockCliConfig({ accessToken: VALID_TOKEN })), + Layer.provide(mockCredentials(Option.none())), + Layer.provide(http.layer), + withBaseDeps({ analytics, configDir, isCi: true }), + ); + + return Effect.gen(function* () { + try { + const api = yield* LegacyPlatformApi; + yield* api.v1.listAllProjects(); + + expect(analytics.aliases).toEqual([]); + expect(analytics.identifies).toEqual([]); + expect(readTelemetryConfig(configDir).distinct_id).toBeUndefined(); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } + }).pipe(Effect.provide(layer)); + }); + + it.effect("does not stitch identity when a distinct_id is already known", () => { + const configDir = tempTelemetryConfig({ distinctId: "existing-user" }); + const analytics = mockAnalytics(); + const http = captureRequests({ "X-Gotrue-Id": "user-123" }); + const layer = legacyPlatformApiLayer.pipe( + Layer.provide(mockCliConfig({ accessToken: VALID_TOKEN })), + Layer.provide(mockCredentials(Option.none())), + Layer.provide(http.layer), + withBaseDeps({ analytics, configDir, distinctId: "existing-user" }), + ); + + return Effect.gen(function* () { + try { + const api = yield* LegacyPlatformApi; + yield* api.v1.listAllProjects(); + + expect(analytics.aliases).toEqual([]); + expect(analytics.identifies).toEqual([]); + expect(readTelemetryConfig(configDir).distinct_id).toBe("existing-user"); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } + }).pipe(Effect.provide(layer)); + }); + + it.effect("does not stitch identity when legacy telemetry state is disabled", () => { + const configDir = tempTelemetryConfig({ enabled: false }); + const analytics = mockAnalytics(); + const http = captureRequests({ "X-Gotrue-Id": "user-123" }); + const layer = legacyPlatformApiLayer.pipe( + Layer.provide(mockCliConfig({ accessToken: VALID_TOKEN })), + Layer.provide(mockCredentials(Option.none())), + Layer.provide(http.layer), + withBaseDeps({ analytics, configDir }), + ); + + return Effect.gen(function* () { + try { + const api = yield* LegacyPlatformApi; + yield* api.v1.listAllProjects(); + + expect(analytics.aliases).toEqual([]); + expect(analytics.identifies).toEqual([]); + const telemetry = readTelemetryConfig(configDir); + expect(telemetry.enabled).toBe(false); + expect(telemetry.distinct_id).toBeUndefined(); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } + }).pipe(Effect.provide(layer)); + }); }); From efc02997cb986b298800b5084d53522c38d9bc18 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 27 May 2026 16:46:46 +0200 Subject: [PATCH 3/3] fix(cli): suppress stitch for ephemeral telemetry runs --- apps/cli-go/internal/telemetry/service.go | 6 +- .../cli-go/internal/telemetry/service_test.go | 29 ++++++++ .../legacy/auth/legacy-platform-api.layer.ts | 10 ++- .../legacy-platform-api.layer.unit.test.ts | 70 +++++++++++++++++-- 4 files changed, 106 insertions(+), 9 deletions(-) diff --git a/apps/cli-go/internal/telemetry/service.go b/apps/cli-go/internal/telemetry/service.go index a8e727e67f..26869ad5a8 100644 --- a/apps/cli-go/internal/telemetry/service.go +++ b/apps/cli-go/internal/telemetry/service.go @@ -154,7 +154,11 @@ func (s *Service) ClearDistinctID() error { } func (s *Service) NeedsIdentityStitch() bool { - return s != nil && s.state.DistinctID == "" && s.canSend() && !s.isCI + return s != nil && s.state.DistinctID == "" && s.canSend() && !s.isEphemeralIdentityRuntime() +} + +func (s *Service) isEphemeralIdentityRuntime() bool { + return s.isCI || (s.isFirstRun && !s.isTTY) } func (s *Service) GroupIdentify(groupType string, groupKey string, properties map[string]any) error { diff --git a/apps/cli-go/internal/telemetry/service_test.go b/apps/cli-go/internal/telemetry/service_test.go index d8be305872..5a19198c83 100644 --- a/apps/cli-go/internal/telemetry/service_test.go +++ b/apps/cli-go/internal/telemetry/service_test.go @@ -222,6 +222,7 @@ func TestServiceNeedsIdentityStitch(t *testing.T) { service, err := NewService(fsys, Options{ Analytics: analytics, Now: func() time.Time { return now }, + IsTTY: true, }) require.NoError(t, err) @@ -244,6 +245,34 @@ func TestServiceNeedsIdentityStitch(t *testing.T) { require.NoError(t, err) assert.False(t, ciService.NeedsIdentityStitch()) }) + + t.Run("false in first-run non-TTY runtime", func(t *testing.T) { + ephemeralFsys := afero.NewMemMapFs() + ephemeralService, err := NewService(ephemeralFsys, Options{ + Analytics: &fakeAnalytics{enabled: true}, + Now: func() time.Time { return now }, + }) + require.NoError(t, err) + assert.False(t, ephemeralService.NeedsIdentityStitch()) + }) + + t.Run("true in persisted non-TTY runtime", func(t *testing.T) { + persistedFsys := afero.NewMemMapFs() + require.NoError(t, SaveState(State{ + Enabled: true, + DeviceID: uuid.NewString(), + SessionID: uuid.NewString(), + SessionLastActive: now, + SchemaVersion: SchemaVersion, + }, persistedFsys)) + + persistedService, err := NewService(persistedFsys, Options{ + Analytics: &fakeAnalytics{enabled: true}, + Now: func() time.Time { return now }, + }) + require.NoError(t, err) + assert.True(t, persistedService.NeedsIdentityStitch()) + }) } func TestServiceCaptureHonorsConsentAndEnvOptOut(t *testing.T) { diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts b/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts index 3346655459..ff16c8afcf 100644 --- a/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts @@ -52,6 +52,14 @@ function numberField(value: unknown, key: string): number | undefined { return typeof field === "number" && Number.isFinite(field) ? field : undefined; } +function isEphemeralIdentityRuntime(runtime: { + readonly isCi: boolean; + readonly isFirstRun: boolean; + readonly isTty: boolean; +}) { + return runtime.isCi || (runtime.isFirstRun && !runtime.isTty); +} + const makeLegacyPlatformApiServices = Effect.gen(function* () { const cliConfig = yield* LegacyCliConfig; const credentials = yield* LegacyCredentials; @@ -63,7 +71,7 @@ const makeLegacyPlatformApiServices = Effect.gen(function* () { const needsIdentityStitch = runtime.consent === "granted" && - !runtime.isCi && + !isEphemeralIdentityRuntime(runtime) && (runtime.distinctId === undefined || runtime.distinctId.length === 0); const stitchIdentity = (gotrueId: string) => diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts index e0a672d49c..1047501322 100644 --- a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts @@ -3,7 +3,7 @@ import { Effect, Exit, FileSystem, Layer, Option, Path, Redacted } from "effect" import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -43,6 +43,8 @@ function mockTelemetryRuntime( configDir?: string; deviceId?: string; distinctId?: string; + isFirstRun?: boolean; + isTty?: boolean; isCi?: boolean; } = {}, ) { @@ -56,8 +58,8 @@ function mockTelemetryRuntime( deviceId: opts.deviceId ?? "device-123", sessionId: "session-123", ...(opts.distinctId === undefined ? {} : { distinctId: opts.distinctId }), - isFirstRun: false, - isTty: false, + isFirstRun: opts.isFirstRun ?? false, + isTty: opts.isTty ?? false, isCi: opts.isCi ?? false, os: "darwin", arch: "arm64", @@ -93,18 +95,18 @@ function nodeFileSystemLayer() { return Layer.succeed(FileSystem.FileSystem, { [FileSystem.FileSystem.key]: FileSystem.FileSystem.key, exists: (filePath: string) => - Effect.promise(() => + Effect.tryPromise(() => access(filePath) .then(() => true) .catch(() => false), ), makeDirectory: (dirPath: string, opts?: { recursive?: boolean; mode?: number }) => - Effect.promise(() => + Effect.tryPromise(() => mkdir(dirPath, { recursive: opts?.recursive, mode: opts?.mode }).then(() => undefined), ), - readFileString: (filePath: string) => Effect.promise(() => readFile(filePath, "utf8")), + readFileString: (filePath: string) => Effect.tryPromise(() => readFile(filePath, "utf8")), writeFileString: (filePath: string, content: string, opts?: { mode?: number }) => - Effect.promise(() => writeFile(filePath, content, { mode: opts?.mode })), + Effect.tryPromise(() => writeFile(filePath, content, { mode: opts?.mode })), } as unknown as FileSystem.FileSystem); } @@ -144,6 +146,8 @@ function withBaseDeps( analytics?: ReturnType; configDir?: string; distinctId?: string; + isFirstRun?: boolean; + isTty?: boolean; isCi?: boolean; } = {}, ) { @@ -155,6 +159,8 @@ function withBaseDeps( mockTelemetryRuntime({ configDir: opts.configDir, distinctId: opts.distinctId, + isFirstRun: opts.isFirstRun, + isTty: opts.isTty, isCi: opts.isCi, }), ), @@ -326,6 +332,56 @@ describe("legacyPlatformApiLayer", () => { }).pipe(Effect.provide(layer)); }); + it.effect("does not stitch identity in a first-run non-TTY runtime", () => { + const configDir = mkdtempSync(path.join(tmpdir(), "supabase-legacy-platform-api-")); + const analytics = mockAnalytics(); + const http = captureRequests({ "X-Gotrue-Id": "user-123" }); + const layer = legacyPlatformApiLayer.pipe( + Layer.provide(mockCliConfig({ accessToken: VALID_TOKEN })), + Layer.provide(mockCredentials(Option.none())), + Layer.provide(http.layer), + withBaseDeps({ analytics, configDir, isFirstRun: true, isTty: false }), + ); + + return Effect.gen(function* () { + try { + const api = yield* LegacyPlatformApi; + yield* api.v1.listAllProjects(); + + expect(analytics.aliases).toEqual([]); + expect(analytics.identifies).toEqual([]); + expect(existsSync(path.join(configDir, "telemetry.json"))).toBe(false); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } + }).pipe(Effect.provide(layer)); + }); + + it.effect("stitches identity in a first-run TTY runtime", () => { + const configDir = mkdtempSync(path.join(tmpdir(), "supabase-legacy-platform-api-")); + const analytics = mockAnalytics(); + const http = captureRequests({ "X-Gotrue-Id": "user-123" }); + const layer = legacyPlatformApiLayer.pipe( + Layer.provide(mockCliConfig({ accessToken: VALID_TOKEN })), + Layer.provide(mockCredentials(Option.none())), + Layer.provide(http.layer), + withBaseDeps({ analytics, configDir, isFirstRun: true, isTty: true }), + ); + + return Effect.gen(function* () { + try { + const api = yield* LegacyPlatformApi; + yield* api.v1.listAllProjects(); + + expect(analytics.aliases).toEqual([{ distinctId: "user-123", alias: "device-123" }]); + expect(analytics.identifies).toEqual([{ distinctId: "user-123", properties: {} }]); + expect(readTelemetryConfig(configDir).distinct_id).toBe("user-123"); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } + }).pipe(Effect.provide(layer)); + }); + it.effect("does not stitch identity when a distinct_id is already known", () => { const configDir = tempTelemetryConfig({ distinctId: "existing-user" }); const analytics = mockAnalytics();