Skip to content
Open
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
67 changes: 63 additions & 4 deletions apps/cli/src/legacy/auth/legacy-http-debug.layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,68 @@ import { legacyDohFetchLayer } from "../shared/legacy-http-dns.ts";
import { LegacyDebugLogger } from "../shared/legacy-debug-logger.service.ts";

/**
* Wraps `FetchHttpClient.layer` so every HTTP request can go through the
* legacy Go-parity debug side channel. The logger itself owns the `--debug`
* guard and byte-for-byte line formatting.
* Query parameters that mean the URL *is* a credential.
*
* A presigned object-store URL authorizes whoever holds it — for the Workers
* build-context upload, to overwrite the archive a deploy is about to build
* from. Logging one verbatim under `--debug` puts that in terminal scrollback
* and in any CI log or bug report the output is pasted into.
*/
const PRESIGNED_QUERY_KEYS = [
// AWS SigV4 and SigV2
"x-amz-signature",
"x-amz-credential",
"x-amz-security-token",
"awsaccesskeyid",
// Google Cloud Storage V4
"x-goog-signature",
"x-goog-credential",
// Azure SAS, and the generic spellings everything else uses
"sig",
"se",
"signature",
"token",
];

/**
* The URL as it should appear in a debug log: unchanged, unless its query string
* carries a signature, in which case the query is replaced wholesale.
*
* Redacting the whole query rather than the matched parameters keeps the
* decision simple and cannot leak a sibling parameter that turns out to matter.
* The path survives, which is what makes the line useful for debugging in the
* first place.
*
* A denylist of known signature parameters, so it is by nature incomplete: a
* provider spelling its signature something new would log verbatim until the
* list learns about it. The alternative — redacting every query string — would
* cost the debug log its usefulness on the Management API calls that are the
* whole reason `--debug` exists. Add spellings here as they turn up.
*/
export function legacyRedactHttpUrl(url: string): string {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
// Not a URL we can reason about; log it as-is rather than swallow it.
return url;
}
if (parsed.search === "") {
return url;
}
const presigned = [...parsed.searchParams.keys()].some((key) =>
PRESIGNED_QUERY_KEYS.includes(key.toLowerCase()),
);
if (!presigned) {
return url;
}
return `${parsed.origin}${parsed.pathname}?<redacted>`;
}

/**
* Wraps `FetchHttpClient.layer` so every HTTP request goes through the legacy
* debug side channel. The logger itself owns the `--debug` guard and the
* line formatting.
*
* `legacyDohFetchLayer` overrides `FetchHttpClient.Fetch` with a
* DNS-over-HTTPS-aware fetch when `--dns-resolver https` is set.
Expand All @@ -19,7 +78,7 @@ export const legacyHttpClientLayer = Layer.effect(
const logger = yield* LegacyDebugLogger;
const base = yield* HttpClient.HttpClient;
return HttpClient.mapRequestEffect(base, (req) =>
logger.http(req.method, req.url).pipe(Effect.as(req)),
logger.http(req.method, legacyRedactHttpUrl(req.url)).pipe(Effect.as(req)),
);
}),
).pipe(Layer.provide(FetchHttpClient.layer), Layer.provide(legacyDohFetchLayer));
56 changes: 56 additions & 0 deletions apps/cli/src/legacy/auth/legacy-http-debug.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, test } from "vitest";
import { legacyRedactHttpUrl } from "./legacy-http-debug.layer.ts";

/**
* `--debug` logs every request URL to stderr. For a presigned object-store URL
* the query string *is* the credential — for the Workers build-context upload,
* one that authorizes overwriting the archive a deploy is about to build from —
* so it must not survive into scrollback or a CI log.
*/
describe("legacyRedactHttpUrl", () => {
test.each([
[
"an AWS presigned upload",
"https://store.example/bucket/ctx.tar.gz?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=deadbeef",
"https://store.example/bucket/ctx.tar.gz?<redacted>",
],
[
"a GCS presigned upload",
"https://store.example/bucket/ctx.tar.gz?X-Goog-Signature=deadbeef",
"https://store.example/bucket/ctx.tar.gz?<redacted>",
],
[
"a lowercase signature parameter",
"https://store.example/o/ctx?signature=deadbeef&expires=123",
"https://store.example/o/ctx?<redacted>",
],
[
"a bare token parameter",
"https://store.example/o/ctx?token=deadbeef",
"https://store.example/o/ctx?<redacted>",
],
])("redacts the query string of %s", (_label, url, expected) => {
expect(legacyRedactHttpUrl(url)).toBe(expected);
expect(legacyRedactHttpUrl(url)).not.toContain("deadbeef");
});

// The debug log is only useful if ordinary requests still read normally, so
// redaction has to be the exception rather than the rule.
test.each([
["a Management API route", "https://api.supabase.com/v2/projects/abc/workers/api"],
["an ordinary query string", "https://api.supabase.com/v1/projects?limit=10"],
["a URL with no query at all", "https://api.supabase.com/v1/projects"],
])("leaves %s untouched", (_label, url) => {
expect(legacyRedactHttpUrl(url)).toBe(url);
});

test("passes through something that is not a parseable URL", () => {
expect(legacyRedactHttpUrl("not a url at all")).toBe("not a url at all");
});

test("keeps the path, which is what makes the log line worth having", () => {
expect(legacyRedactHttpUrl("https://store.example/bucket/deep/ctx.tar.gz?sig=x")).toContain(
"/bucket/deep/ctx.tar.gz",
);
});
});
74 changes: 74 additions & 0 deletions apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# `supabase workers push [name...] (alias: deploy)`

