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
204 changes: 102 additions & 102 deletions apps/cli/docs/go-cli-porting-status.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@
"react": "^19.2.6",
"react-devtools-core": "^7.0.1",
"semantic-release": "^24.2.9",
"vitest": "catalog:"
"smol-toml": "^1.6.1",
"vitest": "catalog:",
"yaml": "^2.9.0"
},
"optionalDependencies": {
"@supabase/cli-darwin-arm64": "workspace:*",
Expand Down
155 changes: 155 additions & 0 deletions apps/cli/src/legacy/auth/legacy-credentials.layer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { Effect, FileSystem, Layer, Option, Path, Redacted } from "effect";

import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts";
import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts";
import { LegacyCredentials } from "./legacy-credentials.service.ts";
import { LegacyInvalidAccessTokenError } from "./legacy-errors.ts";

const KEYRING_SERVICE = "Supabase CLI";
const LEGACY_KEYRING_ACCOUNT = "access-token";
const WSL_OSRELEASE_PATH = "/proc/sys/kernel/osrelease";

const ACCESS_TOKEN_PATTERN = /^sbp_(oauth_)?[a-f0-9]{40}$/;

const INVALID_TOKEN_MESSAGE = "Invalid access token format. Must be like `sbp_0102...1920`.";

type KeyringModule = typeof import("@napi-rs/keyring");

const detectWsl = (fs: FileSystem.FileSystem): Effect.Effect<boolean> =>
Effect.gen(function* () {
const exists = yield* fs.exists(WSL_OSRELEASE_PATH).pipe(Effect.orElseSucceed(() => false));
if (!exists) return false;
const content = yield* fs
.readFileString(WSL_OSRELEASE_PATH)
.pipe(Effect.orElseSucceed(() => ""));
return content.includes("WSL") || content.includes("Microsoft");
});

const tryKeyringRead = (
module: KeyringModule,
account: string,
): Effect.Effect<Option.Option<string>> =>
Effect.try({
try: () => {
const entry = new module.Entry(KEYRING_SERVICE, account);
const value = entry.getPassword();
return value && value.length > 0 ? Option.some(value) : Option.none<string>();
},
catch: () => Option.none<string>(),
}).pipe(Effect.orElseSucceed(() => Option.none<string>()));

const tryKeyringWrite = (
module: KeyringModule,
account: string,
token: string,
): Effect.Effect<boolean> =>
Effect.try({
try: () => {
const entry = new module.Entry(KEYRING_SERVICE, account);
entry.setPassword(token);
return true;
},
catch: () => false,
}).pipe(Effect.orElseSucceed(() => false));

const tryKeyringDelete = (module: KeyringModule, account: string): Effect.Effect<boolean> =>
Effect.try({
try: () => {
const entry = new module.Entry(KEYRING_SERVICE, account);
const value = entry.getPassword();
if (!value) return false;
entry.deleteCredential();
return true;
},
catch: () => false,
}).pipe(Effect.orElseSucceed(() => false));

const makeLegacyCredentials = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const runtimeInfo = yield* RuntimeInfo;
const cliConfig = yield* LegacyCliConfig;
const profileAccount = cliConfig.profile;

// ~/.supabase/access-token — fallback file path
const fallbackDir = path.join(runtimeInfo.homeDir, ".supabase");
const fallbackPath = path.join(fallbackDir, "access-token");

const wsl = yield* detectWsl(fs);
const keyringModule = wsl
? Option.none<KeyringModule>()
: yield* Effect.tryPromise(() => import("@napi-rs/keyring")).pipe(Effect.option);

const validate = (token: string): Effect.Effect<string, LegacyInvalidAccessTokenError> =>
ACCESS_TOKEN_PATTERN.test(token)
? Effect.succeed(token)
: Effect.fail(new LegacyInvalidAccessTokenError({ message: INVALID_TOKEN_MESSAGE }));

const readKeyring = Effect.gen(function* () {
if (Option.isNone(keyringModule)) return Option.none<string>();
const profileResult = yield* tryKeyringRead(keyringModule.value, profileAccount);
if (Option.isSome(profileResult)) return profileResult;
return yield* tryKeyringRead(keyringModule.value, LEGACY_KEYRING_ACCOUNT);
});

const readFile = Effect.gen(function* () {
const exists = yield* fs.exists(fallbackPath).pipe(Effect.orElseSucceed(() => false));
if (!exists) return Option.none<string>();
const content = yield* fs.readFileString(fallbackPath).pipe(Effect.orElseSucceed(() => ""));
const trimmed = content.trim();
return trimmed.length === 0 ? Option.none<string>() : Option.some(trimmed);
});

return LegacyCredentials.of({
getAccessToken: Effect.gen(function* () {
// Env takes precedence (matches access_token.go:38).
if (Option.isSome(cliConfig.accessToken)) {
yield* validate(Redacted.value(cliConfig.accessToken.value));
return Option.some(cliConfig.accessToken.value);
}

// Keyring (profile key, then legacy key). Skipped on WSL.
const keyringValue = yield* readKeyring;
if (Option.isSome(keyringValue)) {
yield* validate(keyringValue.value);
return Option.some(Redacted.make(keyringValue.value));
}

// Filesystem fallback at ~/.supabase/access-token.
const fileValue = yield* readFile;
if (Option.isSome(fileValue)) {
yield* validate(fileValue.value);
return Option.some(Redacted.make(fileValue.value));
}

return Option.none();
}),

saveAccessToken: (token: string) =>
Effect.gen(function* () {
yield* validate(token);
if (Option.isSome(keyringModule)) {
const ok = yield* tryKeyringWrite(keyringModule.value, profileAccount, token);
if (ok) return;
}
yield* fs.makeDirectory(fallbackDir, { recursive: true, mode: 0o700 }).pipe(Effect.orDie);
yield* fs.writeFileString(fallbackPath, token, { mode: 0o600 }).pipe(Effect.orDie);
}),

deleteAccessToken: Effect.gen(function* () {
let anyDeleted = false;
if (Option.isSome(keyringModule)) {
if (yield* tryKeyringDelete(keyringModule.value, profileAccount)) anyDeleted = true;
if (yield* tryKeyringDelete(keyringModule.value, LEGACY_KEYRING_ACCOUNT)) anyDeleted = true;
}
const exists = yield* fs.exists(fallbackPath).pipe(Effect.orElseSucceed(() => false));
if (exists) {
yield* fs.remove(fallbackPath).pipe(Effect.orDie);
anyDeleted = true;
}
return anyDeleted;
}),
});
});

