import { listOrganizations } from "@distilled.cloud/planetscale";
import * as PsCredentialsModule from "@distilled.cloud/planetscale/Credentials";
import * as Console from "effect/Console";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Match from "effect/Match";
import * as Redacted from "effect/Redacted";
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
import * as HttpClient from "effect/unstable/http/HttpClient";
import {
AuthError,
AuthProviderLayer,
type ConfigureContext,
} from "../Auth/AuthProvider.ts";
import { CredentialsStore, displayRedacted } from "../Auth/Credentials.ts";
import {
getEnvRedactedRequired,
getEnvRequired,
retryOnce,
} from "../Auth/Env.ts";
import * as Clank from "../Util/Clank.ts";
import * as OAuthClient from "./OAuthClient.ts";
/**
* Canonical name registered in {@link AuthProviders}. Use this key to look
* up the PlanetScale {@link AuthProvider} from inside provider Layers.
*/
export const PLANETSCALE_AUTH_PROVIDER_NAME = "Planetscale";
/**
* Provide PlanetScale `Credentials` + `HttpClient` to an Effect using a
* just-obtained OAuth access token. Used during configure to call
* org-discovery endpoints before the user has chosen an org.
*
* `organization` is required by the credential type but isn't consulted by
* `listOrganizations` (it's a user-scoped endpoint), so an empty string is
* fine here.
*/
const withOAuthCredentials = (
accessToken: string,
effect: Effect.Effect<
A,
E,
PsCredentialsModule.Credentials | HttpClient.HttpClient
>,
): Effect.Effect =>
Effect.provide(
effect,
Layer.mergeAll(
PsCredentialsModule.fromOAuth({
accessToken,
organization: "",
}),
FetchHttpClient.layer,
),
);
/**
* List the organizations the OAuth user belongs to and either auto-pick
* (one org) or prompt the user to choose. Returns the org's URL slug
* (`name` field, used as `{organization}` in API paths).
*/
const selectOrganization = (accessToken: string) =>
Effect.gen(function* () {
const list = yield* listOrganizations;
const response = yield* list({});
const orgs = response.data;
if (orgs.length === 0) {
return yield* new AuthError({
message: "Planetscale: no organizations found for this credential.",
});
}
if (orgs.length === 1) {
const org = orgs[0]!;
yield* Clank.info(
`Planetscale: using organization: ${org.name} (${org.id})`,
);
return org.name;
}
return yield* Clank.select({
message: "Select a Planetscale organization",
options: orgs.map((o) => ({
value: o.name,
label: o.name,
hint: o.id,
})),
}).pipe(retryOnce);
}).pipe((e) => withOAuthCredentials(accessToken, e));
const options: Array<{
value: PlanetscaleAuthConfig["method"];
label: string;
hint?: string;
}> = [
{
value: "env",
label: "Environment Variables",
hint: "PLANETSCALE_API_TOKEN_ID + PLANETSCALE_API_TOKEN + PLANETSCALE_ORGANIZATION",
},
{
value: "oauth",
label: "OAuth",
hint: "recommended — browser-based login with automatic token refresh",
},
{
value: "stored",
label: "Service Token",
hint: "enter service token interactively, stored in ~/.alchemy/credentials",
},
];
/**
* Auth configuration persisted in `~/.alchemy/profiles.json` for the
* PlanetScale provider.
*
* - `env`: read credentials from environment variables at resolution time.
* - `stored`: read service-token credentials from
* `~/.alchemy/credentials//planetscale-stored.json`.
* - `oauth`: browser-based login; the access/refresh tokens are stored at
* `~/.alchemy/credentials//planetscale-oauth.json` and refreshed
* on demand. PlanetScale has no PKCE flow, so the OAuth application's
* `client_secret` ships in the CLI — see {@link OAuthClient}.
*/
export type PlanetscaleAuthConfig =
| { method: "env" }
| { method: "stored" }
| { method: "oauth"; organization: string };
/**
* apiToken credentials persisted to disk for `method: "stored"`.
* Stored under the file key `"planetscale-stored"`.
*/
export interface PlanetscaleStoredCredentials {
type: "apiToken";
tokenId: string;
token: string;
organization: string;
}
/**
* Resolved in-memory PlanetScale credentials returned by
* {@link AuthProviderImpl.read}. Either a service token (`tokenId`/`token`)
* or an OAuth access token.
*/
export type PlanetscaleResolvedCredentials =
| {
type: "apiToken";
tokenId: Redacted.Redacted;
token: Redacted.Redacted;
organization: string;
source: {
type: PlanetscaleAuthConfig["method"];
details?: string;
};
}
| {
type: "oauth";
accessToken: Redacted.Redacted;
expires: number;
organization: string;
source: {
type: PlanetscaleAuthConfig["method"];
details?: string;
};
};
/**
* Layer that registers the PlanetScale {@link AuthProvider} into the
* {@link AuthProviders} registry when built. Include this in the
* PlanetScale `providers()` layer so `alchemy login` can discover it.
*
* Supported methods:
* - `env`: reads `PLANETSCALE_API_TOKEN_ID`/`PLANETSCALE_API_TOKEN`/`PLANETSCALE_ORGANIZATION`.
* - `stored`: prompts for a service token interactively and writes it to
* `~/.alchemy/credentials//planetscale-stored.json`.
* - `oauth`: browser-based login storing access/refresh tokens at
* `~/.alchemy/credentials//planetscale-oauth.json`.
*/
export const PlanetscaleAuth = AuthProviderLayer<
PlanetscaleAuthConfig,
PlanetscaleResolvedCredentials
>()(
PLANETSCALE_AUTH_PROVIDER_NAME,
Effect.gen(function* () {
const store = yield* CredentialsStore;
const oauthLogin = (profileName: string) =>
Effect.gen(function* () {
const authorization = OAuthClient.authorize();
yield* Clank.info("Planetscale: opening browser for OAuth login...");
yield* Clank.info(authorization.url);
yield* Clank.openUrl(authorization.url).pipe(
Effect.catch(() =>
Clank.warn(
"Planetscale: could not open browser automatically. Please open the URL above manually.",
),
),
);
yield* Clank.info(
"Planetscale: waiting for authorization (up to 5 minutes).",
);
const credentials = yield* Effect.raceFirst(
OAuthClient.callback(authorization),
Clank.text({
message: "Paste the authorization code or callback URL",
placeholder:
"The browser will complete this automatically when local",
validate: (value) =>
value.trim().length > 0 ? undefined : "Paste a code or URL",
}).pipe(
Effect.flatMap((input) =>
OAuthClient.exchangeCallbackInput(input, authorization),
),
),
);
yield* store.write(profileName, "planetscale-oauth", credentials);
yield* Clank.success("Planetscale: OAuth credentials saved.");
return credentials;
});
const configureOAuth = Effect.fn(function* (profileName: string) {
const oauthCreds = yield* oauthLogin(profileName);
// Use the just-issued access token to list the user's orgs and let
// them pick (mirrors Cloudflare's selectAccount). Requires the
// `user:read_organizations` scope. If the call fails for any
// reason — missing scope, network, off-spec response — fall back
// to a manual prompt so login still completes.
const organization = yield* selectOrganization(oauthCreds.access).pipe(
Effect.catch((e) =>
Effect.gen(function* () {
yield* Clank.warn(
`Planetscale: could not auto-list organizations (${String(e)}). Falling back to manual entry.`,
);
return yield* Clank.text({
message: "Planetscale Organization (URL slug)",
validate: (v) => (v.length === 0 ? "Required" : undefined),
}).pipe(retryOnce);
}),
),
);
return { method: "oauth" as const, organization };
});
const loginStored = Effect.fn(function* (profileName: string) {
const tokenId = yield* Clank.text({
message: "Planetscale Service Token ID",
validate: (v) => (v.length === 0 ? "Required" : undefined),
}).pipe(retryOnce);
const token = yield* Clank.password({
message: "Planetscale Service Token",
validate: (v) => (v.length === 0 ? "Required" : undefined),
}).pipe(retryOnce);
const organization = yield* Clank.text({
message: "Planetscale Organization (URL slug)",
validate: (v) => (v.length === 0 ? "Required" : undefined),
}).pipe(retryOnce);
yield* store.write(
profileName,
"planetscale-stored",
{
type: "apiToken",
tokenId,
token,
organization,
},
);
yield* Clank.success("Planetscale: credentials saved.");
return { method: "stored" as const };
});
const configureInteractive = (profileName: string) =>
Clank.select({
message: "Planetscale authentication method",
options,
}).pipe(
Effect.flatMap((method) =>
Match.value(method).pipe(
Match.when("env", () => Effect.succeed({ method: "env" as const })),
Match.when("oauth", () => configureOAuth(profileName)),
Match.when("stored", () => loginStored(profileName)),
Match.exhaustive,
),
),
);
const configureCredentials = (profileName: string, ctx: ConfigureContext) =>
Effect.gen(function* () {
if (ctx.ci) {
return { method: "env" as const };
}
return yield* configureInteractive(profileName);
}).pipe(
Effect.mapError(
(e) =>
new AuthError({
message: "failed to configure credentials",
cause: e,
}),
),
);
const resolveCredentials = (
profileName: string,
config: PlanetscaleAuthConfig,
): Effect.Effect =>
Match.value(config).pipe(
Match.when(
{ method: "env" },
Effect.fn(function* () {
const tokenId = yield* getEnvRedactedRequired(
"PLANETSCALE_API_TOKEN_ID",
);
const token = yield* getEnvRedactedRequired(
"PLANETSCALE_API_TOKEN",
);
const organization = yield* getEnvRequired(
"PLANETSCALE_ORGANIZATION",
);
return {
type: "apiToken" as const,
tokenId,
token,
organization,
source: {
type: "env" as const,
details: "PLANETSCALE_API_TOKEN_ID/PLANETSCALE_API_TOKEN",
},
} satisfies PlanetscaleResolvedCredentials;
}),
),
Match.when({ method: "stored" }, () =>
store
.read(
profileName,
"planetscale-stored",
)
.pipe(
Effect.flatMap((creds) =>
creds == null
? Effect.fail(
new AuthError({
message:
"Planetscale stored credentials not found. Run: alchemy login --configure",
}),
)
: Effect.succeed({
type: "apiToken" as const,
tokenId: Redacted.make(creds.tokenId),
token: Redacted.make(creds.token),
organization: creds.organization,
source: {
type: "stored" as const,
details: undefined,
},
} satisfies PlanetscaleResolvedCredentials),
),
),
),
Match.when({ method: "oauth" }, (cfg) =>
Effect.gen(function* () {
const creds = yield* store.read(
profileName,
"planetscale-oauth",
);
if (creds == null || creds.type !== "oauth") {
return yield* Effect.fail(
new AuthError({
message:
"Planetscale OAuth credentials not found. Run: alchemy login",
}),
);
}
// Refresh proactively if the token has expired (or is within
// 10s of expiring). Persist the refreshed creds so subsequent
// resolves don't repeat the round-trip.
const fresh =
creds.expires > Date.now() + 10_000
? creds
: yield* OAuthClient.refresh(creds).pipe(
Effect.tap((refreshed) =>
store.write(profileName, "planetscale-oauth", refreshed),
),
Effect.mapError(
(e) =>
new AuthError({
message:
"Planetscale OAuth refresh failed. Run: alchemy login",
cause: e,
}),
),
);
return {
type: "oauth" as const,
accessToken: Redacted.make(fresh.access),
expires: fresh.expires,
organization: cfg.organization,
source: { type: "oauth" as const },
} satisfies PlanetscaleResolvedCredentials;
}),
),
Match.exhaustive,
);
const logout = (profileName: string, config: PlanetscaleAuthConfig) =>
Match.value(config).pipe(
Match.when({ method: "env" }, () => Effect.void),
Match.when({ method: "stored" }, () =>
store
.delete(profileName, "planetscale-stored")
.pipe(
Effect.andThen(
Clank.success("Planetscale: stored credentials removed"),
),
),
),
// PlanetScale publishes no token-revocation endpoint, so logout just
// drops the locally stored tokens.
Match.when({ method: "oauth" }, () =>
store
.delete(profileName, "planetscale-oauth")
.pipe(
Effect.andThen(
Clank.success("Planetscale: OAuth credentials removed."),
),
),
),
Match.exhaustive,
);
const login = (profileName: string, config: PlanetscaleAuthConfig) =>
Match.value(config)
.pipe(
Match.when({ method: "env" }, () => Effect.void),
Match.when({ method: "stored" }, () =>
store
.read(
profileName,
"planetscale-stored",
)
.pipe(
Effect.flatMap((creds) =>
creds == null ? loginStored(profileName) : Effect.void,
),
),
),
Match.when({ method: "oauth" }, (c) =>
Effect.gen(function* () {
const creds = yield* store.read(
profileName,
"planetscale-oauth",
);
if (creds?.type === "oauth") {
yield* Clank.info(
"Planetscale: refreshing OAuth credentials...",
);
yield* OAuthClient.refresh(creds).pipe(
Effect.flatMap((refreshed) =>
store
.write(profileName, "planetscale-oauth", refreshed)
.pipe(
Effect.andThen(
Clank.success(
"Planetscale: OAuth credentials refreshed.",
),
),
),
),
Effect.catchTag("OAuthError", () =>
oauthLogin(profileName).pipe(Effect.asVoid),
),
);
return;
}
yield* oauthLogin(profileName);
}),
),
Match.exhaustive,
)
.pipe(
Effect.mapError(
(e) => new AuthError({ message: "login failed", cause: e }),
),
);
const prettyPrint = (profileName: string, config: PlanetscaleAuthConfig) =>
resolveCredentials(profileName, config).pipe(
Effect.tap((creds) => {
const sourceStr = creds.source.details
? `${creds.source.type} - ${creds.source.details}`
: creds.source.type;
return Match.value(creds).pipe(
Match.when({ type: "apiToken" }, (c) =>
Effect.all([
Console.log(` tokenId: ${displayRedacted(c.tokenId, 3)}`),
Console.log(` token: ${displayRedacted(c.token, 6)}`),
Console.log(` organization: ${c.organization}`),
Console.log(` source: ${sourceStr}`),
]),
),
Match.when({ type: "oauth" }, (c) => {
const remainingMs = c.expires - Date.now();
const expiresAt = new Date(c.expires).toISOString();
const expiresStr =
remainingMs <= 0
? `expired (${expiresAt})`
: `in ${Duration.format(Duration.millis(remainingMs))} (${expiresAt})`;
return Effect.all([
Console.log(` accessToken: ${displayRedacted(c.accessToken)}`),
Console.log(` expires: ${expiresStr}`),
Console.log(` organization: ${c.organization}`),
Console.log(` source: ${sourceStr}`),
]);
}),
Match.exhaustive,
);
}),
);
return {
configure: configureCredentials,
logout,
login,
prettyPrint,
read: resolveCredentials,
};
}),
);