> **No live test yet.** `workers` runs against the v2 Management API, which the
> supabase/cli-e2e-ci supabox stack is not expected to serve, so a `*.live.test.ts`
> here would be permanently skipped or permanently red. Revisit when the v2
> Workers routes are available on that stack.

## Files Read

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document project resolution and credential side effects

After the prior cache and telemetry additions, the compatibility document still omits side effects performed by the resolver and auth layers: legacy-project-ref.layer.ts:87-95 consumes SUPABASE_PROJECT_ID, reads <workdir>/supabase/.temp/project-ref, and may call GET /v1/projects for interactive selection, while legacy-credentials.layer.ts:403-443 reads the profile and legacy keyring entries or <SUPABASE_HOME>/access-token. These happen before the Workers requests and leave the Files Read, API Routes, and Environment Variables sections incomplete.

AGENTS.md reference: apps/cli/AGENTS.md:L359-L366

Useful? React with 👍 / 👎.


| Path | Format | When |
| ---------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- |
| `<workdir>/supabase/config.toml` | TOML | always, for each worker's runtime, size, source |
| `<worker source>/**` | any | always — packaged into the build context |
| `<SUPABASE_HOME or ~/.supabase>/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` |
| `<SUPABASE_PROFILE>` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command |

## Files Written

| Path | Format | When |
| ----------------------------------------------- | ------ | --------------------------------------------------------------- |
| `<SUPABASE_HOME or ~/.supabase>/telemetry.json` | JSON | always — flushed on success and on failure |
| `<workdir>/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, when the cache does not hold it |

## API Routes

| Method | Path | Auth | Request body | Response (used fields) |
| ------ | -------------------------------------------- | ------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------ |
| `POST` | `/v2/projects/{ref}/workers/{name}/uploads` | Bearer token | none | `data.id`, `data.attributes.url/method` |
| `PUT` | presigned upload URL (control-plane storage) | URL signature — **no** Supabase credentials | `.tar.gz` build context | status only |
| `POST` | `/v2/projects/{ref}/workers/{name}/deploy` | Bearer token | `{data:{type,attributes:{spec,context_upload_id}}}` | `data.attributes.build_state` |
| `GET` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none | `build_state`, `state_reason`, `image_version`, `spec` |
| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked-project cache miss only — name, org, region |

`GET` is polled until `build_state` leaves `building`.

## Exit Codes

| Code | Condition |
| ---- | ---------------------------------------------------- |
| `0` | success |
| `1` | no workers named and none found in the project |
| `1` | a worker's source directory is missing or empty |
| `1` | build context upload failed |
| `1` | the build reached `failed`, or never left `building` |
| `1` | API error, or project not enrolled in the alpha |

## Environment Variables

| Variable | Purpose | Required? |
| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- |
| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) |
| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) |
| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) |
| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) |

## Telemetry Events Fired

| Event | When | Notable properties / groups |
| ---------------------- | ------------------------------------------ | ----------------------------------- |
| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` |

No custom events — only the `cli_command_executed` that the instrumentation
wrapper emits for every command.

## Output Formats

`-o env` is refused **before** the first deploy rather than at emit time: the
payload always carries a `workers` array, which a flat `KEY=value` list cannot
express, and discovering that at the end would fail the command with the remote
project already changed.

The presigned `PUT` above is the one request whose URL is itself a credential.
`--debug` logs every request URL, so `legacyHttpClientLayer` redacts query
strings that carry a signature.
62 changes: 62 additions & 0 deletions apps/cli/src/legacy/commands/workers/push/push.command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Argument, Command, Flag } from "effect/unstable/cli";
import type * as CliCommand from "effect/unstable/cli/Command";
import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts";
import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts";
import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts";
import { legacyWorkersPush } from "./push.handler.ts";

const config = {
names: Argument.string("name").pipe(
Argument.withDescription("Workers to deploy. Deploys every worker in the project if omitted."),
Argument.variadic(),
),
instances: Flag.integer("instances").pipe(
// Bounded at the parser, the same way `[workers.<name>] instances` is bounded
// in the config schema. Left unchecked it reached the deploy endpoint — after
// the build context had been packaged and uploaded — as a scaling request the
// platform cannot honour.
Flag.filter(
(instances) => instances >= 0,
(instances) => `--instances ${instances} is negative; pass zero or more.`,
),
Flag.withDescription(
"Number of instances to run, overriding `instances` in supabase/config.toml for this deploy. Falls back to the recorded value, then 1.",
),
Flag.optional,
Comment thread
johnstonmatt marked this conversation as resolved.
),
projectRef: Flag.string("project-ref").pipe(
Flag.withDescription("Project ref of the Supabase project."),
Flag.optional,
),
} as const;

export type LegacyWorkersPushFlags = CliCommand.Command.Config.Infer<typeof config>;

export const legacyWorkersPushCommand = Command.make("push", config).pipe(
Comment thread
johnstonmatt marked this conversation as resolved.
Command.withAlias("deploy"),
Command.withDescription(
"Build and deploy workers into the linked Supabase project. Reads each worker's runtime, size and source directory from supabase/config.toml.",
),
Command.withShortDescription("Build and deploy workers"),
Command.withExamples([
{
command: "supabase workers push",
description: "Deploy every worker in the project",
},
{
command: "supabase workers push api",
description: "Deploy a single worker",
},
{
command: "supabase workers push api web",
description: "Deploy several workers by name",
},
]),
Command.withHandler((flags) =>
legacyWorkersPush(flags).pipe(
withLegacyCommandInstrumentation({ flags }),
withJsonErrorHandling,
),
),
Command.provide(legacyManagementApiRuntimeLayer(["workers", "push"])),
);
Loading
Loading