export const legacyCredentialsLayer = Layer.effect(LegacyCredentials, makeLegacyCredentials);
230 changes: 230 additions & 0 deletions apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { describe, expect, it } from "@effect/vitest";
import { BunServices } from "@effect/platform-bun";
import { Effect, Layer, Option, Redacted } from "effect";
import { afterEach, beforeEach, vi } from "vitest";

import { LegacyProfileFlag, LegacyWorkdirFlag } from "../../shared/legacy/global-flags.ts";
import { mockRuntimeInfo, processEnvLayer } from "../../../tests/helpers/mocks.ts";
import { legacyCliConfigLayer } from "../config/legacy-cli-config.layer.ts";
import { legacyCredentialsLayer } from "./legacy-credentials.layer.ts";
import { LegacyCredentials } from "./legacy-credentials.service.ts";
import { LegacyInvalidAccessTokenError } from "./legacy-errors.ts";

// ---------------------------------------------------------------------------
// Keyring mock
// ---------------------------------------------------------------------------

const passwords = new Map<string, string>();
let throwOnSetPassword = false;
const throwOnGetPasswordAccounts = new Set<string>();

vi.mock("@napi-rs/keyring", () => ({
Entry: class Entry {
service: string;
account: string;
constructor(service: string, account: string) {
this.service = service;
this.account = account;
}
getPassword(): string | null {
const key = `${this.service}/${this.account}`;
if (throwOnGetPasswordAccounts.has(key)) {
throw new Error("Keyring unavailable");
}
return passwords.get(key) ?? null;
}
setPassword(value: string): void {
if (throwOnSetPassword) throw new Error("Keyring unavailable");
passwords.set(`${this.service}/${this.account}`, value);
}
deleteCredential(): boolean {
const key = `${this.service}/${this.account}`;
if (!passwords.has(key)) throw new Error("not found");
passwords.delete(key);
return true;
}
},
}));

