Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/cli-go/internal/telemetry/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,11 @@ 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.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 {
Expand Down
40 changes: 40 additions & 0 deletions apps/cli-go/internal/telemetry/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -233,6 +234,45 @@ 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())
})

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) {
Expand Down
129 changes: 123 additions & 6 deletions apps/cli/src/legacy/auth/legacy-platform-api.layer.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,129 @@
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";

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;
}

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;
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" &&
!isEphemeralIdentityRuntime(runtime) &&
(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.
Expand All @@ -26,11 +138,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);
});

Expand Down
Loading
Loading