1import { listOrganizations } from "@distilled.cloud/planetscale";
2import * as PsCredentialsModule from "@distilled.cloud/planetscale/Credentials";
3import * as Clock from "effect/Clock";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Match from "effect/Match";
8import * as Redacted from "effect/Redacted";
9import * as Schema from "effect/Schema";
10import * as FetchHttpClient from "effect/http/FetchHttpClient";
11import * as HttpClient from "effect/http/HttpClient";
12import {
13 AuthError,
14 AuthProviderLayer,
15 refreshHint,
16 type ConfigureField,
17 type ConfigureMethod,
18 NeedsReauth,
19 type ProviderDetails,
20} from "../Auth/AuthProvider.ts";
21import {
22 storedSecret,
23 storedValueText,
24 validateFieldValues,
25} from "../Auth/StoredAuthProvider.ts";
26import { displayRedacted } from "../Auth/Credentials.ts";
27import { withProfileCredentialsLock } from "../Auth/Lock.ts";
28import {
29 getEnvRedactedRequired,
30 getEnvRequired,
31 mapPromptCancellation,
32} from "../Auth/Env.ts";
33import { browserOAuth } from "../Auth/BrowserOAuth.ts";
34import * as Interaction from "../Interaction.ts";
35import * as OAuthClient from "./OAuthClient.ts";
36
37/**
38 * Canonical name registered in {@link AuthProviders}. Use this key to look
39 * up the PlanetScale {@link AuthProvider} from inside provider Layers.
40 */
41export const PLANETSCALE_AUTH_PROVIDER_NAME = "Planetscale";
42
43/**
44 * Provide PlanetScale `Credentials` + `HttpClient` to an Effect using a
45 * just-obtained OAuth access token. Used during configure to call
46 * org-discovery endpoints before the user has chosen an org.
47 *
48 * `organization` is required by the credential type but isn't consulted by
49 * `listOrganizations` (it's a user-scoped endpoint), so an empty string is
50 * fine here.
51 */
52const withOAuthCredentials = <A, E, R>(
53 accessToken: string,
54 effect: Effect.Effect<
55 A,
56 E,
57 R | PsCredentialsModule.Credentials | HttpClient.HttpClient
58 >,
59): Effect.Effect<A, E, R> =>
60 Effect.provide(
61 effect,
62 Layer.mergeAll(
63 PsCredentialsModule.fromOAuth({
64 accessToken,
65 organization: "",
66 }),
67 FetchHttpClient.layer,
68 ),
69 );
70
71/**
72 * List the organizations the OAuth user belongs to and either auto-pick
73 * (one org) or prompt the user to choose. Returns the org's URL slug
74 * (`name` field, used as `{organization}` in API paths).
75 */
76const selectOrganization = (accessToken: string) =>
77 Effect.gen(function* () {
78 const interaction = Interaction.accessors;
79 const list = yield* listOrganizations;
80 const response = yield* list({});
81 const orgs = response.data;
82 if (orgs.length === 0) {
83 return yield* new AuthError({
84 message: "Planetscale: no organizations found for this credential.",
85 });
86 }
87 if (orgs.length === 1) {
88 const org = orgs[0];
89 if (org === undefined) {
90 return yield* new AuthError({
91 message: "Planetscale: organization response was unexpectedly empty.",
92 });
93 }
94 yield* interaction.output.info(
95 `Planetscale: using organization: ${org.name} (${org.id})`,
96 );
97 return org.name;
98 }
99 return yield* interaction.prompt
100 .select({
101 message: "Select a Planetscale organization",
102 options: orgs.map((o) => ({
103 value: o.name,
104 label: o.name,
105 description: o.id,
106 })),
107 })
108 .pipe(mapPromptCancellation);
109 }).pipe((e) => withOAuthCredentials(accessToken, e));
110
111const options: Array<{
112 value: PlanetscaleAuthConfig["method"];
113 label: string;
114 description?: string;
115}> = [
116 {
117 value: "oauth",
118 label: "OAuth",
119 description:
120 "recommended — browser-based login with automatic token refresh",
121 },
122 {
123 value: "stored",
124 label: "Service Token",
125 description:
126 "enter a service token and store it inline in the provider file",
127 },
128];
129
130/**
131 * Typed values stored in a PlanetScale provider profile document. Both
132 * service-token and OAuth values are inline. PlanetScale has no PKCE flow,
133 * so the OAuth application's
134 * `client_secret` ships in the CLI — see {@link OAuthClient}.
135 */
136export const PlanetscaleAuthConfigSchema = Schema.Union([
137 Schema.Struct({
138 method: Schema.Literal("stored"),
139 tokenId: Schema.String,
140 token: Schema.String,
141 organization: Schema.String,
142 }),
143 Schema.Struct({
144 method: Schema.Literal("oauth"),
145 organization: Schema.String,
146 clientId: Schema.optional(Schema.String),
147 access: Schema.String,
148 refresh: Schema.String,
149 expires: Schema.Number,
150 scopes: Schema.mutable(Schema.Array(Schema.String)),
151 }),
152]);
153export type PlanetscaleAuthConfig = typeof PlanetscaleAuthConfigSchema.Type;
154
155/**
156 * Resolved in-memory PlanetScale credentials returned by
157 * {@link AuthProviderImpl.read}. Either a service token (`tokenId`/`token`)
158 * or an OAuth access token.
159 */
160export type PlanetscaleResolvedCredentials =
161 | {
162 type: "apiToken";
163 tokenId: Redacted.Redacted<string>;
164 token: Redacted.Redacted<string>;
165 organization: string;
166 source: {
167 type: PlanetscaleAuthConfig["method"] | "env";
168 details?: string;
169 };
170 }
171 | {
172 type: "oauth";
173 accessToken: Redacted.Redacted<string>;
174 expires: number;
175 organization: string;
176 source: {
177 type: PlanetscaleAuthConfig["method"] | "env";
178 details?: string;
179 };
180 };
181
182/**
183 * Layer that registers the PlanetScale {@link AuthProvider} into the
184 * {@link AuthProviders} registry when built. Include this in the
185 * PlanetScale `providers()` layer so the alchemy CLI can discover it.
186 *
187 * Supported methods:
188 * - `stored`: prompts for a service token and stores it inline.
189 * - `oauth`: browser-based login storing access/refresh values inline.
190 */
191export const PlanetscaleAuth = AuthProviderLayer<
192 PlanetscaleAuthConfig,
193 PlanetscaleResolvedCredentials
194>()(
195 PLANETSCALE_AUTH_PROVIDER_NAME,
196 Effect.gen(function* () {
197 const interaction = Interaction.accessors;
198 const oauthLogin = (_profileName: string) =>
199 Effect.gen(function* () {
200 const authorization = yield* OAuthClient.authorize();
201
202 const credentials = yield* browserOAuth({
203 provider: "Planetscale",
204 url: authorization.url,
205 callback: OAuthClient.callback(authorization),
206 exchange: (input) =>
207 OAuthClient.exchangeCallbackInput(input, authorization),
208 });
209 yield* interaction.output.success(
210 "Planetscale: OAuth credentials saved.",
211 );
212 return credentials;
213 });
214
215 const configureOAuth = Effect.fn(function* (profileName: string) {
216 const oauthCreds = yield* oauthLogin(profileName);
217
218 // Use the just-issued access token to list the user's orgs and let
219 // them pick (mirrors Cloudflare's selectAccount). Requires the
220 // `user:read_organizations` scope. If the call fails for any
221 // reason — missing scope, network, off-spec response — fall back
222 // to a manual prompt so login still completes.
223 const organization = yield* selectOrganization(
224 Redacted.value(oauthCreds.access),
225 ).pipe(
226 Effect.catch((e) =>
227 Effect.gen(function* () {
228 yield* interaction.output.warning(
229 `Planetscale: could not auto-list organizations (${String(e)}). Falling back to manual entry.`,
230 );
231 return yield* interaction.prompt
232 .text({
233 message: "Planetscale Organization (URL slug)",
234 validate: (v) => (v.length === 0 ? "Required" : undefined),
235 })
236 .pipe(mapPromptCancellation);
237 }),
238 ),
239 );
240
241 return {
242 method: "oauth" as const,
243 organization,
244 clientId: oauthCreds.clientId,
245 access: Redacted.value(oauthCreds.access),
246 refresh: Redacted.value(oauthCreds.refresh),
247 expires: oauthCreds.expires,
248 scopes: oauthCreds.scopes,
249 };
250 });
251
252 const loginStored = Effect.fn(function* (_profileName: string) {
253 const tokenId = yield* interaction.prompt
254 .text({
255 message: "Planetscale Service Token ID",
256 validate: (v) => (v.length === 0 ? "Required" : undefined),
257 })
258 .pipe(mapPromptCancellation);
259
260 const token = yield* interaction.prompt
261 .password({
262 message: "Planetscale Service Token",
263 validate: (v) => (v.length === 0 ? "Required" : undefined),
264 })
265 .pipe(mapPromptCancellation);
266
267 const organization = yield* interaction.prompt
268 .text({
269 message: "Planetscale Organization (URL slug)",
270 validate: (v) => (v.length === 0 ? "Required" : undefined),
271 })
272 .pipe(mapPromptCancellation);
273
274 yield* interaction.output.success("Planetscale: credentials saved.");
275 return { method: "stored" as const, tokenId, token, organization };
276 });
277
278 const configureInteractive = (profileName: string) =>
279 interaction.prompt
280 .select({
281 message: "Planetscale authentication method",
282 options,
283 })
284 .pipe(
285 Effect.flatMap((method) =>
286 Match.value(method).pipe(
287 Match.when("oauth", () => configureOAuth(profileName)),
288 Match.when("stored", () => loginStored(profileName)),
289 Match.exhaustive,
290 ),
291 ),
292 );
293
294 const configureCredentials = (profileName: string) =>
295 configureInteractive(profileName).pipe(
296 Effect.mapError(
297 (e) =>
298 new AuthError({
299 message: "failed to configure credentials",
300 cause: e,
301 }),
302 ),
303 );
304
305 /**
306 * Flag-driven (`--method stored --set ...`) fields, mirroring the
307 * interactive service-token prompts. OAuth requires a browser and stays
308 * interactive-only, so it is deliberately absent from
309 * {@link configureMethods}.
310 */
311 const serviceTokenFields: ReadonlyArray<ConfigureField> = [
312 { name: "tokenId", label: "Planetscale Service Token ID" },
313 { name: "token", label: "Planetscale Service Token", secret: true },
314 { name: "organization", label: "Planetscale Organization (URL slug)" },
315 ];
316
317 const configureMethods: ReadonlyArray<ConfigureMethod> = [
318 { method: "stored", fields: serviceTokenFields },
319 ];
320
321 const configureWith = (
322 _profileName: string,
323 input: {
324 readonly method: string;
325 readonly values: Record<string, string>;
326 },
327 ): Effect.Effect<
328 PlanetscaleAuthConfig,
329 AuthError,
330 Interaction.Interaction
331 > =>
332 input.method === "stored"
333 ? validateFieldValues(
334 PLANETSCALE_AUTH_PROVIDER_NAME,
335 serviceTokenFields,
336 input.values,
337 ).pipe(
338 Effect.map((values) => ({
339 method: "stored" as const,
340 tokenId: Redacted.value(
341 storedSecret(values.tokenId) ?? Redacted.make(""),
342 ),
343 token: Redacted.value(
344 storedSecret(values.token) ?? Redacted.make(""),
345 ),
346 organization: storedValueText(values.organization) ?? "",
347 })),
348 Effect.tap(() =>
349 interaction.output.success("Planetscale: credentials saved."),
350 ),
351 )
352 : Effect.fail(
353 new AuthError({
354 message: `Planetscale: unknown method '${input.method}'. Valid methods: stored. (OAuth is interactive-only.)`,
355 }),
356 );
357
358 const resolveCredentials = (
359 profileName: string,
360 config: PlanetscaleAuthConfig,
361 updateConfig?: (
362 config: PlanetscaleAuthConfig,
363 ) => Effect.Effect<void, AuthError>,
364 ) =>
365 Effect.gen(function* () {
366 const reauth = refreshHint(PLANETSCALE_AUTH_PROVIDER_NAME, profileName);
367 return yield* Match.value(config).pipe(
368 Match.when({ method: "stored" }, (stored) =>
369 Effect.succeed({
370 type: "apiToken" as const,
371 tokenId: Redacted.make(stored.tokenId),
372 token: Redacted.make(stored.token),
373 organization: stored.organization,
374 source: { type: "stored" as const, details: undefined },
375 } satisfies PlanetscaleResolvedCredentials),
376 ),
377 Match.when({ method: "oauth" }, (cfg) =>
378 Effect.gen(function* () {
379 const creds: OAuthClient.OAuthCredentials = {
380 type: "oauth",
381 clientId: cfg.clientId,
382 access: Redacted.make(cfg.access),
383 refresh: Redacted.make(cfg.refresh),
384 expires: cfg.expires,
385 scopes: cfg.scopes,
386 };
387 if (!OAuthClient.usesCurrentClient(creds)) {
388 return yield* Effect.fail(
389 new NeedsReauth({
390 provider: PLANETSCALE_AUTH_PROVIDER_NAME,
391 profile: profileName,
392 message: `Planetscale OAuth credentials for profile '${profileName}' were issued to an incompatible OAuth client and have been removed. ${reauth}`,
393 }),
394 );
395 }
396 // Refresh proactively if the token has expired (or is within
397 // 10s of expiring). Persist the refreshed creds so subsequent
398 // resolves don't repeat the round-trip.
399 const now = yield* Clock.currentTimeMillis;
400 const fresh =
401 creds.expires > now + 10_000
402 ? creds
403 : yield* OAuthClient.refresh(creds).pipe(
404 // Only the refresh round-trip maps to NeedsReauth — a
405 // failed persist afterwards is a local I/O AuthError and
406 // passes through untouched.
407 Effect.mapError(
408 (e) =>
409 new NeedsReauth({
410 provider: PLANETSCALE_AUTH_PROVIDER_NAME,
411 profile: profileName,
412 message: `Planetscale OAuth refresh failed. ${reauth}`,
413 cause: e,
414 }),
415 ),
416 );
417 if (fresh !== creds) {
418 yield* (
419 updateConfig?.({
420 method: "oauth",
421 organization: cfg.organization,
422 clientId: fresh.clientId,
423 access: Redacted.value(fresh.access),
424 refresh: Redacted.value(fresh.refresh),
425 expires: fresh.expires,
426 scopes: fresh.scopes,
427 }) ?? Effect.void
428 );
429 }
430 return {
431 type: "oauth" as const,
432 accessToken: fresh.access,
433 expires: fresh.expires,
434 organization: cfg.organization,
435 source: { type: "oauth" as const },
436 } satisfies PlanetscaleResolvedCredentials;
437 }),
438 ),
439 Match.exhaustive,
440 );
441 });
442
443 const logout = (_profileName: string, config: PlanetscaleAuthConfig) =>
444 Match.value(config).pipe(
445 Match.when({ method: "stored" }, () => Effect.void),
446 Match.when({ method: "oauth" }, () => Effect.void),
447 Match.exhaustive,
448 );
449
450 const login = (
451 profileName: string,
452 config: PlanetscaleAuthConfig,
453 updateConfig?: (
454 config: PlanetscaleAuthConfig,
455 ) => Effect.Effect<void, AuthError>,
456 ) =>
457 Match.value(config)
458 .pipe(
459 Match.when({ method: "stored" }, (stored) => Effect.succeed(stored)),
460 Match.when({ method: "oauth" }, (oauth) =>
461 Effect.gen(function* () {
462 const credentials: OAuthClient.OAuthCredentials = {
463 type: "oauth",
464 clientId: oauth.clientId,
465 access: Redacted.make(oauth.access),
466 refresh: Redacted.make(oauth.refresh),
467 expires: oauth.expires,
468 scopes: oauth.scopes,
469 };
470 if (!OAuthClient.usesCurrentClient(credentials)) {
471 return yield* configureOAuth(profileName);
472 }
473 const refreshed = yield* withProfileCredentialsLock(
474 profileName,
475 interaction.output
476 .info("Planetscale: refreshing OAuth credentials...")
477 .pipe(
478 Effect.andThen(OAuthClient.refresh(credentials)),
479 Effect.flatMap((credentials) => {
480 const config = {
481 ...oauth,
482 clientId: credentials.clientId,
483 access: Redacted.value(credentials.access),
484 refresh: Redacted.value(credentials.refresh),
485 expires: credentials.expires,
486 scopes: credentials.scopes,
487 };
488 return (updateConfig?.(config) ?? Effect.void).pipe(
489 Effect.as(config),
490 );
491 }),
492 Effect.tap(() =>
493 interaction.output.success(
494 "Planetscale: OAuth credentials refreshed.",
495 ),
496 ),
497 ),
498 ).pipe(
499 Effect.catchTag("OAuthError", () =>
500 configureOAuth(profileName),
501 ),
502 );
503 return refreshed;
504 }),
505 ),
506 Match.exhaustive,
507 )
508 .pipe(
509 Effect.mapError(
510 (e) => new AuthError({ message: "login failed", cause: e }),
511 ),
512 );
513
514 const details = (
515 profileName: string,
516 config: PlanetscaleAuthConfig,
517 updateConfig?: (
518 config: PlanetscaleAuthConfig,
519 ) => Effect.Effect<void, AuthError>,
520 ) =>
521 Effect.all([
522 resolveCredentials(profileName, config, updateConfig),
523 Clock.currentTimeMillis,
524 ]).pipe(
525 Effect.map(([creds, now]): ProviderDetails => {
526 const sourceStr =
527 "details" in creds.source && creds.source.details
528 ? `${creds.source.type} - ${creds.source.details}`
529 : creds.source.type;
530 return Match.value(creds).pipe(
531 Match.when({ type: "apiToken" }, (c) => ({
532 lines: [
533 { key: "tokenId", value: displayRedacted(c.tokenId, 3) },
534 { key: "token", value: displayRedacted(c.token, 6) },
535 { key: "organization", value: c.organization },
536 { key: "source", value: sourceStr },
537 ],
538 })),
539 Match.when({ type: "oauth" }, (c) => {
540 const remainingMs = c.expires - now;
541 const expiresAt = new Date(c.expires).toISOString();
542 const expiresStr =
543 remainingMs <= 0
544 ? `expired (${expiresAt})`
545 : `in ${Duration.format(Duration.millis(remainingMs))} (${expiresAt})`;
546 return {
547 lines: [
548 { key: "accessToken", value: displayRedacted(c.accessToken) },
549 { key: "expires", value: expiresStr },
550 { key: "organization", value: c.organization },
551 { key: "source", value: sourceStr },
552 ],
553 };
554 }),
555 Match.exhaustive,
556 );
557 }),
558 );
559
560 const readEnvironment = Effect.gen(function* () {
561 const tokenId = yield* getEnvRedactedRequired("PLANETSCALE_API_TOKEN_ID");
562 const token = yield* getEnvRedactedRequired("PLANETSCALE_API_TOKEN");
563 const organization = yield* getEnvRequired("PLANETSCALE_ORGANIZATION");
564 return {
565 type: "apiToken" as const,
566 tokenId,
567 token,
568 organization,
569 source: {
570 type: "env" as const,
571 details: "PLANETSCALE_API_TOKEN_ID/PLANETSCALE_API_TOKEN",
572 },
573 } satisfies PlanetscaleResolvedCredentials;
574 });
575
576 return {
577 configSchema: PlanetscaleAuthConfigSchema,
578 configure: configureCredentials,
579 configureWith,
580 configureMethods,
581 logout,
582 login,
583 details,
584 read: resolveCredentials,
585 readEnvironment,
586 environment: [
587 {
588 name: "PLANETSCALE_API_TOKEN_ID",
589 required: true,
590 description: "Service token id.",
591 },
592 {
593 name: "PLANETSCALE_API_TOKEN",
594 required: true,
595 secret: true,
596 description: "Service token secret.",
597 },
598 {
599 name: "PLANETSCALE_ORGANIZATION",
600 required: true,
601 description: "Organization URL slug.",
602 },
603 {
604 name: "PLANETSCALE_API_BASE_URL",
605 required: false,
606 description: "API base URL override.",
607 },
608 ],
609 };
610 }),
611);