diff --git a/apps/cli/docs/go-cli-divergences.md b/apps/cli/docs/go-cli-divergences.md index 9575203cc6..535ad151d4 100644 --- a/apps/cli/docs/go-cli-divergences.md +++ b/apps/cli/docs/go-cli-divergences.md @@ -14,7 +14,7 @@ These commands exist in the TS CLI today but have no direct top-level equivalent | ----------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `dev` | `planned` | Reserved for a TS-native long-running local development workflow command that watches files and orchestrates subcommands. Track this as TS-only unless a direct Go equivalent emerges. | | `feedback add` | [`../src/legacy/commands/feedback/add/add.command.ts`](../src/legacy/commands/feedback/add/add.command.ts) | Quick feedback submission, legacy shell only (CLI-1946). Submits through the `submit_interfaces_feedback` RPC via supabase-js with a committed publishable key and prints the server-issued delete token once; profile-driven staging/production environments (production currently reuses staging). | -| `feedback delete` | [`../src/legacy/commands/feedback/delete/delete.command.ts`](../src/legacy/commands/feedback/delete/delete.command.ts) | Deletes previously submitted feedback using the token printed by `feedback add` (CLI-2188). Previews the feedback text, then hard-deletes via the token-gated RLS policy (`x-feedback-token` + optional `x-feedback-project-ref` headers). | +| `feedback delete` | [`../src/legacy/commands/feedback/delete/delete.command.ts`](../src/legacy/commands/feedback/delete/delete.command.ts) | Deletes previously submitted feedback using the token printed by `feedback add` (CLI-2188). Previews the feedback text, then hard-deletes via the token-gated RLS policy (`x-feedback-token` + optional `x-feedback-project-ref` / `x-feedback-user-id` headers). | | `logs` | [`../src/next/commands/logs/logs.command.ts`](../src/next/commands/logs/logs.command.ts) | Streams local stack logs. No top-level `logs` command exists in the old Go CLI reference. | | `api` | [`../src/next/commands/platform/api.command.ts`](../src/next/commands/platform/api.command.ts) | Low-level Management API client. It supersedes the old generated tree with explicit discovery via `supabase api routes` and execution via `supabase api request [--method ]`. | | `stack` | [`../src/next/cli/root.ts`](../src/next/cli/root.ts) | TS-only local runtime namespace exposing `stack start`, `stack stop`, `stack status`, `stack list`, and `stack update`. Top-level `start`, `stop`, and `status` remain aliases. | diff --git a/apps/cli/src/legacy/commands/feedback/add/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/feedback/add/SIDE_EFFECTS.md index 5c5c12b674..98f79494ed 100644 --- a/apps/cli/src/legacy/commands/feedback/add/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/feedback/add/SIDE_EFFECTS.md @@ -2,11 +2,12 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase/profile` | plain text (profile name) | when `--profile` and `SUPABASE_PROFILE` are unset (profile resolution via `legacyCliConfigLayer`) | -| `$SUPABASE_PROFILE` | YAML (`api_url:` / `gotrue_url:` …) | when `SUPABASE_PROFILE` is set to a file path instead of a built-in profile name | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `SUPABASE_PROJECT_ID` is unset — supplies the submission's `project_ref`. Absent, blank, or unreadable → `null` (never fails the submission) | +| Path | Format | When | +| ----------------------------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/profile` | plain text (profile name) | when `--profile` and `SUPABASE_PROFILE` are unset (profile resolution via `legacyCliConfigLayer`) | +| `$SUPABASE_PROFILE` | YAML (`api_url:` / `gotrue_url:` …) | when `SUPABASE_PROFILE` is set to a file path instead of a built-in profile name | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `SUPABASE_PROJECT_ID` is unset — supplies the submission's `project_ref`. Absent, blank, or unreadable → `null` (never fails the submission) | +| `/telemetry.json` | JSON (telemetry state) | read at startup by the shared telemetry runtime — its `distinct_id` (gotrue user id stamped at login) supplies the submission's `user_id` when telemetry consent is granted. Absent, logged-out, or consent-denied → omitted | ## Files Written @@ -16,9 +17,9 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ----------------------------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------- | -| `POST` | `/rest/v1/rpc/submit_interfaces_feedback` | `apikey` = committed publishable key | `{ feedback, user_agent, project_ref (omitted when unlinked), metadata: { cli_version, source: "cli", os, arch, is_agent, agent_name? } }` | uuid delete token (issued exactly once, shown to the user) | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------------------------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| `POST` | `/rest/v1/rpc/submit_interfaces_feedback` | `apikey` = committed publishable key | `{ feedback, user_agent, project_ref (omitted when unlinked), user_id (omitted when logged out or consent-denied), metadata: { cli_version, source: "cli", os, arch, is_agent, agent_name? } }` | uuid delete token (issued exactly once, shown to the user) | `` follows the resolved profile (`feedback.layers.ts`): `supabase-staging` / `supabase-local` → the staging feedback project; @@ -100,6 +101,12 @@ terminal, a "What's on your mind?" text prompt collects it first. - Submission context: CLI version, user agent (`SupabaseCLI/` from `LegacyCliConfig`), OS/arch, agent detection, and — when the workdir has a linked project — its project ref. The resolved access token is never sent. +- The persisted gotrue user id from `/telemetry.json` (`distinct_id`, + stamped at login) is sent as `user_id` when present **and** telemetry consent + is granted; opted-out or logged-out runs omit it. The lookup is a synchronous + in-memory read — no auth or network dependency is added. A row submitted with + a `user_id` additionally requires the matching `x-feedback-user-id` header to + preview/delete it later (`feedback delete` sends it automatically). - Project-ref resolution order: `SUPABASE_PROJECT_ID` → `/supabase/.temp/project-ref` (written by `supabase link`) → `null`. This mirrors the soft-load half of `LegacyProjectRefResolver.resolveOptional` diff --git a/apps/cli/src/legacy/commands/feedback/add/add.handler.ts b/apps/cli/src/legacy/commands/feedback/add/add.handler.ts index 882aea8036..c68518df6b 100644 --- a/apps/cli/src/legacy/commands/feedback/add/add.handler.ts +++ b/apps/cli/src/legacy/commands/feedback/add/add.handler.ts @@ -61,6 +61,13 @@ export const legacyFeedbackAdd = Effect.fn("legacy.feedback.add")(function* ( .submit({ message, projectRef: Option.getOrUndefined(projectRef), + // Gotrue user UUID stamped into ~/.supabase/telemetry.json at login (ADR + // 0013). A synchronous in-memory read — best-effort attribution with no + // auth/API/network dependency, so feedback keeps working logged-out + // (undefined → user_id omitted). Gated on telemetry consent: opted-out + // users submit anonymously. + userId: + telemetryRuntime.consent === "granted" ? telemetryRuntime.identity.current() : undefined, context: { cliVersion: telemetryRuntime.cliVersion, userAgent: cliConfig.userAgent, diff --git a/apps/cli/src/legacy/commands/feedback/add/add.integration.test.ts b/apps/cli/src/legacy/commands/feedback/add/add.integration.test.ts index f6ccdecb97..2bb99e8c79 100644 --- a/apps/cli/src/legacy/commands/feedback/add/add.integration.test.ts +++ b/apps/cli/src/legacy/commands/feedback/add/add.integration.test.ts @@ -83,6 +83,9 @@ function setupLegacyFeedback( submitFailWith?: string; /** Simulates `SUPABASE_PROJECT_ID`, the only source `LegacyCliConfig` reads. */ projectIdEnv?: string; + /** Simulates the gotrue user id persisted to telemetry.json at login. */ + distinctId?: string; + consent?: "granted" | "denied"; } = {}, ) { const out = mockOutput(opts.output); @@ -94,7 +97,11 @@ function setupLegacyFeedback( submitter.layer, mockStdin(opts.stdinIsTTY ?? true, opts.pipedInput), mockRuntimeInfo({ platform: "darwin", arch: "arm64" }), - mockTelemetryRuntime({ cliVersion: "9.9.9" }), + mockTelemetryRuntime({ + cliVersion: "9.9.9", + distinctId: opts.distinctId, + consent: opts.consent, + }), mockLegacyCliConfig({ workdir: tempRoot.current, userAgent: "SupabaseCLI/9.9.9", @@ -197,6 +204,40 @@ describe("legacy feedback add", () => { }).pipe(Effect.provide(layer)); }); + it.live("attaches the persisted gotrue user id when logged in", () => { + const { layer, submitter } = setupLegacyFeedback({ + distinctId: "11111111-2222-3333-4444-555555555555", + }); + return Effect.gen(function* () { + yield* legacyFeedbackAdd({ message: ["logged in feedback"] }); + + expect(submitter.submissions[0]?.userId).toBe("11111111-2222-3333-4444-555555555555"); + }).pipe(Effect.provide(layer)); + }); + + it.live("sends no user id when not logged in", () => { + const { layer, submitter } = setupLegacyFeedback(); + return Effect.gen(function* () { + yield* legacyFeedbackAdd({ message: ["logged out feedback"] }); + + expect(submitter.submissions[0]?.userId).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("sends no user id when telemetry consent is denied", () => { + // Submit-side attribution is consent-gated: opted-out users submit + // anonymously even when a persisted gotrue id exists. + const { layer, submitter } = setupLegacyFeedback({ + distinctId: "11111111-2222-3333-4444-555555555555", + consent: "denied", + }); + return Effect.gen(function* () { + yield* legacyFeedbackAdd({ message: ["opted out feedback"] }); + + expect(submitter.submissions[0]?.userId).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + it.live("sends no project ref when the workdir is not linked", () => { const { layer, submitter } = setupLegacyFeedback(); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/feedback/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/feedback/delete/SIDE_EFFECTS.md index ec77a3dece..49a102c8a8 100644 --- a/apps/cli/src/legacy/commands/feedback/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/feedback/delete/SIDE_EFFECTS.md @@ -2,11 +2,12 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase/profile` | plain text (profile name) | when `--profile` and `SUPABASE_PROFILE` are unset (profile resolution via `legacyCliConfigLayer`) | -| `$SUPABASE_PROFILE` | YAML (`api_url:` / `gotrue_url:` …) | when `SUPABASE_PROFILE` is set to a file path instead of a built-in profile name | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` and `SUPABASE_PROJECT_ID` are unset — supplies the `x-feedback-project-ref` context. Absent, blank, or unreadable → header omitted (never fails the command) | +| Path | Format | When | +| ----------------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/profile` | plain text (profile name) | when `--profile` and `SUPABASE_PROFILE` are unset (profile resolution via `legacyCliConfigLayer`) | +| `$SUPABASE_PROFILE` | YAML (`api_url:` / `gotrue_url:` …) | when `SUPABASE_PROFILE` is set to a file path instead of a built-in profile name | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` and `SUPABASE_PROJECT_ID` are unset — supplies the `x-feedback-project-ref` context. Absent, blank, or unreadable → header omitted (never fails the command) | +| `/telemetry.json` | JSON (telemetry state) | read at startup by the shared telemetry runtime — its `distinct_id` (gotrue user id stamped at login) supplies the `x-feedback-user-id` context. Absent or logged-out → header omitted | ## Files Written @@ -16,19 +17,22 @@ ## API Routes -| Method | Path | Auth / headers | Request body | Response (used fields) | -| -------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------ | ------------------------------------------------------------ | -| `GET` | `/rest/v1/interfaces_feedback?select=feedback&delete_token=eq.` | `apikey` = committed publishable key; `x-feedback-token: `; `x-feedback-project-ref: ` when resolved | — | `[{ feedback }]` or `[]` — previews the text before deletion | -| `DELETE` | `/rest/v1/interfaces_feedback?delete_token=eq.` | same headers, plus `Prefer: count=exact` | — | `Content-Range: */1` (deleted) vs `*/0` (no row matched) | +| Method | Path | Auth / headers | Request body | Response (used fields) | +| -------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ------------------------------------------------------------ | +| `GET` | `/rest/v1/interfaces_feedback?select=feedback&delete_token=eq.` | `apikey` = committed publishable key; `x-feedback-token: `; `x-feedback-project-ref: ` when resolved; `x-feedback-user-id: ` when logged in | — | `[{ feedback }]` or `[]` — previews the text before deletion | +| `DELETE` | `/rest/v1/interfaces_feedback?delete_token=eq.` | same headers, plus `Prefer: count=exact` | — | `Content-Range: */1` (deleted) vs `*/0` (no row matched) | `` follows the resolved profile exactly as `feedback add` does (`feedback.layers.ts` / `src/shared/feedback/feedback-client.layer.ts`). The `delete_token=eq.` URL filter only satisfies PostgREST's filterless-delete rejection — the `x-feedback-token` header is the security boundary enforced by -RLS. Rows submitted with a `project_ref` additionally require the matching -`x-feedback-project-ref` header on both routes; sending it against a -context-free row is ignored server-side, so the CLI always sends whatever ref -resolves. Each request times out after 10 s. +RLS. Rows submitted with a `project_ref` and/or `user_id` additionally require +the matching `x-feedback-project-ref` / `x-feedback-user-id` header on both +routes; extra context against a context-free row is ignored server-side, so +the CLI always sends whatever resolves. The user-id header is NOT gated on +telemetry consent (unlike `feedback add`'s submit-side attribution) — it is +functional auth context, and gating it would strand rows submitted before a +consent opt-out. Each request times out after 10 s. ## Environment Variables @@ -48,7 +52,7 @@ Global telemetry consent env applies as with every command. | ---- | --------------------------------------------------------------------------------------------- | | `0` | feedback deleted | | `1` | token argument is not a UUID | -| `1` | no feedback matched (wrong token, already deleted, or project-ref context mismatch) | +| `1` | no feedback matched (wrong token, already deleted, or project-ref/user-id context mismatch) | | `1` | confirmation declined, or prompt unavailable (non-interactive / machine mode without `--yes`) | | `1` | backend failure (PostgREST error, network failure, or 10 s timeout) on preview or delete | @@ -111,3 +115,11 @@ Also requires `--yes`. with that same ref presented — rerun from the linked directory or pass `--project-ref`. This mirrors `feedback add`'s resolution and works logged-out (no auth dependency). +- A row submitted while logged in (with telemetry consent) carries a `user_id` + and can only be previewed/deleted while logged in as that same user — the + persisted `distinct_id` is presented automatically as `x-feedback-user-id`. + The lookup is a synchronous in-memory read; logged-out runs simply omit the + header, which still matches all anonymous rows. `supabase logout` wipes the + persisted identity, so a delete token for an attributed row reports "not + found" until the user logs back in as the same account (login re-stamps the + same gotrue UUID, restoring delete access). diff --git a/apps/cli/src/legacy/commands/feedback/delete/delete.errors.ts b/apps/cli/src/legacy/commands/feedback/delete/delete.errors.ts index 1311da8595..a0cfd05a7a 100644 --- a/apps/cli/src/legacy/commands/feedback/delete/delete.errors.ts +++ b/apps/cli/src/legacy/commands/feedback/delete/delete.errors.ts @@ -11,8 +11,9 @@ export const LEGACY_FEEDBACK_INVALID_TOKEN_MESSAGE = export const LEGACY_FEEDBACK_NOT_FOUND_MESSAGE = "No feedback found for this token. It may already be deleted, the token may be wrong, " + - "or the feedback was submitted from a linked project directory — rerun this command " + - "from that directory or pass --project-ref ."; + "or the feedback was submitted with project/user context that isn't present — rerun " + + "from the linked project directory (or pass --project-ref ) and log in as the " + + "account that submitted it."; export const LEGACY_FEEDBACK_DELETE_CANCELLED_MESSAGE = "Deletion cancelled."; @@ -27,9 +28,9 @@ export class LegacyFeedbackInvalidTokenError extends Data.TaggedError( /** * The token matched no row: wrong token, already deleted, or the row was - * submitted with a project ref that wasn't presented (`x-feedback-project-ref` - * context gate). The backend cannot distinguish these, so the message carries - * all three remediations. + * submitted with a project ref and/or user id that wasn't presented (the + * `x-feedback-project-ref` / `x-feedback-user-id` context gates). The backend + * cannot distinguish these, so the message carries all the remediations. */ export class LegacyFeedbackNotFoundError extends Data.TaggedError("LegacyFeedbackNotFoundError")<{ readonly message: string; diff --git a/apps/cli/src/legacy/commands/feedback/delete/delete.handler.ts b/apps/cli/src/legacy/commands/feedback/delete/delete.handler.ts index 20e867411b..88a07f7fc2 100644 --- a/apps/cli/src/legacy/commands/feedback/delete/delete.handler.ts +++ b/apps/cli/src/legacy/commands/feedback/delete/delete.handler.ts @@ -2,6 +2,7 @@ import { Effect, Option } from "effect"; import { FeedbackClient } from "../../../../shared/feedback/feedback-client.service.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; +import { TelemetryRuntime } from "../../../../shared/telemetry/runtime.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { legacyResolveFeedbackProjectRef } from "../feedback-project-ref.ts"; import type { LegacyFeedbackDeleteArgs } from "./delete.command.ts"; @@ -24,6 +25,7 @@ export const legacyFeedbackDelete = Effect.fn("legacy.feedback.delete")(function ) { const output = yield* Output; const cliConfig = yield* LegacyCliConfig; + const telemetryRuntime = yield* TelemetryRuntime; const client = yield* FeedbackClient; if (!LEGACY_UUID_PATTERN.test(args.token)) { @@ -43,9 +45,19 @@ export const legacyFeedbackDelete = Effect.fn("legacy.feedback.delete")(function Option.orElse(args.projectRef, () => cliConfig.projectId), ).pipe(Effect.map(Option.getOrUndefined)); + // User-id context gate, same shape as the project-ref one: rows submitted + // with a user_id only match when the same id arrives as a header. Unlike + // the submit-side attribution this is NOT consent-gated — it is functional + // auth context, and gating it would strand rows submitted before a consent + // opt-out. Logged out → undefined → header omitted. + const rowContext = { + projectRef, + userId: telemetryRuntime.identity.current(), + }; + const looking = yield* output.task("Looking up feedback..."); const preview = yield* client - .preview(token, projectRef) + .preview(token, rowContext) .pipe(Effect.tapError(() => looking.fail())); yield* looking.clear(); @@ -79,7 +91,7 @@ export const legacyFeedbackDelete = Effect.fn("legacy.feedback.delete")(function const deleting = yield* output.task("Deleting feedback..."); const { deleted } = yield* client - .delete(token, projectRef) + .delete(token, rowContext) .pipe(Effect.tapError(() => deleting.fail())); yield* deleting.clear(); diff --git a/apps/cli/src/legacy/commands/feedback/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/feedback/delete/delete.integration.test.ts index 3c8281cce7..1ab88a8e4e 100644 --- a/apps/cli/src/legacy/commands/feedback/delete/delete.integration.test.ts +++ b/apps/cli/src/legacy/commands/feedback/delete/delete.integration.test.ts @@ -14,6 +14,7 @@ import { mockContextualAnalytics, mockOutput, mockProcessControl, + mockTelemetryRuntime, } from "../../../../../tests/helpers/mocks.ts"; import { LEGACY_VALID_REF, @@ -59,17 +60,23 @@ interface MockClientOpts { deleteFailWith?: string; } +interface RecordedCall { + token: string; + projectRef: string | undefined; + userId: string | undefined; +} + function mockFeedbackClient(opts: MockClientOpts = {}) { - const previewCalls: Array<{ token: string; projectRef: string | undefined }> = []; - const deleteCalls: Array<{ token: string; projectRef: string | undefined }> = []; + const previewCalls: Array = []; + const deleteCalls: Array = []; return { layer: Layer.succeed( FeedbackClient, FeedbackClient.of({ submit: () => Effect.die("submit is not reachable from feedback delete"), - preview: (token, projectRef) => + preview: (token, context) => Effect.suspend(() => { - previewCalls.push({ token, projectRef }); + previewCalls.push({ token, projectRef: context?.projectRef, userId: context?.userId }); return opts.previewFailWith !== undefined ? Effect.fail( new FeedbackBackendError({ @@ -79,9 +86,9 @@ function mockFeedbackClient(opts: MockClientOpts = {}) { ) : Effect.succeed(Option.fromNullishOr(opts.previewText)); }), - delete: (token, projectRef) => + delete: (token, context) => Effect.suspend(() => { - deleteCalls.push({ token, projectRef }); + deleteCalls.push({ token, projectRef: context?.projectRef, userId: context?.userId }); return opts.deleteFailWith !== undefined ? Effect.fail( new FeedbackBackendError({ message: opts.deleteFailWith, operation: "delete" }), @@ -102,6 +109,9 @@ function setupLegacyFeedbackDelete( yes?: boolean; /** Simulates `SUPABASE_PROJECT_ID`, the only source `LegacyCliConfig` reads. */ projectIdEnv?: string; + /** Simulates the gotrue user id persisted to telemetry.json at login. */ + distinctId?: string; + consent?: "granted" | "denied"; } = {}, ) { const out = mockOutput(opts.output ?? { promptConfirmResponses: [true] }); @@ -109,6 +119,11 @@ function setupLegacyFeedbackDelete( const layer = Layer.mergeAll( out.layer, client.layer, + mockTelemetryRuntime({ + cliVersion: "9.9.9", + distinctId: opts.distinctId, + consent: opts.consent, + }), mockLegacyCliConfig({ workdir: tempRoot.current, userAgent: "SupabaseCLI/9.9.9", @@ -273,6 +288,50 @@ describe("legacy feedback delete", () => { }).pipe(Effect.provide(layer)); }); + it.live("presents the persisted gotrue user id with the preview and the delete", () => { + // Rows submitted while logged in carry a user_id, and the RLS only + // matches them when the same id arrives as the x-feedback-user-id header. + const { layer, client } = setupLegacyFeedbackDelete({ + yes: true, + distinctId: "11111111-2222-3333-4444-555555555555", + }); + return Effect.gen(function* () { + yield* legacyFeedbackDelete(deleteArgs()); + + expect(client.previewCalls).toEqual([ + { token: TOKEN, projectRef: undefined, userId: "11111111-2222-3333-4444-555555555555" }, + ]); + expect(client.deleteCalls).toEqual([ + { token: TOKEN, projectRef: undefined, userId: "11111111-2222-3333-4444-555555555555" }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("still presents the user id when telemetry consent is denied", () => { + // Unlike submit-side attribution, the header is functional auth context — + // gating it on consent would strand rows submitted before an opt-out. + const { layer, client } = setupLegacyFeedbackDelete({ + yes: true, + distinctId: "11111111-2222-3333-4444-555555555555", + consent: "denied", + }); + return Effect.gen(function* () { + yield* legacyFeedbackDelete(deleteArgs()); + + expect(client.deleteCalls[0]?.userId).toBe("11111111-2222-3333-4444-555555555555"); + }).pipe(Effect.provide(layer)); + }); + + it.live("sends no user id when not logged in", () => { + const { layer, client } = setupLegacyFeedbackDelete({ yes: true }); + return Effect.gen(function* () { + yield* legacyFeedbackDelete(deleteArgs()); + + expect(client.previewCalls[0]?.userId).toBeUndefined(); + expect(client.deleteCalls[0]?.userId).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + it.live("degrades to no project ref when the linked ref file cannot be read", () => { const { layer, client } = setupLegacyFeedbackDelete({ yes: true }); writeLinkedProjectRef(tempRoot.current, LEGACY_VALID_REF, { asDirectory: true }); diff --git a/apps/cli/src/shared/feedback/feedback-client.integration.test.ts b/apps/cli/src/shared/feedback/feedback-client.integration.test.ts index 925abf25d4..3544de4e17 100644 --- a/apps/cli/src/shared/feedback/feedback-client.integration.test.ts +++ b/apps/cli/src/shared/feedback/feedback-client.integration.test.ts @@ -13,10 +13,12 @@ const TEST_ENV = { const TOKEN = "123e4567-e89b-12d3-a456-426614174000"; const PROJECT_REF = "abcdefghijklmnopqrst"; +const USER_ID = "11111111-2222-3333-4444-555555555555"; const SUBMISSION: FeedbackSubmission = { message: "port conflicts when running two stacks", projectRef: PROJECT_REF, + userId: USER_ID, context: { cliVersion: "9.9.9", userAgent: "SupabaseCLI/9.9.9", @@ -76,6 +78,7 @@ describe("feedbackClientLayer", () => { feedback: "port conflicts when running two stacks", user_agent: "SupabaseCLI/9.9.9", project_ref: PROJECT_REF, + user_id: USER_ID, metadata: { cli_version: "9.9.9", source: "cli", @@ -171,7 +174,7 @@ describe("feedbackClientLayer", () => { const transport = recordingFetch(() => jsonResponse([{ feedback: "my papercut" }])); return Effect.gen(function* () { const client = yield* FeedbackClient; - const preview = yield* client.preview(TOKEN, PROJECT_REF); + const preview = yield* client.preview(TOKEN, { projectRef: PROJECT_REF, userId: USER_ID }); expect(preview).toEqual(Option.some("my papercut")); const { request } = transport.requests[0]!; @@ -183,10 +186,11 @@ describe("feedbackClientLayer", () => { expect(request.headers.get("apikey")).toBe(TEST_ENV.key); expect(request.headers.get("x-feedback-token")).toBe(TOKEN); expect(request.headers.get("x-feedback-project-ref")).toBe(PROJECT_REF); + expect(request.headers.get("x-feedback-user-id")).toBe(USER_ID); }).pipe(Effect.provide(layerWith(transport))); }); - it.live("sends no project-ref header when no ref is provided", () => { + it.live("sends no context headers when no ref or user id is provided", () => { const transport = recordingFetch(() => jsonResponse([{ feedback: "context-free" }])); return Effect.gen(function* () { const client = yield* FeedbackClient; @@ -195,6 +199,7 @@ describe("feedbackClientLayer", () => { const { request } = transport.requests[0]!; expect(request.headers.get("x-feedback-token")).toBe(TOKEN); expect(request.headers.has("x-feedback-project-ref")).toBe(false); + expect(request.headers.has("x-feedback-user-id")).toBe(false); }).pipe(Effect.provide(layerWith(transport))); }); @@ -202,7 +207,7 @@ describe("feedbackClientLayer", () => { const transport = recordingFetch(() => jsonResponse([])); return Effect.gen(function* () { const client = yield* FeedbackClient; - const preview = yield* client.preview(TOKEN, PROJECT_REF); + const preview = yield* client.preview(TOKEN, { projectRef: PROJECT_REF }); expect(Option.isNone(preview)).toBe(true); }).pipe(Effect.provide(layerWith(transport))); @@ -235,7 +240,7 @@ describe("feedbackClientLayer", () => { ); return Effect.gen(function* () { const client = yield* FeedbackClient; - const result = yield* client.delete(TOKEN, PROJECT_REF); + const result = yield* client.delete(TOKEN, { projectRef: PROJECT_REF, userId: USER_ID }); expect(result).toEqual({ deleted: true }); const { request } = transport.requests[0]!; @@ -246,12 +251,13 @@ describe("feedbackClientLayer", () => { expect(request.headers.get("prefer")).toContain("count=exact"); expect(request.headers.get("x-feedback-token")).toBe(TOKEN); expect(request.headers.get("x-feedback-project-ref")).toBe(PROJECT_REF); + expect(request.headers.get("x-feedback-user-id")).toBe(USER_ID); }).pipe(Effect.provide(layerWith(transport))); }); it.live("reports deleted: false when the delete matched zero rows", () => { - // Wrong/stale token or a project-ref context mismatch: RLS matches - // nothing and PostgREST reports an empty range. + // Wrong/stale token or a project-ref/user-id context mismatch: RLS + // matches nothing and PostgREST reports an empty range. const transport = recordingFetch( () => new Response(null, { status: 204, headers: { "content-range": "*/0" } }), ); @@ -261,6 +267,7 @@ describe("feedbackClientLayer", () => { expect(result).toEqual({ deleted: false }); expect(transport.requests[0]!.request.headers.has("x-feedback-project-ref")).toBe(false); + expect(transport.requests[0]!.request.headers.has("x-feedback-user-id")).toBe(false); }).pipe(Effect.provide(layerWith(transport))); }); diff --git a/apps/cli/src/shared/feedback/feedback-client.layer.ts b/apps/cli/src/shared/feedback/feedback-client.layer.ts index a346868c27..865f7d525a 100644 --- a/apps/cli/src/shared/feedback/feedback-client.layer.ts +++ b/apps/cli/src/shared/feedback/feedback-client.layer.ts @@ -50,14 +50,17 @@ export function legacyFeedbackEnvironment(profile: string): FeedbackEnvironment type RpcArgs = Database["public"]["Functions"]["submit_interfaces_feedback"]["Args"]; -// `user_id` is deliberately never sent: the CLI has no user-scoped identity in -// this flow, and omitting it keeps the row's delete authorization token-only. +// `user_id` is the gotrue user UUID the handler read from the persisted +// telemetry identity — best-effort attribution, omitted when logged out or +// when telemetry consent is denied. A row submitted with it additionally +// requires the matching `x-feedback-user-id` header on preview/delete (RLS). function toRpcArgs(submission: FeedbackSubmission): RpcArgs { const { context } = submission; return { feedback: submission.message, user_agent: context.userAgent, ...(submission.projectRef === undefined ? {} : { project_ref: submission.projectRef }), + ...(submission.userId === undefined ? {} : { user_id: submission.userId }), metadata: { cli_version: context.cliVersion, source: "cli", @@ -120,35 +123,43 @@ export function feedbackClientLayer(options: FeedbackClientOptions): Layer.Layer ), ), - preview: (token, projectRef) => + preview: (token, context) => run("preview", () => { - const request = client + let request = client .from("interfaces_feedback") .select("feedback") .eq("delete_token", token) .setHeader("x-feedback-token", token) .abortSignal(AbortSignal.timeout(REQUEST_TIMEOUT_MS)); - return projectRef === undefined - ? request - : request.setHeader("x-feedback-project-ref", projectRef); + if (context?.projectRef !== undefined) { + request = request.setHeader("x-feedback-project-ref", context.projectRef); + } + if (context?.userId !== undefined) { + request = request.setHeader("x-feedback-user-id", context.userId); + } + return request; }).pipe(Effect.map(({ data }) => Option.fromNullishOr(data?.[0]?.feedback))), - delete: (token, projectRef) => + delete: (token, context) => run("delete", () => { // The `delete_token=eq.` filter satisfies PostgREST's filterless-delete // rejection; the `x-feedback-token` header is the actual security // boundary (RLS matches zero rows without it). `count: "exact"` asks // for a Content-Range so the caller can tell a matched delete from a // zero-row one. - const request = client + let request = client .from("interfaces_feedback") .delete({ count: "exact" }) .eq("delete_token", token) .setHeader("x-feedback-token", token) .abortSignal(AbortSignal.timeout(REQUEST_TIMEOUT_MS)); - return projectRef === undefined - ? request - : request.setHeader("x-feedback-project-ref", projectRef); + if (context?.projectRef !== undefined) { + request = request.setHeader("x-feedback-project-ref", context.projectRef); + } + if (context?.userId !== undefined) { + request = request.setHeader("x-feedback-user-id", context.userId); + } + return request; }).pipe(Effect.map(({ count }) => ({ deleted: count === 1 }))), }); }); diff --git a/apps/cli/src/shared/feedback/feedback-client.service.ts b/apps/cli/src/shared/feedback/feedback-client.service.ts index 58c3f5d356..7d53f7fd1f 100644 --- a/apps/cli/src/shared/feedback/feedback-client.service.ts +++ b/apps/cli/src/shared/feedback/feedback-client.service.ts @@ -19,9 +19,25 @@ interface FeedbackContext { export interface FeedbackSubmission { readonly message: string; readonly projectRef?: string; + /** + * Gotrue user UUID (persisted telemetry distinct_id); absent when not + * logged in or when telemetry consent is denied. + */ + readonly userId?: string; readonly context: FeedbackContext; } +/** + * Row-context values presented as `x-feedback-*` headers on preview/delete. + * The RLS policies require each one when (and only when) the row was + * submitted with it — extra context against a context-free row is ignored, so + * sending whatever is available is always safe. + */ +interface FeedbackRowContext { + readonly projectRef?: string; + readonly userId?: string; +} + /** * Returned once per submission: the server-generated token that authorizes * deleting the row later. Never persisted by the CLI — shown to the user and @@ -49,17 +65,17 @@ interface FeedbackClientShape { ) => Effect.Effect; /** * The feedback text of the row the token unlocks, or `None` when no row - * matches (wrong token, already deleted, or a project-ref context mismatch — - * the backend cannot distinguish these). + * matches (wrong token, already deleted, or a project-ref/user-id context + * mismatch — the backend cannot distinguish these). */ readonly preview: ( token: string, - projectRef?: string, + context?: FeedbackRowContext, ) => Effect.Effect, FeedbackBackendError>; /** `deleted: false` means the delete matched zero rows (same causes as `preview` → `None`). */ readonly delete: ( token: string, - projectRef?: string, + context?: FeedbackRowContext, ) => Effect.Effect<{ readonly deleted: boolean }, FeedbackBackendError>; }