// ---------------------------------------------------------------------------
// Layer wiring
// ---------------------------------------------------------------------------

let tempHome: string;

function makeLayer(opts: { env?: Record<string, string | undefined>; home?: string } = {}) {
const home = opts.home ?? tempHome;
const env = { HOME: home, ...opts.env };
const runtimeInfoLayer = mockRuntimeInfo({ homeDir: home, cwd: home });
const cliConfigLayer = legacyCliConfigLayer.pipe(
Layer.provide(Layer.succeed(LegacyProfileFlag, "supabase")),
Layer.provide(Layer.succeed(LegacyWorkdirFlag, Option.none<string>())),
Layer.provide(runtimeInfoLayer),
Layer.provide(BunServices.layer),
Layer.provide(processEnvLayer(env)),
);
return legacyCredentialsLayer.pipe(
Layer.provide(cliConfigLayer),
Layer.provide(runtimeInfoLayer),
Layer.provide(BunServices.layer),
Layer.provide(processEnvLayer(env)),
);
}

beforeEach(() => {
passwords.clear();
throwOnSetPassword = false;
throwOnGetPasswordAccounts.clear();
tempHome = mkdtempSync(join(tmpdir(), "supabase-legacy-creds-"));
});

afterEach(() => {
rmSync(tempHome, { recursive: true, force: true });
});

const VALID_TOKEN = "sbp_" + "a".repeat(40);
const VALID_OAUTH_TOKEN = "sbp_oauth_" + "b".repeat(40);

const expectSomeToken = (token: Option.Option<Redacted.Redacted<string>>, expected: string) => {
expect(Option.isSome(token)).toBe(true);
if (Option.isSome(token)) {
expect(Redacted.value(token.value)).toBe(expected);
}
};

describe("legacyCredentialsLayer.getAccessToken", () => {
it.effect("returns the SUPABASE_ACCESS_TOKEN env value (highest precedence)", () => {
passwords.set("Supabase CLI/supabase", "sbp_" + "9".repeat(40));
return Effect.gen(function* () {
const { getAccessToken } = yield* LegacyCredentials;
const token = yield* getAccessToken;
expectSomeToken(token, VALID_TOKEN);
}).pipe(Effect.provide(makeLayer({ env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN } })));
});

it.effect("uses the keyring profile account when env is unset", () => {
passwords.set("Supabase CLI/supabase", VALID_TOKEN);
return Effect.gen(function* () {
const { getAccessToken } = yield* LegacyCredentials;
const token = yield* getAccessToken;
expectSomeToken(token, VALID_TOKEN);
}).pipe(Effect.provide(makeLayer()));
});

it.effect("falls through to the legacy access-token keyring entry", () => {
passwords.set("Supabase CLI/access-token", VALID_OAUTH_TOKEN);
return Effect.gen(function* () {
const { getAccessToken } = yield* LegacyCredentials;
const token = yield* getAccessToken;
expectSomeToken(token, VALID_OAUTH_TOKEN);
}).pipe(Effect.provide(makeLayer()));
});

it.effect("falls back to ~/.supabase/access-token when keyring entries miss", () => {
const supaDir = join(tempHome, ".supabase");
mkdirSync(supaDir, { recursive: true });
writeFileSync(join(supaDir, "access-token"), `${VALID_TOKEN}\n`, { mode: 0o600 });
return Effect.gen(function* () {
const { getAccessToken } = yield* LegacyCredentials;
const token = yield* getAccessToken;
expectSomeToken(token, VALID_TOKEN);
}).pipe(Effect.provide(makeLayer()));
});

it.effect("returns None when no source provides a token", () =>
Effect.gen(function* () {
const { getAccessToken } = yield* LegacyCredentials;
const token = yield* getAccessToken;
expect(token).toEqual(Option.none());
}).pipe(Effect.provide(makeLayer())),
);

it.effect("fails with LegacyInvalidAccessTokenError when token format is invalid", () => {
passwords.set("Supabase CLI/supabase", "not-a-valid-token");
return Effect.gen(function* () {
const { getAccessToken } = yield* LegacyCredentials;
const exit = yield* Effect.exit(getAccessToken);
expect(exit._tag).toBe("Failure");
if (exit._tag === "Failure") {
const errorJson = JSON.stringify(exit.cause);
expect(errorJson).toContain("LegacyInvalidAccessTokenError");
expect(errorJson).toContain("Invalid access token format");
}
}).pipe(Effect.provide(makeLayer()));
});

it.effect("falls back to the filesystem when keyring throws", () => {
throwOnGetPasswordAccounts.add("Supabase CLI/supabase");
throwOnGetPasswordAccounts.add("Supabase CLI/access-token");
const supaDir = join(tempHome, ".supabase");
mkdirSync(supaDir, { recursive: true });
writeFileSync(join(supaDir, "access-token"), VALID_TOKEN, { mode: 0o600 });
return Effect.gen(function* () {
const { getAccessToken } = yield* LegacyCredentials;
const token = yield* getAccessToken;
expectSomeToken(token, VALID_TOKEN);
}).pipe(Effect.provide(makeLayer()));
});
});

