diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index 6681a2c958..41dd8d7927 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -21,14 +21,36 @@ "!apps/cli/tests/helpers/postgres-config-live.ts", "!apps/cli/tests/helpers/secrets-live.ts", "!apps/cli/tests/helpers/storage-live.ts", + "!apps/cli/src/command-internal/api-*", + "!apps/cli/src/command-internal/branch-ref.*", "!apps/cli/src/command-internal/branch-target.*", "!apps/cli/src/command-internal/connect-errors.*", "!apps/cli/src/command-internal/db-*.ts", + "!apps/cli/src/command-internal/debug-logger.*", + "!apps/cli/src/command-internal/ensure-login.*", "!apps/cli/src/command-internal/experimental-feature.ts", + "!apps/cli/src/command-internal/get-api-keys.*", + "!apps/cli/src/command-internal/get-tenant-api-keys.*", + "!apps/cli/src/command-internal/hostname.*", + "!apps/cli/src/command-internal/http-*", + "!apps/cli/src/command-internal/identity-stitch.*", + "!apps/cli/src/command-internal/kong-*", + "!apps/cli/src/command-internal/link-services-core.*", + "!apps/cli/src/command-internal/linked-state.*", + "!apps/cli/src/command-internal/login-*", + "!apps/cli/src/command-internal/management-api-runtime.*", + "!apps/cli/src/command-internal/parent-project-ref.*", "!apps/cli/src/command-internal/pgpass.*", "!apps/cli/src/command-internal/pgservicefile.*", "!apps/cli/src/command-internal/pooler-fallback.*", "!apps/cli/src/command-internal/postgres-client.run.ts", + "!apps/cli/src/command-internal/profile-load.*", + "!apps/cli/src/command-internal/profile.*", + "!apps/cli/src/command-internal/project-create-core.*", + "!apps/cli/src/command-internal/project-target.*", + "!apps/cli/src/command-internal/raw-http.*", + "!apps/cli/src/command-internal/ref-patterns.*", + "!apps/cli/src/command-internal/resolve-token.*", "!apps/cli/src/command-internal/schema-flags.*", "!apps/cli/src/command-internal/stack-api.ts", "!apps/cli/src/command-internal/stack-backend.ts", @@ -36,6 +58,7 @@ "!apps/cli/src/command-internal/stack-config.ts", "!apps/cli/src/command-internal/stack-local-database.ts", "!apps/cli/src/command-internal/stack-shadow.ts", - "!apps/cli/src/command-internal/stack-storage.ts" + "!apps/cli/src/command-internal/stack-storage.ts", + "!apps/cli/src/command-internal/tenant-*" ] } diff --git a/apps/cli/src/command-internal/branch-ref.resolver.ts b/apps/cli/src/command-internal/branch-ref.resolver.ts index b5e2e6a570..2bd89f1aae 100644 --- a/apps/cli/src/command-internal/branch-ref.resolver.ts +++ b/apps/cli/src/command-internal/branch-ref.resolver.ts @@ -33,12 +33,12 @@ export interface BranchRefResolveMappers { * directory: the UUID endpoint does not use a parent ref, so requiring one * up front would fail invocations the API itself can serve. */ -export function resolveBranchProjectRef( - input: string, - projectRef: string | Effect.Effect, - mappers: BranchRefResolveMappers, -) { - return Effect.gen(function* () { +export const resolveBranchProjectRef = Effect.fn("BranchRef.resolve")( + function* ( + input: string, + projectRef: string | Effect.Effect, + mappers: BranchRefResolveMappers, + ) { if (BRANCH_PROJECT_REF_PATTERN.test(input)) { yield* Effect.annotateCurrentSpan("branch_ref.input_kind", "project_ref"); return input; @@ -60,8 +60,6 @@ export function resolveBranchProjectRef Effect.annotateCurrentSpan("project.ref", ref)), - Effect.withSpan("BranchRef.resolve"), - ); -} + }, + Effect.tap((ref) => Effect.annotateCurrentSpan("project.ref", ref)), +); diff --git a/apps/cli/src/command-internal/debug-logger.layer.ts b/apps/cli/src/command-internal/debug-logger.layer.ts index bc3c156247..b0a1c75d43 100644 --- a/apps/cli/src/command-internal/debug-logger.layer.ts +++ b/apps/cli/src/command-internal/debug-logger.layer.ts @@ -1,4 +1,4 @@ -import { Effect, Layer } from "effect"; +import { DateTime, Effect, Layer } from "effect"; import { DebugFlag } from "./global-flags.ts"; import { DebugLogger } from "./debug-logger.service.ts"; @@ -6,10 +6,11 @@ import { DebugLogger } from "./debug-logger.service.ts"; const pad = (n: number): string => String(n).padStart(2, "0"); /** Formats a timestamp matching Go's `log.LstdFlags`: `YYYY/MM/DD HH:MM:SS`. */ -function formatTimestamp(now: Date): string { +function formatTimestamp(now: DateTime.DateTime): string { + const local = DateTime.toParts(DateTime.setZone(now, DateTime.zoneMakeLocal())); return ( - `${now.getFullYear()}/${pad(now.getMonth() + 1)}/${pad(now.getDate())} ` + - `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}` + `${local.year}/${pad(local.month)}/${pad(local.day)} ` + + `${pad(local.hour)}:${pad(local.minute)}:${pad(local.second)}` ); } @@ -25,7 +26,10 @@ export const debugLoggerLayer = Layer.effect( return DebugLogger.of({ debug: writeLine, - http: (method, url) => writeLine(`${formatTimestamp(new Date())} HTTP ${method}: ${url}`), + http: (method, url) => + Effect.flatMap(DateTime.now, (now) => + writeLine(`${formatTimestamp(now)} HTTP ${method}: ${url}`), + ), }); }), ); diff --git a/apps/cli/src/command-internal/debug-logger.layer.unit.test.ts b/apps/cli/src/command-internal/debug-logger.layer.unit.test.ts index 8207e64115..846ae8b799 100644 --- a/apps/cli/src/command-internal/debug-logger.layer.unit.test.ts +++ b/apps/cli/src/command-internal/debug-logger.layer.unit.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; -import { afterEach, vi } from "vitest"; +import { DateTime, Effect, Layer } from "effect"; +import { TestClock } from "effect/testing"; +import { vi } from "vitest"; +import { withEnvVar } from "../../tests/helpers/command-mocks.ts"; import { DebugFlag } from "./global-flags.ts"; import { debugLoggerLayer } from "./debug-logger.layer.ts"; import { DebugLogger } from "./debug-logger.service.ts"; @@ -14,10 +16,6 @@ function captureStderr() { return vi.spyOn(process.stderr, "write").mockImplementation(() => true); } -afterEach(() => { - vi.useRealTimers(); -}); - describe("debugLoggerLayer", () => { it.effect("does not write stderr bytes when debug is disabled", () => { const stderr = captureStderr(); @@ -47,16 +45,20 @@ describe("debugLoggerLayer", () => { }); it.effect("http emits Go timestamp order and method/url format", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date(2026, 5, 4, 8, 24, 47)); const stderr = captureStderr(); - return Effect.gen(function* () { + const body = Effect.gen(function* () { + const localTime = DateTime.makeZonedUnsafe( + { year: 2026, month: 6, day: 4, hour: 8, minute: 24, second: 47 }, + { timeZone: DateTime.zoneMakeLocal(), adjustForTimeZone: true }, + ); + yield* TestClock.setTime(DateTime.toEpochMillis(localTime)); const logger = yield* DebugLogger; yield* logger.http("GET", "https://api.supabase.green/v1/projects"); expect(stderr.mock.calls.map(([chunk]) => String(chunk)).join("")).toBe( "2026/06/04 08:24:47 HTTP GET: https://api.supabase.green/v1/projects\n", ); - }).pipe( + }); + return withEnvVar("TZ", "Asia/Kolkata", body).pipe( Effect.ensuring(Effect.sync(() => stderr.mockRestore())), Effect.provide(makeLayer(true)), ); diff --git a/apps/cli/src/command-internal/ensure-login.ts b/apps/cli/src/command-internal/ensure-login.ts index 012e69b44a..1dd1170101 100644 --- a/apps/cli/src/command-internal/ensure-login.ts +++ b/apps/cli/src/command-internal/ensure-login.ts @@ -135,14 +135,12 @@ export const browserLogin = Effect.fn("Login.browser")(function* (opts: BrowserL const failures = failuresSoFar + 1; if (failures > MAX_LOGIN_RETRIES) { yield* Effect.annotateCurrentSpan("login.verify_attempt_count", failures); - return yield* Effect.fail( - new LoginFailedError({ - message: err.message, - statusCode: err.statusCode, - network: err.network, - decode: err.decode, - }), - ); + return yield* new LoginFailedError({ + message: err.message, + statusCode: err.statusCode, + network: err.network, + decode: err.decode, + }); } yield* output.raw(`${err.message}\nRetry (${failures}/${MAX_LOGIN_RETRIES}): `, "stderr"); return yield* verifyWithRetries(failures); diff --git a/apps/cli/src/command-internal/hostname.unit.test.ts b/apps/cli/src/command-internal/hostname.unit.test.ts index 4d42405fb3..712fbdb58b 100644 --- a/apps/cli/src/command-internal/hostname.unit.test.ts +++ b/apps/cli/src/command-internal/hostname.unit.test.ts @@ -1,11 +1,18 @@ import { createHash } from "node:crypto"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Config, ConfigProvider, Crypto, Effect, FileSystem, Layer, Option, Path } from "effect"; +import { + Config, + ConfigProvider, + Crypto, + Effect, + FileSystem, + Layer, + Option, + Path, + Schema, +} from "effect"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { configureLoopbackProxyBypass, @@ -38,38 +45,43 @@ function configLayer(env: Readonly>) { ); } -function writeDockerConfigDir(options: { +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + +const writeDockerConfigDir = Effect.fnUntraced(function* (options: { readonly currentContext?: string; readonly contexts?: Readonly>; -}): string { - const dir = mkdtempSync(join(tmpdir(), "hostname-docker-config-")); +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "hostname-docker-config-" }); if (options.currentContext !== undefined) { - writeFileSync( - join(dir, "config.json"), - JSON.stringify({ currentContext: options.currentContext }), + yield* fs.writeFileString( + path.join(dir, "config.json"), + yield* encodeJson({ currentContext: options.currentContext }), ); } for (const [name, host] of Object.entries(options.contexts ?? {})) { const contextId = createHash("sha256").update(name).digest("hex"); - const metaDir = join(dir, "contexts", "meta", contextId); - mkdirSync(metaDir, { recursive: true }); - writeFileSync( - join(metaDir, "meta.json"), - JSON.stringify({ Endpoints: { docker: { Host: host } } }), + const metaDir = path.join(dir, "contexts", "meta", contextId); + yield* fs.makeDirectory(metaDir, { recursive: true }); + yield* fs.writeFileString( + path.join(metaDir, "meta.json"), + yield* encodeJson({ Endpoints: { docker: { Host: host } } }), ); } return dir; -} +}); function withDockerConfig( options: Parameters[0], env: Readonly>, run: () => Effect.Effect, -): Effect.Effect { - return Effect.acquireUseRelease( - Effect.sync(() => writeDockerConfigDir(options)), - (configDir) => run().pipe(Effect.provide(configLayer({ ...env, DOCKER_CONFIG: configDir }))), - (configDir) => Effect.sync(() => rmSync(configDir, { recursive: true, force: true })), +) { + return writeDockerConfigDir(options).pipe( + Effect.provide(BunServices.layer), + Effect.flatMap((configDir) => + run().pipe(Effect.provide(configLayer({ ...env, DOCKER_CONFIG: configDir }))), + ), ); } diff --git a/apps/cli/src/command-internal/http-dns.ts b/apps/cli/src/command-internal/http-dns.ts index c21fe7ce61..f4e04d43b1 100644 --- a/apps/cli/src/command-internal/http-dns.ts +++ b/apps/cli/src/command-internal/http-dns.ts @@ -76,6 +76,14 @@ export interface DohFetchOptions { readonly innerFetch?: FetchFn; } +const interruptOnAbort = (signal: AbortSignal) => + Effect.callback((resume) => { + if (signal.aborted) return resume(Effect.interrupt); + const onAbort = () => resume(Effect.interrupt); + signal.addEventListener("abort", onAbort, { once: true }); + return Effect.sync(() => signal.removeEventListener("abort", onAbort)); + }); + /** * Produces a custom `fetch` implementation that DNS-over-HTTPS-resolves the * request hostname before dialing, then passes `tls.serverName` so Bun @@ -90,55 +98,57 @@ export function dohFetch(opts: DohFetchOptions): typeof globalThis.fetch { const { dnsResolver, resolver = resolveHostsOverHttps } = opts; const innerFetch: FetchFn = opts.innerFetch ?? globalThis.fetch; - const fetchImpl: FetchFn = async ( - input: string | URL | Request, - init?: RequestInit, - ): Promise => { - const originalUrl = - typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const parsed = new URL(originalUrl); - // Strip Bun's IPv6 brackets (e.g. "[::1]") so net.isIP identifies the literal correctly. - const rawHostname = parsed.hostname; - const host = - rawHostname.startsWith("[") && rawHostname.endsWith("]") - ? rawHostname.slice(1, -1) - : rawHostname; - - if (dnsResolver !== "https" || net.isIP(host) !== 0) { - return innerFetch(input, init); - } - - // The request's abort signal must reach the lookup too, or an abort during - // resolution leaves the resolver fiber running until the DoH server answers. - const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined); - const ips = await Effect.runPromise(resolver(host), { signal: signal ?? undefined }); - const firstIp = ips[0]; - if (firstIp === undefined) { - // resolver guarantees a non-empty result; this is a safety net. - return innerFetch(input, init); - } - - const { url, serverName, hostHeader } = buildDohRequest(originalUrl, firstIp); - - // `init.headers` may be a plain record, a WHATWG `Headers` instance - // (supabase-js), or an entries array; spreading a `Headers` instance yields - // zero entries, so rebuild through the constructor. A `Request` input with - // no `init.headers` carries its headers on the request itself. - const headers = new Headers( - init?.headers ?? (input instanceof Request ? input.headers : undefined), + const fetchImpl: FetchFn = (input, init) => + Effect.runPromise( + Effect.gen(function* () { + const originalUrl = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const parsed = new URL(originalUrl); + // Strip Bun's IPv6 brackets (e.g. "[::1]") so net.isIP identifies the literal correctly. + const rawHostname = parsed.hostname; + const host = + rawHostname.startsWith("[") && rawHostname.endsWith("]") + ? rawHostname.slice(1, -1) + : rawHostname; + + if (dnsResolver !== "https" || net.isIP(host) !== 0) { + return yield* Effect.promise(() => innerFetch(input, init)); + } + + // The request's abort signal must reach the lookup too, or an abort during + // resolution leaves the resolver fiber running until the DoH server answers. + const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined); + const ips = yield* signal + ? Effect.raceFirst(resolver(host), interruptOnAbort(signal)) + : resolver(host); + const firstIp = ips[0]; + if (firstIp === undefined) { + // resolver guarantees a non-empty result; this is a safety net. + return yield* Effect.promise(() => innerFetch(input, init)); + } + + const { url, serverName, hostHeader } = buildDohRequest(originalUrl, firstIp); + + // `init.headers` may be a plain record, a WHATWG `Headers` instance + // (supabase-js), or an entries array; spreading a `Headers` instance yields + // zero entries, so rebuild through the constructor. A `Request` input with + // no `init.headers` carries its headers on the request itself. + const headers = new Headers( + init?.headers ?? (input instanceof Request ? input.headers : undefined), + ); + headers.set("Host", hostHeader); + // Bun's fetch sends `tls.serverName` as the SNI extension and validates + // the peer certificate against it, not against the IP used as the URL + // authority. + const rewrittenInit: BunFetchRequestInit = { + ...init, + headers, + tls: { serverName }, + }; + + return yield* Effect.promise(() => innerFetch(url, rewrittenInit)); + }), ); - headers.set("Host", hostHeader); - // Bun's fetch sends `tls.serverName` as the SNI extension and validates - // the peer certificate against it, not against the IP used as the URL - // authority. - const rewrittenInit: BunFetchRequestInit = { - ...init, - headers, - tls: { serverName }, - }; - - return innerFetch(url, rewrittenInit); - }; // `typeof globalThis.fetch` includes Bun's `preconnect` member; attach the // real one so this override satisfies that type without a cast. diff --git a/apps/cli/src/command-internal/http-dns.unit.test.ts b/apps/cli/src/command-internal/http-dns.unit.test.ts index 0e2e4106e7..e3d3e3e7a1 100644 --- a/apps/cli/src/command-internal/http-dns.unit.test.ts +++ b/apps/cli/src/command-internal/http-dns.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect, Exit, Layer } from "effect"; import * as net from "node:net"; import { DnsResolverFlag } from "./global-flags.ts"; @@ -56,10 +56,10 @@ describe("dohFetch", () => { }; function makeFakeFetch(captured: CapturedCall[]): typeof globalThis.fetch { - const fn = async (input: string | URL | Request, init?: RequestInit): Promise => { + const fn = (input: string | URL | Request, init?: RequestInit): Promise => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; captured.push({ url, init: (init ?? {}) as CapturedCall["init"] }); - return new Response("ok", { status: 200 }); + return Promise.resolve(new Response("ok", { status: 200 })); }; return fn as typeof globalThis.fetch; } @@ -68,160 +68,263 @@ describe("dohFetch", () => { return (_host: string) => Effect.succeed(ips); } - it("dials the first resolved IP, sets tls.serverName, and injects Host header", async () => { - const captured: CapturedCall[] = []; - const fetchFn = dohFetch({ - dnsResolver: "https", - resolver: makeFakeResolver(["203.0.113.10", "203.0.113.11"]), - innerFetch: makeFakeFetch(captured), - }); - - await fetchFn("https://api.supabase.com/v1/projects", { - method: "GET", - headers: { authorization: "Bearer tok" }, - }); - - expect(captured).toHaveLength(1); - const call = captured[0]!; - expect(new URL(call.url).hostname).toBe("203.0.113.10"); - expect(new URL(call.url).pathname).toBe("/v1/projects"); - expect(call.init.tls?.serverName).toBe("api.supabase.com"); - // Host header pinned to original hostname. - const headers = new Headers(call.init.headers); - expect(headers.get("host")).toBe("api.supabase.com"); - // Other headers preserved. - expect(headers.get("authorization")).toBe("Bearer tok"); - }); - - it("preserves entries from a WHATWG Headers instance (supabase-js shape)", async () => { - const captured: CapturedCall[] = []; - const fetchFn = dohFetch({ - dnsResolver: "https", - resolver: makeFakeResolver(["203.0.113.10"]), - innerFetch: makeFakeFetch(captured), - }); - - // supabase-js passes `init.headers` as a `Headers` instance, not a plain - // record. Spreading a `Headers` instance yields zero entries, so this is - // the regression case: auth and capability headers must survive the - // DoH rewrite. - await fetchFn("https://feedback.supabase.co/rest/v1/interfaces_feedback", { - method: "DELETE", - headers: new Headers({ - apikey: "sb_publishable_key", - "content-type": "application/json", - "x-feedback-token": "123e4567-e89b-12d3-a456-426614174000", - }), - }); - - const headers = new Headers(captured[0]!.init.headers); - expect(headers.get("apikey")).toBe("sb_publishable_key"); - expect(headers.get("content-type")).toBe("application/json"); - expect(headers.get("x-feedback-token")).toBe("123e4567-e89b-12d3-a456-426614174000"); - expect(headers.get("host")).toBe("feedback.supabase.co"); - }); + it.effect("dials the first resolved IP, sets tls.serverName, and injects Host header", () => + Effect.gen(function* () { + const captured: CapturedCall[] = []; + const fetchFn = dohFetch({ + dnsResolver: "https", + resolver: makeFakeResolver(["203.0.113.10", "203.0.113.11"]), + innerFetch: makeFakeFetch(captured), + }); + + yield* Effect.promise(() => + fetchFn("https://api.supabase.com/v1/projects", { + method: "GET", + headers: { authorization: "Bearer tok" }, + }), + ); - it("preserves headers embedded on a Request when no init headers are given", async () => { - const captured: CapturedCall[] = []; - const fetchFn = dohFetch({ - dnsResolver: "https", - resolver: makeFakeResolver(["203.0.113.10"]), - innerFetch: makeFakeFetch(captured), - }); + expect(captured).toHaveLength(1); + const call = captured[0]!; + expect(new URL(call.url).hostname).toBe("203.0.113.10"); + expect(new URL(call.url).pathname).toBe("/v1/projects"); + expect(call.init.tls?.serverName).toBe("api.supabase.com"); + // Host header pinned to original hostname. + const headers = new Headers(call.init.headers); + expect(headers.get("host")).toBe("api.supabase.com"); + // Other headers preserved. + expect(headers.get("authorization")).toBe("Bearer tok"); + }), + ); + + it.effect("preserves entries from a WHATWG Headers instance (supabase-js shape)", () => + Effect.gen(function* () { + const captured: CapturedCall[] = []; + const fetchFn = dohFetch({ + dnsResolver: "https", + resolver: makeFakeResolver(["203.0.113.10"]), + innerFetch: makeFakeFetch(captured), + }); + + // supabase-js passes `init.headers` as a `Headers` instance, not a plain + // record. Spreading a `Headers` instance yields zero entries, so this is + // the regression case: auth and capability headers must survive the + // DoH rewrite. + yield* Effect.promise(() => + fetchFn("https://feedback.supabase.co/rest/v1/interfaces_feedback", { + method: "DELETE", + headers: new Headers({ + apikey: "sb_publishable_key", + "content-type": "application/json", + "x-feedback-token": "123e4567-e89b-12d3-a456-426614174000", + }), + }), + ); - await fetchFn( - new Request("https://api.supabase.com/v1/projects", { - headers: { authorization: "Bearer tok" }, - }), - ); + const headers = new Headers(captured[0]!.init.headers); + expect(headers.get("apikey")).toBe("sb_publishable_key"); + expect(headers.get("content-type")).toBe("application/json"); + expect(headers.get("x-feedback-token")).toBe("123e4567-e89b-12d3-a456-426614174000"); + expect(headers.get("host")).toBe("feedback.supabase.co"); + }), + ); + + it.effect("preserves headers embedded on a Request when no init headers are given", () => + Effect.gen(function* () { + const captured: CapturedCall[] = []; + const fetchFn = dohFetch({ + dnsResolver: "https", + resolver: makeFakeResolver(["203.0.113.10"]), + innerFetch: makeFakeFetch(captured), + }); + + yield* Effect.promise(() => + fetchFn( + new Request("https://api.supabase.com/v1/projects", { + headers: { authorization: "Bearer tok" }, + }), + ), + ); - const headers = new Headers(captured[0]!.init.headers); - expect(headers.get("authorization")).toBe("Bearer tok"); - expect(headers.get("host")).toBe("api.supabase.com"); - }); + const headers = new Headers(captured[0]!.init.headers); + expect(headers.get("authorization")).toBe("Bearer tok"); + expect(headers.get("host")).toBe("api.supabase.com"); + }), + ); - it("cancels an in-flight DoH resolution when the request signal aborts", async () => { + it.effect("cancels an in-flight DoH resolution when the request signal aborts", () => { // Ctrl-C or a caller timeout during the DNS lookup must not leave the // resolver running (holding the process open) until the DoH server // answers: the request signal has to reach the resolver fiber. - const captured: CapturedCall[] = []; - const fetchFn = dohFetch({ - dnsResolver: "https", - resolver: () => Effect.never, - innerFetch: makeFakeFetch(captured), - }); const controller = new AbortController(); - const pending = fetchFn("https://api.supabase.com/v1/projects", { signal: controller.signal }); - controller.abort(); - - await expect(pending).rejects.toBeDefined(); - expect(captured).toHaveLength(0); - }); - - it("passes through without DoH when dnsResolver is 'native'", async () => { - const captured: CapturedCall[] = []; - const resolverCalls: string[] = []; - const fetchFn = dohFetch({ - dnsResolver: "native", - resolver: (host) => { - resolverCalls.push(host); - return Effect.succeed(["203.0.113.10"]); - }, - innerFetch: makeFakeFetch(captured), + return Effect.gen(function* () { + const captured: CapturedCall[] = []; + const fetchFn = dohFetch({ + dnsResolver: "https", + resolver: () => Effect.never, + innerFetch: makeFakeFetch(captured), + }); + + const pending = fetchFn("https://api.supabase.com/v1/projects", { + signal: controller.signal, + }); + controller.abort(); + + expect(Exit.isFailure(yield* Effect.exit(Effect.tryPromise(() => pending)))).toBe(true); + expect(captured).toHaveLength(0); }); - - await fetchFn("https://api.supabase.com/v1/projects"); - - expect(captured[0]?.url).toBe("https://api.supabase.com/v1/projects"); - expect(resolverCalls).toHaveLength(0); }); - it("passes through without DoH when the URL host is already an IPv4 literal", async () => { - const captured: CapturedCall[] = []; - const resolverCalls: string[] = []; - const fetchFn = dohFetch({ - dnsResolver: "https", - resolver: (host) => { - resolverCalls.push(host); - return Effect.succeed(["203.0.113.10"]); - }, - innerFetch: makeFakeFetch(captured), - }); - - await fetchFn("https://203.0.113.99/v1/projects"); - - expect(captured[0]?.url).toBe("https://203.0.113.99/v1/projects"); - expect(resolverCalls).toHaveLength(0); - }); + it.effect("cancels the DoH lookup when the request signal is already aborted", () => + Effect.gen(function* () { + const captured: CapturedCall[] = []; + let cancelled = 0; + const fetchFn = dohFetch({ + dnsResolver: "https", + resolver: () => + Effect.yieldNow.pipe( + Effect.as(["203.0.113.10"]), + Effect.onInterrupt(() => + Effect.sync(() => { + cancelled += 1; + }), + ), + ), + innerFetch: makeFakeFetch(captured), + }); + + const pending = fetchFn("https://api.supabase.com/v1/projects", { + signal: AbortSignal.abort(), + }); + + expect(Exit.isFailure(yield* Effect.exit(Effect.tryPromise(() => pending)))).toBe(true); + expect(captured).toHaveLength(0); + expect(cancelled).toBe(1); + }), + ); + + it.effect("keeps fetch's own rejection when the request signal is aborted", () => + Effect.gen(function* () { + const abortError = new DOMException("The operation was aborted.", "AbortError"); + + for (const dnsResolver of ["native", "https"] as const) { + const fetchFn = dohFetch({ + dnsResolver, + resolver: makeFakeResolver(["203.0.113.10"]), + innerFetch: () => Promise.reject(abortError), + }); + + const error = yield* Effect.flip( + Effect.tryPromise(() => + fetchFn("https://api.supabase.com/v1/projects", { signal: AbortSignal.abort() }), + ), + ); + expect(error.cause).toBe(abortError); + } + }), + ); + + it.effect("keeps a response that completes as the request signal aborts", () => { + const controllers = { + native: new AbortController(), + https: new AbortController(), + }; - it("passes through without DoH when the URL host is already an IPv6 literal", async () => { - const captured: CapturedCall[] = []; - const resolverCalls: string[] = []; - const fetchFn = dohFetch({ - dnsResolver: "https", - resolver: (host) => { - resolverCalls.push(host); - return Effect.succeed(["2001:db8::1"]); - }, - innerFetch: makeFakeFetch(captured), + return Effect.gen(function* () { + for (const dnsResolver of ["native", "https"] as const) { + const controller = controllers[dnsResolver]; + const fetchFn = dohFetch({ + dnsResolver, + resolver: makeFakeResolver(["203.0.113.10"]), + innerFetch: () => { + controller.abort(); + return Promise.resolve(new Response("ok", { status: 200 })); + }, + }); + + const response = yield* Effect.promise(() => + fetchFn("https://api.supabase.com/v1/projects", { signal: controller.signal }), + ); + expect(response.status).toBe(200); + } }); - - await fetchFn("https://[2001:db8::1]/v1/projects"); - - expect(captured[0]?.url).toBe("https://[2001:db8::1]/v1/projects"); - expect(resolverCalls).toHaveLength(0); }); - it("propagates resolver failures as rejected promises", async () => { - const fetchFn = dohFetch({ - dnsResolver: "https", - resolver: (_host) => Effect.fail(new DbConnectError({ message: "DoH timed out" })), - innerFetch: makeFakeFetch([]), - }); - - await expect(fetchFn("https://api.supabase.com/v1/projects")).rejects.toThrow(); - }); + it.effect("passes through without DoH when dnsResolver is 'native'", () => + Effect.gen(function* () { + const captured: CapturedCall[] = []; + const resolverCalls: string[] = []; + const fetchFn = dohFetch({ + dnsResolver: "native", + resolver: (host) => { + resolverCalls.push(host); + return Effect.succeed(["203.0.113.10"]); + }, + innerFetch: makeFakeFetch(captured), + }); + + yield* Effect.promise(() => fetchFn("https://api.supabase.com/v1/projects")); + + expect(captured[0]?.url).toBe("https://api.supabase.com/v1/projects"); + expect(resolverCalls).toHaveLength(0); + }), + ); + + it.effect("passes through without DoH when the URL host is already an IPv4 literal", () => + Effect.gen(function* () { + const captured: CapturedCall[] = []; + const resolverCalls: string[] = []; + const fetchFn = dohFetch({ + dnsResolver: "https", + resolver: (host) => { + resolverCalls.push(host); + return Effect.succeed(["203.0.113.10"]); + }, + innerFetch: makeFakeFetch(captured), + }); + + yield* Effect.promise(() => fetchFn("https://203.0.113.99/v1/projects")); + + expect(captured[0]?.url).toBe("https://203.0.113.99/v1/projects"); + expect(resolverCalls).toHaveLength(0); + }), + ); + + it.effect("passes through without DoH when the URL host is already an IPv6 literal", () => + Effect.gen(function* () { + const captured: CapturedCall[] = []; + const resolverCalls: string[] = []; + const fetchFn = dohFetch({ + dnsResolver: "https", + resolver: (host) => { + resolverCalls.push(host); + return Effect.succeed(["2001:db8::1"]); + }, + innerFetch: makeFakeFetch(captured), + }); + + yield* Effect.promise(() => fetchFn("https://[2001:db8::1]/v1/projects")); + + expect(captured[0]?.url).toBe("https://[2001:db8::1]/v1/projects"); + expect(resolverCalls).toHaveLength(0); + }), + ); + + it.effect("propagates resolver failures as rejected promises", () => + Effect.gen(function* () { + const fetchFn = dohFetch({ + dnsResolver: "https", + resolver: (_host) => Effect.fail(new DbConnectError({ message: "DoH timed out" })), + innerFetch: makeFakeFetch([]), + }); + + const error = yield* Effect.flip( + Effect.tryPromise(() => fetchFn("https://api.supabase.com/v1/projects")), + ); + expect(error.cause).toBeInstanceOf(DbConnectError); + }), + ); }); describe("dohFetchLayer (Effect layer integration)", () => { @@ -231,7 +334,7 @@ describe("dohFetchLayer (Effect layer integration)", () => { const fakeFetch = dohFetch({ dnsResolver: "https", resolver: (_host) => Effect.succeed(["203.0.113.10"]), - innerFetch: (async (input: string | URL | Request, init?: RequestInit) => { + innerFetch: ((input: string | URL | Request, init?: RequestInit) => { const url = typeof input === "string" ? input @@ -239,7 +342,7 @@ describe("dohFetchLayer (Effect layer integration)", () => { ? input.href : (input as Request).url; captured.push({ url, tls: (init as { tls?: { serverName: string } })?.tls }); - return new Response("ok", { status: 200 }); + return Promise.resolve(new Response("ok", { status: 200 })); }) as typeof globalThis.fetch, }); diff --git a/apps/cli/src/command-internal/identity-stitch.integration.test.ts b/apps/cli/src/command-internal/identity-stitch.integration.test.ts index 581841d56b..f3c72d37f2 100644 --- a/apps/cli/src/command-internal/identity-stitch.integration.test.ts +++ b/apps/cli/src/command-internal/identity-stitch.integration.test.ts @@ -2,8 +2,9 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path } from "effect"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { BunServices } from "@effect/platform-bun"; import { mockAnalytics, mockTelemetryRuntime } from "../../tests/helpers/mocks.ts"; +import { TelemetryRuntime } from "../shared/telemetry/runtime.service.ts"; import { IdentityStitch, identityStitchLayer } from "./identity-stitch.ts"; function fakeResponse(headers: Record): HttpClientResponse.HttpClientResponse { @@ -13,45 +14,50 @@ function fakeResponse(headers: Record): HttpClientResponse.HttpC function makeStitchLayer(opts: { analytics: ReturnType; - configDir: string; deviceId?: string; distinctId?: string; isCi?: boolean; isFirstRun?: boolean; isTty?: boolean; }) { - return identityStitchLayer.pipe( - Layer.provide(opts.analytics.layer), - Layer.provide( - mockTelemetryRuntime({ + const runtime = Layer.unwrap( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return mockTelemetryRuntime({ consent: "granted", isFirstRun: opts.isFirstRun ?? false, isTty: opts.isTty ?? false, isCi: opts.isCi ?? false, - configDir: opts.configDir, + configDir: yield* fs.makeTempDirectoryScoped({ prefix: "identity-stitch-test-" }), deviceId: opts.deviceId ?? "device-001", distinctId: opts.distinctId, - }), - ), - Layer.provide(BunFileSystem.layer), - Layer.provide(BunPath.layer), + }); + }), + ); + return identityStitchLayer.pipe( + Layer.provideMerge(runtime), + Layer.provide(opts.analytics.layer), + Layer.provideMerge(BunServices.layer), ); } +const writeEnabledTelemetry = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { configDir } = yield* TelemetryRuntime; + yield* fs.writeFileString( + path.join(configDir, "telemetry.json"), + `{"enabled":true,"device_id":"device-001","schema_version":1}`, + ); +}); + describe("identityStitchLayer — stitchedDistinctId()", () => { it.live("populates stitchedDistinctId() after the first response with X-Gotrue-Id", () => { const analytics = mockAnalytics(); - const configDir = "/tmp/identity-stitch-test-" + String(Date.now()); return Effect.gen(function* () { // Write a valid telemetry.json so stitchIdentity sees enabled=true. - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* fs.makeDirectory(configDir, { recursive: true }); - yield* fs.writeFileString( - path.join(configDir, "telemetry.json"), - JSON.stringify({ enabled: true, device_id: "device-001", schema_version: 1 }), - ); + yield* writeEnabledTelemetry; const svc = yield* IdentityStitch; @@ -63,25 +69,14 @@ describe("identityStitchLayer — stitchedDistinctId()", () => { expect(analytics.aliased).toHaveLength(1); expect(analytics.aliased[0]).toEqual({ distinctId: "gotrue-abc-123", alias: "device-001" }); - }).pipe( - Effect.provide(makeStitchLayer({ analytics, configDir })), - Effect.provide(BunFileSystem.layer), - Effect.provide(BunPath.layer), - ); + }).pipe(Effect.provide(makeStitchLayer({ analytics }))); }); it.live("once-only guard: a second stitch call with a different id keeps the first", () => { const analytics = mockAnalytics(); - const configDir = "/tmp/identity-stitch-test-guard-" + String(Date.now()); return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* fs.makeDirectory(configDir, { recursive: true }); - yield* fs.writeFileString( - path.join(configDir, "telemetry.json"), - JSON.stringify({ enabled: true, device_id: "device-001", schema_version: 1 }), - ); + yield* writeEnabledTelemetry; const svc = yield* IdentityStitch; @@ -92,22 +87,18 @@ describe("identityStitchLayer — stitchedDistinctId()", () => { expect(analytics.aliased).toHaveLength(1); expect(analytics.aliased[0]?.distinctId).toBe("first-id"); - }).pipe( - Effect.provide(makeStitchLayer({ analytics, configDir })), - Effect.provide(BunFileSystem.layer), - Effect.provide(BunPath.layer), - ); + }).pipe(Effect.provide(makeStitchLayer({ analytics }))); }); }); describe("identityStitchLayer — hybrid stamp/alias", () => { it.live("ephemeral (CI) runtime stamps the identity but does not alias or persist", () => { const analytics = mockAnalytics(); - const configDir = "/tmp/identity-stitch-test-ci-" + String(Date.now()); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const { configDir } = yield* TelemetryRuntime; const svc = yield* IdentityStitch; yield* svc.stitch(fakeResponse({ "x-gotrue-id": "gotrue-ci-1" })); @@ -116,16 +107,11 @@ describe("identityStitchLayer — hybrid stamp/alias", () => { expect(analytics.aliased).toHaveLength(0); const exists = yield* fs.exists(path.join(configDir, "telemetry.json")); expect(exists).toBe(false); - }).pipe( - Effect.provide(makeStitchLayer({ analytics, configDir, isCi: true })), - Effect.provide(BunFileSystem.layer), - Effect.provide(BunPath.layer), - ); + }).pipe(Effect.provide(makeStitchLayer({ analytics, isCi: true }))); }); it.live("stamps over a stale persisted identity without aliasing", () => { const analytics = mockAnalytics(); - const configDir = "/tmp/identity-stitch-test-stale-" + String(Date.now()); return Effect.gen(function* () { const svc = yield* IdentityStitch; @@ -135,25 +121,39 @@ describe("identityStitchLayer — hybrid stamp/alias", () => { expect(svc.stitchedDistinctId()).toBe("new-user"); expect(analytics.aliased).toHaveLength(0); - }).pipe( - Effect.provide(makeStitchLayer({ analytics, configDir, distinctId: "old-user" })), - Effect.provide(BunFileSystem.layer), - Effect.provide(BunPath.layer), - ); + }).pipe(Effect.provide(makeStitchLayer({ analytics, distinctId: "old-user" }))); }); - it.live("concurrent first responses alias exactly once", () => { + it.live("persists a prior int64 schema_version token byte for byte", () => { const analytics = mockAnalytics(); - const configDir = "/tmp/identity-stitch-test-conc-" + String(Date.now()); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - yield* fs.makeDirectory(configDir, { recursive: true }); + const { configDir } = yield* TelemetryRuntime; + const telemetryPath = path.join(configDir, "telemetry.json"); yield* fs.writeFileString( - path.join(configDir, "telemetry.json"), - JSON.stringify({ enabled: true, device_id: "device-001", schema_version: 1 }), + telemetryPath, + `{"enabled":true,"device_id":"device-001","session_id":"session-001","session_last_active":"2026-01-01T00:00:00.000Z","schema_version":9007199254740993}`, + ); + const svc = yield* IdentityStitch; + + yield* svc.stitch(fakeResponse({ "x-gotrue-id": "gotrue-int64" })); + + const written = yield* fs.readFileString(telemetryPath); + expect(written).toContain(`"schema_version":9007199254740993}`); + expect(written).toMatch( + /"session_last_active":"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z"/, ); + expect(written).toContain(`"distinct_id":"gotrue-int64"`); + }).pipe(Effect.provide(makeStitchLayer({ analytics }))); + }); + + it.live("concurrent first responses alias exactly once", () => { + const analytics = mockAnalytics(); + + return Effect.gen(function* () { + yield* writeEnabledTelemetry; const svc = yield* IdentityStitch; @@ -167,10 +167,6 @@ describe("identityStitchLayer — hybrid stamp/alias", () => { expect(analytics.aliased).toHaveLength(1); expect(svc.stitchedDistinctId()).toBe(analytics.aliased[0]?.distinctId); - }).pipe( - Effect.provide(makeStitchLayer({ analytics, configDir })), - Effect.provide(BunFileSystem.layer), - Effect.provide(BunPath.layer), - ); + }).pipe(Effect.provide(makeStitchLayer({ analytics }))); }); }); diff --git a/apps/cli/src/command-internal/identity-stitch.ts b/apps/cli/src/command-internal/identity-stitch.ts index 0a25071f75..522c591a6e 100644 --- a/apps/cli/src/command-internal/identity-stitch.ts +++ b/apps/cli/src/command-internal/identity-stitch.ts @@ -1,4 +1,4 @@ -import { Context, Effect, FileSystem, Layer, Option, Path } from "effect"; +import { Context, DateTime, Effect, FileSystem, Layer, Option, Path, Schema } from "effect"; import type * as HttpClientResponse from "effect/http/HttpClientResponse"; import { Analytics } from "../shared/telemetry/analytics.service.ts"; @@ -20,6 +20,8 @@ import { readExistingState } from "../telemetry/telemetry-state.layer.ts"; const HEADER_GOTRUE_ID = "x-gotrue-id"; const TELEMETRY_SCHEMA_VERSION = 1; +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + interface TelemetryState { readonly enabled: boolean; readonly device_id: string; @@ -100,7 +102,7 @@ const makeIdentityStitcher: Effect.Effect< enabled, device_id: prior?.device_id ?? runtime.deviceId, session_id: prior?.session_id ?? runtime.sessionId, - session_last_active: new Date().toISOString(), + session_last_active: DateTime.formatIso(yield* DateTime.now), distinct_id: gotrueId, schema_version: prior?.schemaVersionToken !== undefined @@ -114,9 +116,11 @@ const makeIdentityStitcher: Effect.Effect< // Preserves the prior schema_version's exact int64 token: // re-serializing `state.schema_version` through `Number` would round // values above 2^53 (e.g. 9007199254740993 → …992). - prior?.schemaVersionToken === undefined - ? JSON.stringify(state) - : JSON.stringify({ ...state, schema_version: JSON.rawJSON(prior.schemaVersionToken) }), + yield* encodeJson( + prior?.schemaVersionToken === undefined + ? state + : { ...state, schema_version: JSON.rawJSON(prior.schemaVersionToken) }, + ), ); }); diff --git a/apps/cli/src/command-internal/login-api.layer.ts b/apps/cli/src/command-internal/login-api.layer.ts index 234d621010..6a6a994b1e 100644 --- a/apps/cli/src/command-internal/login-api.layer.ts +++ b/apps/cli/src/command-internal/login-api.layer.ts @@ -36,12 +36,10 @@ export const loginApiLayer = Layer.effect( const response = yield* httpClient.execute(request); if (response.status !== 200) { const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - return yield* Effect.fail( - new LoginVerificationError({ - message: `Error status ${response.status}: ${body}`, - statusCode: response.status, - }), - ); + return yield* new LoginVerificationError({ + message: `Error status ${response.status}: ${body}`, + statusCode: response.status, + }); } const body = yield* response.json; const session: LoginApiSessionResponse = { diff --git a/apps/cli/src/command-internal/login-crypto.layer.ts b/apps/cli/src/command-internal/login-crypto.layer.ts index 63ab3fc5fb..75d6eece0a 100644 --- a/apps/cli/src/command-internal/login-crypto.layer.ts +++ b/apps/cli/src/command-internal/login-crypto.layer.ts @@ -1,7 +1,7 @@ import { Buffer } from "node:buffer"; import { createDecipheriv, createECDH, randomUUID, type ECDH } from "node:crypto"; import { hostname, userInfo } from "node:os"; -import { Effect, Layer } from "effect"; +import { Clock, Effect, Layer } from "effect"; import { LoginCrypto, type LoginEncryptedPayload } from "../commands/login/login-crypto.service.ts"; import { LoginCryptoError, LoginDecryptError } from "../commands/login/login.errors.ts"; @@ -20,8 +20,8 @@ export const loginCryptoLayer = Layer.sync(LoginCrypto, () => new LoginCryptoError({ message: `cannot generate crypto keys: ${String(cause)}` }), }), generateSessionId: Effect.sync(() => randomUUID()), - defaultTokenName: Effect.sync(() => { - const ts = Math.floor(Date.now() / 1000); + defaultTokenName: Effect.map(Clock.currentTimeMillis, (millis) => { + const ts = Math.floor(millis / 1000); try { const user = userInfo().username; const host = hostname(); diff --git a/apps/cli/src/command-internal/management-api-runtime.layer.ts b/apps/cli/src/command-internal/management-api-runtime.layer.ts index 1a2be6b77c..1340d805c3 100644 --- a/apps/cli/src/command-internal/management-api-runtime.layer.ts +++ b/apps/cli/src/command-internal/management-api-runtime.layer.ts @@ -87,9 +87,13 @@ export function managementApiRuntimeLayer(subcommand: ReadonlyArray) { // Compile-time guarantee that the merged layer exposes every service a Management-API handler // may yield from its top-level `Effect.fn` body — a service missing from `ManagementApiServices` - // below becomes a type error here instead of a `Service not found` runtime panic. `unknown` for - // E and R keeps this check scoped to exposed services only. - const _serviceCoverageCheck: Layer.Layer = built; + // below becomes a type error here instead of a `Service not found` runtime panic. Reusing + // `built`'s own E and R keeps this check scoped to exposed services only. + const _serviceCoverageCheck: Layer.Layer< + ManagementApiServices, + Layer.Error, + Layer.Services + > = built; void _serviceCoverageCheck; return built; diff --git a/apps/cli/src/command-internal/profile-load.ts b/apps/cli/src/command-internal/profile-load.ts index 28a1c66c65..221c79493b 100644 --- a/apps/cli/src/command-internal/profile-load.ts +++ b/apps/cli/src/command-internal/profile-load.ts @@ -1,4 +1,4 @@ -import { Data, Effect, FileSystem } from "effect"; +import { Data, Effect, FileSystem, Schema } from "effect"; import { parse as parseYaml } from "yaml"; import { @@ -71,7 +71,10 @@ export function loadProfile( const ext = goFilepathExt(token); if (!VIPER_SUPPORTED_EXTS.has(ext)) { - return yield* failRead(`Unsupported Config Type ${JSON.stringify(ext)}`); + const quoted = yield* quoteJsonString(ext).pipe( + Effect.mapError((error) => readError(error.message)), + ); + return yield* failRead(`Unsupported Config Type ${quoted}`); } const content = yield* fs @@ -90,12 +93,10 @@ export function loadProfile( ), ); - let parsed: unknown; - try { - parsed = parseYaml(content); - } catch (cause) { - return yield* failRead(`While parsing config: ${parseDetail(cause)}`); - } + let parsed = yield* Effect.try({ + try: (): unknown => parseYaml(content), + catch: (cause) => readError(`While parsing config: ${parseDetail(cause)}`), + }); if (parsed === null || parsed === undefined) { parsed = {}; } @@ -166,7 +167,12 @@ export function loadProfile( const fail = (message: string) => Effect.fail(new ProfileLoadError({ message })); -const failRead = (detail: string) => fail(`failed to read profile: ${detail}`); +const readError = (detail: string) => + new ProfileLoadError({ message: `failed to read profile: ${detail}` }); + +const failRead = (detail: string) => Effect.fail(readError(detail)); + +const quoteJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.String)); /** Aggregate decode-error template: multiple failing fields render as one block. */ const failDecode = (detail: string) => diff --git a/apps/cli/src/command-internal/profile-load.unit.test.ts b/apps/cli/src/command-internal/profile-load.unit.test.ts index d4978e6521..ead3c719b4 100644 --- a/apps/cli/src/command-internal/profile-load.unit.test.ts +++ b/apps/cli/src/command-internal/profile-load.unit.test.ts @@ -1,16 +1,9 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - import { BunServices } from "@effect/platform-bun"; -import { afterAll, describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem } from "effect"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; import { loadProfile, padGoErrorBlock, type ProfileLoadError } from "./profile-load.ts"; -const tempRoot = mkdtempSync(join(tmpdir(), "supabase-profile-load-")); -afterAll(() => rmSync(tempRoot, { recursive: true, force: true })); - const load = (token: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -23,11 +16,21 @@ const loadError = (token: string) => Effect.map((error: ProfileLoadError) => error.message), ); -const writeProfile = (name: string, content: string): string => { - const filePath = join(tempRoot, name); - writeFileSync(filePath, content); - return filePath; -}; +/** A path named `name` in a fresh temp directory, removed when the test ends. */ +const tempPath = (name: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return path.join(yield* fs.makeTempDirectoryScoped({ prefix: "supabase-profile-load-" }), name); + }); + +const writeProfile = (name: string, content: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const filePath = yield* tempPath(name); + yield* fs.writeFileString(filePath, content); + return filePath; + }).pipe(Effect.provide(BunServices.layer)); describe("loadProfile", () => { it.effect("resolves built-in profile names case-insensitively (Go strings.EqualFold)", () => @@ -61,10 +64,11 @@ describe("loadProfile", () => { it.effect("uses Go filepath.Ext semantics for dot-files (`.yml` IS extension `yml`)", () => Effect.gen(function* () { - expect(yield* loadError(join(tempRoot, ".yml"))).toBe( - `failed to read profile: open ${join(tempRoot, ".yml")}: no such file or directory`, + const missing = yield* tempPath(".yml"); + expect(yield* loadError(missing)).toBe( + `failed to read profile: open ${missing}: no such file or directory`, ); - }), + }).pipe(Effect.provide(BunServices.layer)), ); it.effect("fails on a missing file with Go's os.Open error", () => @@ -77,15 +81,16 @@ describe("loadProfile", () => { it.effect("fails on a directory with Go's read error", () => Effect.gen(function* () { - const dir = join(tempRoot, "dir.yml"); - mkdirSync(dir, { recursive: true }); + const fs = yield* FileSystem.FileSystem; + const dir = yield* tempPath("dir.yml"); + yield* fs.makeDirectory(dir); expect(yield* loadError(dir)).toBe(`failed to read profile: read ${dir}: is a directory`); - }), + }).pipe(Effect.provide(BunServices.layer)), ); it.effect("resolves a valid YAML profile to its api_url", () => Effect.gen(function* () { - const file = writeProfile( + const file = yield* writeProfile( "valid.yml", [ "name: harness", @@ -101,7 +106,7 @@ describe("loadProfile", () => { it.effect("accepts mixed-case keys like viper's insensitive decode (probed on go1.26)", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const file = writeProfile( + const file = yield* writeProfile( "mixed-case.yml", [ "Name: harness", @@ -125,7 +130,7 @@ describe("loadProfile", () => { expect(builtin.dashboardUrl).toBe("https://supabase.green/dashboard"); // pooler_host is omitted below; it's optional and stays empty (disables the MITM // assertion). - const file = writeProfile( + const file = yield* writeProfile( "endpoints.yml", [ "name: harness", @@ -143,7 +148,7 @@ describe("loadProfile", () => { it.effect("reports unknown keys LOWERCASED, like viper's pre-decode normalization", () => Effect.gen(function* () { - const file = writeProfile( + const file = yield* writeProfile( "bogus-upper.yml", [ "name: harness", @@ -163,7 +168,7 @@ describe("loadProfile", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; expect((yield* loadProfile("SUPABASE-LOCAL", fs)).name).toBe("supabase-local"); - const file = writeProfile( + const file = yield* writeProfile( "named.yml", [ "name: harness", @@ -178,7 +183,7 @@ describe("loadProfile", () => { it.effect("rejects unknown keys with mapstructure's padded UnmarshalExact block", () => Effect.gen(function* () { - const file = writeProfile( + const file = yield* writeProfile( "extra-keys.yml", [ "name: extra", @@ -201,7 +206,7 @@ describe("loadProfile", () => { "reports missing required fields with the validator's padded lines, in struct order", () => Effect.gen(function* () { - const file = writeProfile("incomplete.yml", "name: incomplete\n"); + const file = yield* writeProfile("incomplete.yml", "name: incomplete\n"); const lines = [ "invalid profile: Key: 'Profile.APIURL' Error:Field validation for 'APIURL' failed on the 'required' tag", "Key: 'Profile.DashboardURL' Error:Field validation for 'DashboardURL' failed on the 'required' tag", @@ -214,7 +219,7 @@ describe("loadProfile", () => { it.effect("reports a missing name (only) — required covers empty strings", () => Effect.gen(function* () { - const file = writeProfile( + const file = yield* writeProfile( "noname.yml", [ "api_url: http://127.0.0.1:44444", @@ -232,7 +237,7 @@ describe("loadProfile", () => { "weakly stringifies scalars like viper, so `api_url: 123` fails http_url, not decoding", () => Effect.gen(function* () { - const file = writeProfile( + const file = yield* writeProfile( "typebad.yml", [ "name: t", @@ -249,7 +254,7 @@ describe("loadProfile", () => { it.effect("validates the hostname_rfc1123 and http_url format tags", () => Effect.gen(function* () { - const file = writeProfile( + const file = yield* writeProfile( "badhost.yml", [ "name: t", @@ -269,7 +274,7 @@ describe("loadProfile", () => { it.effect("fails a malformed YAML file closed with viper's parse prefix", () => Effect.gen(function* () { - const file = writeProfile("malformed.yml", "name: [broken\n api_url"); + const file = yield* writeProfile("malformed.yml", "name: [broken\n api_url"); const message = yield* loadError(file); expect(message).toMatch(/^failed to read profile: While parsing config: /); }), @@ -277,7 +282,7 @@ describe("loadProfile", () => { it.effect("fails closed on unconvertible values (array on a string field)", () => Effect.gen(function* () { - const file = writeProfile( + const file = yield* writeProfile( "arrayval.yml", [ "name: t", diff --git a/apps/cli/src/command-internal/project-target.ts b/apps/cli/src/command-internal/project-target.ts index 9f66fdbbae..1dac052d46 100644 --- a/apps/cli/src/command-internal/project-target.ts +++ b/apps/cli/src/command-internal/project-target.ts @@ -155,62 +155,61 @@ function reclassifyBranchNotFoundError( * A UUID branch needs no parent ref, so it works unlinked; a name branch resolves the parent * ref eagerly, before any spinner starts, so an unlinked or stale link fails immediately. */ -export function resolveConfigTarget( +export const resolveConfigTarget = Effect.fn("ProjectTarget.resolve")(function* < + TError, + EResolve extends ConfigTargetResolveFailure, +>( requested: Option.Option, errors: ConfigTargetErrors, /** Maps a branch-lookup (`GET`-by-UUID or `FIND`-by-name) transport/status failure. */ mapResolveError: (cause: SupabaseApiError) => Effect.Effect, ) { - return Effect.gen(function* () { - const output = yield* Output; - const resolver = yield* ProjectRefResolver; + const output = yield* Output; + const resolver = yield* ProjectRefResolver; - let ref: string; - let branch: string | undefined; - if (Option.isSome(requested) && !BRANCH_PROJECT_REF_PATTERN.test(requested.value)) { - const target = requested.value; - branch = target; - const byId = BRANCH_UUID_PATTERN.test(target); - yield* Effect.annotateCurrentSpan("project_target.kind", byId ? "branch_id" : "branch_name"); + let ref: string; + let branch: string | undefined; + if (Option.isSome(requested) && !BRANCH_PROJECT_REF_PATTERN.test(requested.value)) { + const target = requested.value; + branch = target; + const byId = BRANCH_UUID_PATTERN.test(target); + yield* Effect.annotateCurrentSpan("project_target.kind", byId ? "branch_id" : "branch_name"); - let parentRef: ReturnType; - if (byId) { - parentRef = resolveParentScopedProjectRef(Option.none()); - } else { - const parent = yield* resolveLinkedParentRef(); - if (parent.kind === "absent") { - return yield* Effect.fail(errors.notLinked(target)); - } - if (parent.kind === "invalid") { - return yield* Effect.fail(errors.parentRefInvalid(target)); - } - parentRef = Effect.succeed(parent.ref); + let parentRef: ReturnType; + if (byId) { + parentRef = resolveParentScopedProjectRef(Option.none()); + } else { + const parent = yield* resolveLinkedParentRef(); + if (parent.kind === "absent") { + return yield* Effect.fail(errors.notLinked(target)); + } + if (parent.kind === "invalid") { + return yield* Effect.fail(errors.parentRefInvalid(target)); } + parentRef = Effect.succeed(parent.ref); + } - const resolving = - output.format === "text" ? yield* output.task("Resolving branch...") : undefined; - ref = yield* resolveBranchProjectRef(target, parentRef, { - mapGetError: mapResolveError, - mapFindError: mapResolveError, - }).pipe( - Effect.tapError(() => resolving?.fail() ?? Effect.void), - Effect.catch((cause) => - reclassifyBranchNotFoundError(cause, errors.branchNotFound(target)), - ), - ); - yield* resolving?.clear ?? Effect.void; + const resolving = + output.format === "text" ? yield* output.task("Resolving branch...") : undefined; + ref = yield* resolveBranchProjectRef(target, parentRef, { + mapGetError: mapResolveError, + mapFindError: mapResolveError, + }).pipe( + Effect.tapError(() => resolving?.fail() ?? Effect.void), + Effect.catch((cause) => reclassifyBranchNotFoundError(cause, errors.branchNotFound(target))), + ); + yield* resolving?.clear ?? Effect.void; - // The resolved branch might not have a project ref yet (still provisioning); don't - // let an empty ref reach the config-read call. - if (!BRANCH_PROJECT_REF_PATTERN.test(ref)) { - return yield* Effect.fail(errors.branchNotReady(target)); - } - } else { - yield* Effect.annotateCurrentSpan("project_target.kind", "project"); - ref = yield* resolver.resolve(requested); + // The resolved branch might not have a project ref yet (still provisioning); don't + // let an empty ref reach the config-read call. + if (!BRANCH_PROJECT_REF_PATTERN.test(ref)) { + return yield* Effect.fail(errors.branchNotReady(target)); } + } else { + yield* Effect.annotateCurrentSpan("project_target.kind", "project"); + ref = yield* resolver.resolve(requested); + } - yield* Effect.annotateCurrentSpan("project.ref", ref); - return { ref, branch }; - }).pipe(Effect.withSpan("ProjectTarget.resolve")); -} + yield* Effect.annotateCurrentSpan("project.ref", ref); + return { ref, branch }; +}); diff --git a/apps/cli/src/command-internal/tenant-versions.ts b/apps/cli/src/command-internal/tenant-versions.ts index db8dd9a7e7..e5f6b0df78 100644 --- a/apps/cli/src/command-internal/tenant-versions.ts +++ b/apps/cli/src/command-internal/tenant-versions.ts @@ -75,7 +75,7 @@ const fetchJson = (request: HttpClientRequest.HttpClientRequest) => const response = yield* httpClient.execute(request); if (response.status !== 200) return Option.none(); return Option.some(yield* response.json); - }).pipe(Effect.catch(() => Effect.succeed(Option.none()))); + }).pipe(Effect.orElseSucceed(() => Option.none())); const fetchText = (request: HttpClientRequest.HttpClientRequest) => Effect.gen(function* () { @@ -83,7 +83,7 @@ const fetchText = (request: HttpClientRequest.HttpClientRequest) => const response = yield* httpClient.execute(request); if (response.status !== 200) return Option.none(); return Option.some(yield* response.text); - }).pipe(Effect.catch(() => Effect.succeed(Option.none()))); + }).pipe(Effect.orElseSucceed(() => Option.none())); export const fetchPostgrestVersion = ( opts: TenantVersionOptions, diff --git a/apps/cli/src/commands/login/login.integration.test.ts b/apps/cli/src/commands/login/login.integration.test.ts index 33551b35fd..d93c0dd89e 100644 --- a/apps/cli/src/commands/login/login.integration.test.ts +++ b/apps/cli/src/commands/login/login.integration.test.ts @@ -2,6 +2,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Redacted } from "effect"; import * as HttpClient from "effect/http/HttpClient"; +import { TestClock } from "effect/testing"; import { mockAnalytics, @@ -13,6 +14,7 @@ import { } from "../../../tests/helpers/mocks.ts"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { ProfileFlag } from "../../command-internal/global-flags.ts"; +import { loginCryptoLayer } from "../../command-internal/login-crypto.layer.ts"; import { VALID_TOKEN, buildTestRuntime, @@ -26,6 +28,7 @@ import { withEnvVar, } from "../../../tests/helpers/command-mocks.ts"; import { EventLoginCompleted } from "../../shared/telemetry/event-catalog.ts"; +import { LoginCrypto } from "./login-crypto.service.ts"; import { login } from "./login.handler.ts"; import type { LoginFlags } from "./login.command.ts"; @@ -204,6 +207,14 @@ describe("login integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("the default token name ends in unix seconds from the clock", () => + Effect.gen(function* () { + yield* TestClock.setTime(1_700_000_000_500); + const crypto = yield* LoginCrypto; + expect(yield* crypto.defaultTokenName).toMatch(/^cli_(.+@.+_)?1700000000$/); + }).pipe(Effect.provide(loginCryptoLayer)), + ); + it.live("retries verification on poll failure then succeeds", () => { const { layer, out, loginApi } = setupLogin({ isTTY: true, failTimes: 2 }); return Effect.gen(function* () {