| 1 | import { listOrganizations } from "@distilled.cloud/planetscale"; |
| 2 | import * as PsCredentialsModule from "@distilled.cloud/planetscale/Credentials"; |
| 3 | import * as Clock from "effect/Clock"; |
| 4 | import * as Duration from "effect/Duration"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Match from "effect/Match"; |
| 8 | import * as Redacted from "effect/Redacted"; |
| 9 | import * as Schema from "effect/Schema"; |
| 10 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; |
| 11 | import * as HttpClient from "effect/http/HttpClient"; |
| 12 | import { |
| 13 | AuthError, |
| 14 | AuthProviderLayer, |
| 15 | refreshHint, |
| 16 | type ConfigureField, |
| 17 | type ConfigureMethod, |
| 18 | NeedsReauth, |
| 19 | type ProviderDetails, |
| 20 | } from "../Auth/AuthProvider.ts"; |
| 21 | import { |
| 22 | storedSecret, |
| 23 | storedValueText, |
| 24 | validateFieldValues, |
| 25 | } from "../Auth/StoredAuthProvider.ts"; |
| 26 | import { displayRedacted } from "../Auth/Credentials.ts"; |
| 27 | import { withProfileCredentialsLock } from "../Auth/Lock.ts"; |
| 28 | import { |
| 29 | getEnvRedactedRequired, |
| 30 | getEnvRequired, |
| 31 | mapPromptCancellation, |
| 32 | } from "../Auth/Env.ts"; |
| 33 | import { browserOAuth } from "../Auth/BrowserOAuth.ts"; |
| 34 | import * as Interaction from "../Interaction.ts"; |
| 35 | import * 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 | */ |
| 41 | export 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 | */ |
| 52 | const 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 | */ |
| 76 | const 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 | |
| 111 | const 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 | */ |
| 136 | export 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 | ]); |
| 153 | export 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 | */ |
| 160 | export 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 | */ |
| 191 | export 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 | ); |