describe("legacyCredentialsLayer.saveAccessToken", () => {
it.effect("rejects invalid token formats up front", () =>
Effect.gen(function* () {
const { saveAccessToken } = yield* LegacyCredentials;
const exit = yield* Effect.exit(saveAccessToken("nope"));
expect(exit._tag).toBe("Failure");
if (exit._tag === "Failure") {
expect(JSON.stringify(exit.cause)).toContain("LegacyInvalidAccessTokenError");
}
}).pipe(Effect.provide(makeLayer())),
);

it.effect("writes to the keyring profile entry when available", () =>
Effect.gen(function* () {
const { saveAccessToken } = yield* LegacyCredentials;
yield* saveAccessToken(VALID_TOKEN);
expect(passwords.get("Supabase CLI/supabase")).toBe(VALID_TOKEN);
}).pipe(Effect.provide(makeLayer())),
);

it.effect("falls back to the filesystem when the keyring write throws", () => {
throwOnSetPassword = true;
return Effect.gen(function* () {
const { saveAccessToken } = yield* LegacyCredentials;
yield* saveAccessToken(VALID_TOKEN);
const content = readFileSync(join(tempHome, ".supabase", "access-token"), "utf-8");
expect(content).toBe(VALID_TOKEN);
}).pipe(Effect.provide(makeLayer()));
});
});

describe("legacyCredentialsLayer.deleteAccessToken", () => {
it.effect("returns false when no token is stored anywhere", () =>
Effect.gen(function* () {
const { deleteAccessToken } = yield* LegacyCredentials;
expect(yield* deleteAccessToken).toBe(false);
}).pipe(Effect.provide(makeLayer())),
);

it.effect("removes both keyring entries plus the filesystem file", () => {
passwords.set("Supabase CLI/supabase", VALID_TOKEN);
passwords.set("Supabase CLI/access-token", VALID_OAUTH_TOKEN);
const supaDir = join(tempHome, ".supabase");
mkdirSync(supaDir, { recursive: true });
writeFileSync(join(supaDir, "access-token"), VALID_TOKEN, { mode: 0o600 });
return Effect.gen(function* () {
const { deleteAccessToken } = yield* LegacyCredentials;
expect(yield* deleteAccessToken).toBe(true);
expect(passwords.has("Supabase CLI/supabase")).toBe(false);
expect(passwords.has("Supabase CLI/access-token")).toBe(false);
expect(existsSync(join(supaDir, "access-token"))).toBe(false);
}).pipe(Effect.provide(makeLayer()));
});
});

// Suppress unused-import nag — referenced in JSDoc.
void LegacyInvalidAccessTokenError;
Loading
Loading