1// @effect-diagnostics nodeBuiltinImport:off - The CLI loopback OAuth callback is a Node HTTP boundary.
2import * as NodeHttp from "node:http";
3
4import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
5import * as Clock from "effect/Clock";
6import * as Cause from "effect/Cause";
7import * as Console from "effect/Console";
8import * as Context from "effect/Context";
9import * as Crypto from "effect/Crypto";
10import * as Deferred from "effect/Deferred";
11import * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";
13import * as Base64Url from "effect/encoding/Base64Url";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Queue from "effect/Queue";
17import * as Schema from "effect/Schema";
18import * as Semaphore from "effect/Semaphore";
19import * as Terminal from "effect/Terminal";
20import * as HttpClient from "effect/http/HttpClient";
21import * as HttpClientError from "effect/http/HttpClientError";
22import * as HttpClientRequest from "effect/http/HttpClientRequest";
23import * as HttpClientResponse from "effect/http/HttpClientResponse";
24import * as HttpRouter from "effect/http/HttpRouter";
25import * as HttpServerRequest from "effect/http/HttpServerRequest";
26import * as HttpServerResponse from "effect/http/HttpServerResponse";
27
28import { buildConnectAuthorizeRequestUrl } from "@t3tools/shared/connectAuth";
29
30import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
31import * as ExternalLauncher from "../process/externalLauncher.ts";
32import {
33 cloudCliOAuthConfig,
34 hostedAppUrlConfig,
35 type CloudCliOAuthConfig,
36} from "./publicConfig.ts";
37import { renderLoopbackAuthorizationCompleteHtml } from "./cliAuthHtml.ts";
38
39const CLOUD_CLI_OAUTH_TOKEN_SECRET = "cloud-cli-oauth-token";
40const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10);
41const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5));
42const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
43// RFC 8628 defaults, used only when Clerk omits the field.
44const DEVICE_AUTHORIZATION_DEFAULT_INTERVAL = Duration.seconds(5);
45// RFC 8628 §3.5: a slow_down response means "add 5 seconds to the interval".
46const DEVICE_AUTHORIZATION_SLOW_DOWN_INCREMENT = Duration.seconds(5);
47// Bounds one token or device-authorization request, including reading the
48// body. A stalled refresh holds the manager's semaphore and blocks every
49// other credential call.
50const OAUTH_REQUEST_TIMEOUT = Duration.seconds(10);
51const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`;
52
53function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string {
54 return [
55 "Open this URL to authorize T3 Connect:",
56 ` ${authorizationUrl}`,
57 "",
58 `Press ${boldTerminalText("Enter")} to open it in your browser.`,
59 `No browser on this device? Press ${boldTerminalText("H")} to switch to headless mode.`,
60 ].join("\n");
61}
62
63export type LoopbackAuthorizationResult =
64 | { readonly _tag: "AuthorizationCode"; readonly code: string }
65 | { readonly _tag: "HeadlessRequested" };
66
67const readLoopbackAuthorizationAction = Effect.fn(
68 "cloud.cli_token.read_loopback_authorization_action",
69)(function* (input: Queue.Dequeue<Terminal.UserInput, Cause.Done>) {
70 while (true) {
71 const event = yield* Queue.take(input).pipe(Effect.mapError(() => new Terminal.QuitError({})));
72 const keyName = event.key.name.toLowerCase();
73 if (!event.key.ctrl && !event.key.meta && keyName === "h") {
74 return "headless" as const;
75 }
76 if (keyName === "enter" || keyName === "return") {
77 return "open-browser" as const;
78 }
79 }
80});
81
82export const waitForLoopbackAuthorization = Effect.fn(
83 "cloud.cli_token.wait_for_loopback_authorization",
84)(function* <E, R>(input: {
85 readonly authorizationUrl: string;
86 readonly callback: Effect.Effect<string, E, R>;
87 readonly terminal: Terminal.Terminal;
88 readonly launchBrowser: (
89 url: string,
90 ) => Effect.Effect<void, ExternalLauncher.ExternalLauncherError>;
91}) {
92 return yield* Effect.scoped(
93 Effect.gen(function* () {
94 const terminalInput = yield* input.terminal.readInput;
95 while (true) {
96 const result = yield* Effect.raceFirst(
97 input.callback.pipe(
98 Effect.map((code): LoopbackAuthorizationResult => ({
99 _tag: "AuthorizationCode",
100 code,
101 })),
102 ),
103 readLoopbackAuthorizationAction(terminalInput),
104 );
105 if (typeof result !== "string") {
106 return result;
107 }
108 if (result === "headless") {
109 return { _tag: "HeadlessRequested" } as const;
110 }
111 yield* input
112 .launchBrowser(input.authorizationUrl)
113 .pipe(
114 Effect.catch(() =>
115 Console.warn(
116 `Could not open a browser on this device. Open the URL above manually, or press ${boldTerminalText("H")} to switch to headless mode.`,
117 ),
118 ),
119 );
120 }
121 }),
122 );
123});
124
125const PersistedToken = Schema.Struct({
126 accessToken: Schema.String,
127 refreshToken: Schema.String,
128 expiresAtEpochMs: Schema.Number,
129 identity: Schema.optional(Schema.String),
130});
131export type PersistedToken = typeof PersistedToken.Type;
132
133const PersistedTokenJson = Schema.fromJsonString(PersistedToken);
134const decodePersistedToken = Schema.decodeUnknownEffect(PersistedTokenJson);
135const encodePersistedToken = Schema.encodeEffect(PersistedTokenJson);
136
137const OAuthTokenResponse = Schema.Struct({
138 access_token: Schema.String,
139 refresh_token: Schema.optional(Schema.String),
140 id_token: Schema.optional(Schema.String),
141 expires_in: Schema.Number,
142 token_type: Schema.String,
143});
144
145const OAuthErrorResponse = Schema.Struct({
146 error: Schema.String,
147 error_description: Schema.optional(Schema.String),
148});
149
150const DeviceAuthorizationResponse = Schema.Struct({
151 device_code: Schema.String,
152 user_code: Schema.String,
153 verification_uri: Schema.String,
154 verification_uri_complete: Schema.optional(Schema.String),
155 expires_in: Schema.Number,
156 interval: Schema.optional(Schema.Number),
157});
158
159const OidcIdentityClaimsJson = Schema.fromJsonString(
160 Schema.Struct({
161 email: Schema.optional(Schema.String),
162 preferred_username: Schema.optional(Schema.String),
163 sub: Schema.optional(Schema.String),
164 }),
165);
166const decodeOidcIdentityClaimsJson = Schema.decodeUnknownOption(OidcIdentityClaimsJson);
167
168/**
169 * Best-effort read of the `email` (or fallback) claim from an OIDC id_token.
170 * Only used to show the operator which account they linked, so a malformed
171 * token degrades to "no identity" rather than an error.
172 */
173function idTokenIdentity(idToken: string | undefined): string | null {
174 if (!idToken) return null;
175 const payload = idToken.split(".")[1];
176 if (!payload) return null;
177 const decoded = Base64Url.decodeString(payload);
178 if (decoded._tag !== "Success") return null;
179 const claims = decodeOidcIdentityClaimsJson(decoded.success);
180 if (Option.isNone(claims)) return null;
181 for (const value of [claims.value.email, claims.value.preferred_username, claims.value.sub]) {
182 if (typeof value === "string" && value.length > 0) return value;
183 }
184 return null;
185}
186
187export class CloudCliCredentialRemovalError extends Schema.TaggedError<CloudCliCredentialRemovalError>()(
188 "CloudCliCredentialRemovalError",
189 { cause: Schema.Defect() },
190) {
191 override get message(): string {
192 return "Could not remove the stored T3 Connect CLI credential.";
193 }
194}
195
196export class CloudCliCredentialRefreshError extends Schema.TaggedError<CloudCliCredentialRefreshError>()(
197 "CloudCliCredentialRefreshError",
198 { cause: Schema.Defect() },
199) {
200 override get message(): string {
201 return "Could not refresh the T3 Connect CLI credential.";
202 }
203}
204
205export class CloudCliCredentialReadError extends Schema.TaggedError<CloudCliCredentialReadError>()(
206 "CloudCliCredentialReadError",
207 { cause: Schema.Defect() },
208) {
209 override get message(): string {
210 return "Could not read the stored T3 Connect CLI credential.";
211 }
212}
213
214export class CloudCliAuthorizationError extends Schema.TaggedError<CloudCliAuthorizationError>()(
215 "CloudCliAuthorizationError",
216 { cause: Schema.Defect() },
217) {
218 override get message(): string {
219 return "Could not authorize the T3 Connect CLI.";
220 }
221}
222
223export class CloudCliAuthorizationTimeoutError extends Schema.TaggedError<CloudCliAuthorizationTimeoutError>()(
224 "CloudCliAuthorizationTimeoutError",
225 { cause: Schema.Defect() },
226) {
227 override get message(): string {
228 return "Timed out waiting for T3 Connect authorization.";
229 }
230}
231
232export class CloudCliAuthorizationDeniedError extends Schema.TaggedError<CloudCliAuthorizationDeniedError>()(
233 "CloudCliAuthorizationDeniedError",
234 {},
235) {
236 override get message(): string {
237 return "T3 Connect authorization was denied in the browser.";
238 }
239}
240
241export const CloudCliTokenManagerError = Schema.Union([
242 CloudCliCredentialRemovalError,
243 CloudCliCredentialRefreshError,
244 CloudCliCredentialReadError,
245 CloudCliAuthorizationError,
246 CloudCliAuthorizationTimeoutError,
247 CloudCliAuthorizationDeniedError,
248]);
249export type CloudCliTokenManagerError = typeof CloudCliTokenManagerError.Type;
250
251export class CloudCliTokenManager extends Context.Service<
252 CloudCliTokenManager,
253 {
254 readonly get: Effect.Effect<
255 | { readonly _tag: "Authorized"; readonly token: PersistedToken }
256 | { readonly _tag: "HeadlessRequested" },
257 CloudCliTokenManagerError | Terminal.QuitError
258 >;
259 readonly getExisting: Effect.Effect<Option.Option<PersistedToken>, CloudCliTokenManagerError>;
260 readonly hasCredential: Effect.Effect<boolean, CloudCliTokenManagerError>;
261 readonly store: (token: PersistedToken) => Effect.Effect<void, CloudCliTokenManagerError>;
262 readonly clear: Effect.Effect<void, CloudCliTokenManagerError>;
263 }
264>()("t3/cloud/CliTokenManager/CloudCliTokenManager") {}
265
266function stringToBytes(value: string): Uint8Array {
267 return new TextEncoder().encode(value);
268}
269
270function bytesToString(value: Uint8Array): string {
271 return new TextDecoder().decode(value);
272}
273
274const readTokenResponse = Effect.fn("cloud.cli_token.read_token_response")(function* (
275 response: HttpClientResponse.HttpClientResponse,
276 params: Record<string, string>,
277) {
278 const body = yield* HttpClientResponse.schemaBodyJson(OAuthTokenResponse)(response);
279 const now = yield* Clock.currentTimeMillis;
280 const identity = idTokenIdentity(body.id_token);
281 return {
282 token: {
283 accessToken: body.access_token,
284 refreshToken: body.refresh_token ?? params.refresh_token ?? "",
285 expiresAtEpochMs: now + body.expires_in * 1_000,
286 ...(identity === null ? {} : { identity }),
287 } satisfies PersistedToken,
288 identity,
289 };
290});
291
292const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* (
293 metadata: Pick<CloudCliOAuthConfig, "tokenEndpoint">,
294 params: Record<string, string>,
295) {
296 const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
297 return yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe(
298 HttpClientRequest.bodyUrlParams(params),
299 httpClient.execute,
300 Effect.flatMap((response) => readTokenResponse(response, params)),
301 Effect.timeout(OAUTH_REQUEST_TIMEOUT),
302 );
303});
304
305const makePkceRequest = Effect.gen(function* () {
306 const crypto = yield* Crypto.Crypto;
307 const verifier = Base64Url.encode(yield* crypto.randomBytes(32));
308 const challenge = Base64Url.encode(
309 yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier)),
310 );
311 const state = Base64Url.encode(yield* crypto.randomBytes(16));
312 return { verifier, challenge, state };
313});
314
315export interface DeviceAuthorizationPrompt {
316 readonly verificationUri: string;
317 readonly verificationUriComplete: string | undefined;
318 readonly userCode: string;
319 readonly expiresIn: Duration.Duration;
320}
321
322const isTransportError = (error: unknown) =>
323 HttpClientError.isHttpClientError(error) && error.reason._tag === "TransportError";
324
325/**
326 * Polls Clerk's token endpoint until the user approves or denies the device
327 * request in the browser (RFC 8628 §3.4/3.5). `authorization_pending` keeps
328 * waiting, while `slow_down` and transient failures widen the interval before
329 * the next tick; the caller bounds the whole loop with the device code's
330 * lifetime.
331 */
332const pollDeviceToken = Effect.fn("cloud.cli_token.poll_device_token")(function* (
333 metadata: Pick<CloudCliOAuthConfig, "tokenEndpoint" | "clientId">,
334 deviceCode: string,
335 initialInterval: Duration.Duration,
336) {
337 const httpClient = yield* HttpClient.HttpClient;
338 const params = {
339 grant_type: DEVICE_CODE_GRANT_TYPE,
340 device_code: deviceCode,
341 client_id: metadata.clientId,
342 };
343 let interval = initialInterval;
344 while (true) {
345 yield* Effect.sleep(interval);
346 const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe(
347 HttpClientRequest.bodyUrlParams(params),
348 httpClient.execute,
349 Effect.asSome,
350 Effect.catchIf(isTransportError, () => Effect.succeedNone),
351 );
352 // Transport failures and upstream 5xx are transient while the device code
353 // is still valid. RFC 8628 §3.5 asks clients to back off before retrying,
354 // so widen the interval like slow_down; drain the body so the connection
355 // returns to the pool for the next poll.
356 if (Option.isNone(response) || response.value.status >= 500) {
357 if (Option.isSome(response)) yield* Effect.ignore(response.value.text);
358 interval = Duration.sum(interval, DEVICE_AUTHORIZATION_SLOW_DOWN_INCREMENT);
359 continue;
360 }
361 if (response.value.status >= 200 && response.value.status < 300) {
362 return yield* readTokenResponse(response.value, params);
363 }
364 const failure = yield* HttpClientResponse.schemaBodyJson(OAuthErrorResponse)(response.value);
365 switch (failure.error) {
366 case "authorization_pending":
367 continue;
368 case "slow_down":
369 interval = Duration.sum(interval, DEVICE_AUTHORIZATION_SLOW_DOWN_INCREMENT);
370 continue;
371 case "expired_token":
372 return yield* new CloudCliAuthorizationTimeoutError({ cause: failure });
373 case "access_denied":
374 return yield* new CloudCliAuthorizationDeniedError();
375 default:
376 return yield* new CloudCliAuthorizationError({
377 cause: failure.error_description ?? failure.error,
378 });
379 }
380 }
381});
382
383/**
384 * OAuth device authorization grant for machines without a local browser
385 * (SSH). Clerk issues a short user code; the user approves it on Clerk's
386 * hosted device page from any browser while this process polls the token
387 * endpoint. Nothing is typed into the terminal and no redirect URI is
388 * involved, so the hosted app plays no part in this flow.
389 */
390export const deviceAuthorizationLogin = Effect.fn("cloud.cli_token.device_authorization_login")(
391 function* <E, R>(showPrompt: (prompt: DeviceAuthorizationPrompt) => Effect.Effect<void, E, R>) {
392 const metadata = yield* cloudCliOAuthConfig;
393 const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
394 const authorization = yield* HttpClientRequest.post(metadata.deviceAuthorizationEndpoint).pipe(
395 HttpClientRequest.bodyUrlParams({
396 client_id: metadata.clientId,
397 scope: metadata.scopes.join(" "),
398 }),
399 httpClient.execute,
400 Effect.flatMap(HttpClientResponse.schemaBodyJson(DeviceAuthorizationResponse)),
401 Effect.timeout(OAUTH_REQUEST_TIMEOUT),
402 );
403 // Clerk's advertised lifetime and interval are authoritative.
404 const expiresIn = Duration.seconds(authorization.expires_in);
405 const interval =
406 authorization.interval === undefined
407 ? DEVICE_AUTHORIZATION_DEFAULT_INTERVAL
408 : Duration.seconds(authorization.interval);
409 yield* showPrompt({
410 verificationUri: authorization.verification_uri,
411 verificationUriComplete: authorization.verification_uri_complete,
412 userCode: authorization.user_code,
413 expiresIn,
414 });
415 return yield* pollDeviceToken(metadata, authorization.device_code, interval).pipe(
416 Effect.timeout(expiresIn),
417 Effect.catchTags({
418 TimeoutError: (cause) => Effect.fail(new CloudCliAuthorizationTimeoutError({ cause })),
419 }),
420 );
421 },
422);
423
424/** @public Service construction is part of the canonical Effect module API. */
425export const make = Effect.gen(function* () {
426 // Capture exactly the services the login/refresh flows need at build time,
427 // not the whole ambient context.
428 const crypto = yield* Crypto.Crypto;
429 const httpClient = yield* HttpClient.HttpClient;
430 const services = Context.make(Crypto.Crypto, crypto).pipe(
431 Context.add(HttpClient.HttpClient, httpClient),
432 );
433 const secrets = yield* ServerSecretStore.ServerSecretStore;
434 const terminal = yield* Terminal.Terminal;
435 const externalLauncher = yield* ExternalLauncher.ExternalLauncher;
436 const semaphore = yield* Semaphore.make(1);
437 const persist = Effect.fn("cloud.cli_token.persist")(function* (token: PersistedToken) {
438 const encoded = yield* encodePersistedToken(token);
439 yield* secrets.set(CLOUD_CLI_OAUTH_TOKEN_SECRET, stringToBytes(encoded));
440 return token;
441 });
442
443 const clear = secrets
444 .remove(CLOUD_CLI_OAUTH_TOKEN_SECRET)
445 .pipe(Effect.mapError((cause) => new CloudCliCredentialRemovalError({ cause })));
446
447 const read = Effect.fn("cloud.cli_token.read")(function* () {
448 const encoded = yield* secrets.get(CLOUD_CLI_OAUTH_TOKEN_SECRET);
449 if (Option.isNone(encoded)) return Option.none<PersistedToken>();
450 return Option.some(yield* decodePersistedToken(bytesToString(encoded.value)));
451 });
452
453 const refresh = Effect.fn("cloud.cli_token.refresh")(function* (token: PersistedToken) {
454 const metadata = yield* cloudCliOAuthConfig;
455 const { token: refreshed } = yield* exchangeToken(metadata, {
456 grant_type: "refresh_token",
457 refresh_token: token.refreshToken,
458 client_id: metadata.clientId,
459 });
460 return refreshed.identity === undefined && token.identity !== undefined
461 ? { ...refreshed, identity: token.identity }
462 : refreshed;
463 });
464
465 const login = Effect.fn("cloud.cli_token.login")(function* () {
466 const metadata = yield* cloudCliOAuthConfig;
467 const hostedAppUrl = yield* hostedAppUrlConfig;
468 const { verifier, challenge, state } = yield* makePkceRequest;
469 const callback = yield* Deferred.make<string>();
470 const layerCallbackRoute = HttpRouter.add(
471 "GET",
472 "/callback",
473 Effect.gen(function* () {
474 const request = yield* HttpServerRequest.HttpServerRequest;
475 const url = new URL(request.originalUrl, metadata.redirectUri);
476 const code = url.searchParams.get("code");
477 if (url.searchParams.get("state") !== state || !code) {
478 return HttpServerResponse.text("Invalid T3 Connect authorization callback.", {
479 status: 400,
480 });
481 }
482 yield* Deferred.succeed(callback, code);
483 return HttpServerResponse.html(renderLoopbackAuthorizationCompleteHtml());
484 }),
485 );
486 yield* HttpRouter.serve(layerCallbackRoute, {
487 disableListenLog: true,
488 disableLogger: true,
489 }).pipe(
490 Layer.provide(
491 NodeHttpServer.layer(NodeHttp.createServer, {
492 host: "127.0.0.1",
493 port: metadata.loopbackPort,
494 disablePreemptiveShutdown: true,
495 }),
496 ),
497 Layer.build,
498 );
499 // The hosted /connect page establishes a Clerk session before forwarding
500 // the request to /oauth/authorize with the loopback redirect URI. Sending
501 // a signed-out browser to /oauth/authorize directly loses the authorize
502 // parameters across Clerk's sign-in redirect (#5051).
503 const authorizationUrl = buildConnectAuthorizeRequestUrl({
504 hostedAppUrl,
505 state,
506 challenge,
507 loopbackPort: metadata.loopbackPort,
508 });
509 yield* Console.log(formatLoopbackAuthorizationPrompt(authorizationUrl));
510 const authorization = yield* waitForLoopbackAuthorization({
511 authorizationUrl,
512 callback: Deferred.await(callback).pipe(
513 Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT),
514 Effect.catchTags({
515 TimeoutError: (cause) => Effect.fail(new CloudCliAuthorizationTimeoutError({ cause })),
516 }),
517 ),
518 terminal,
519 launchBrowser: externalLauncher.launchBrowser,
520 });
521 if (authorization._tag === "HeadlessRequested") {
522 return authorization;
523 }
524 const { token } = yield* exchangeToken(metadata, {
525 grant_type: "authorization_code",
526 code: authorization.code,
527 redirect_uri: metadata.redirectUri,
528 client_id: metadata.clientId,
529 code_verifier: verifier,
530 });
531 return { _tag: "Authorized", token } as const;
532 });
533
534 const getExistingNoLock = Effect.fn("cloud.cli_token.get_existing_no_lock")(function* () {
535 const token = yield* read();
536 if (Option.isNone(token)) return token;
537 const now = yield* Clock.currentTimeMillis;
538 if (token.value.expiresAtEpochMs - CLOUD_CLI_OAUTH_REFRESH_EARLY_MS > now) {
539 return token;
540 }
541 return Option.some(yield* refresh(token.value).pipe(Effect.flatMap(persist)));
542 });
543
544 const getExisting = semaphore.withPermits(1)(
545 getExistingNoLock().pipe(
546 Effect.mapError((cause) => new CloudCliCredentialRefreshError({ cause })),
547 Effect.provide(services),
548 ),
549 );
550 const hasCredential = semaphore.withPermits(1)(
551 read().pipe(
552 Effect.map(Option.isSome),
553 Effect.mapError((cause) => new CloudCliCredentialReadError({ cause })),
554 ),
555 );
556 const get = semaphore.withPermits(1)(
557 Effect.gen(function* () {
558 // A stored credential that can't be read or refreshed (corrupt, revoked,
559 // expired grant) must fall through to a fresh login rather than dead-end
560 // the command — authorizeCli applies the same fallback to device
561 // authorization.
562 const token = yield* getExistingNoLock().pipe(
563 Effect.orElseSucceed(() => Option.none<PersistedToken>()),
564 );
565 if (Option.isSome(token)) {
566 return { _tag: "Authorized", token: token.value } as const;
567 }
568 const authorization = yield* Effect.scoped(login());
569 return authorization._tag === "Authorized"
570 ? ({ _tag: "Authorized", token: yield* persist(authorization.token) } as const)
571 : authorization;
572 }).pipe(
573 Effect.mapError((cause) =>
574 Terminal.isQuitError(cause) ? cause : new CloudCliAuthorizationError({ cause }),
575 ),
576 Effect.provide(services),
577 ),
578 );
579 const store = Effect.fn("cloud.cli_token.store")(function* (token: PersistedToken) {
580 yield* semaphore.withPermits(1)(
581 persist(token).pipe(
582 Effect.asVoid,
583 Effect.mapError((cause) => new CloudCliAuthorizationError({ cause })),
584 ),
585 );
586 });
587
588 return CloudCliTokenManager.of({ get, getExisting, hasCredential, store, clear });
589});
590
591export const layer = Layer.effect(CloudCliTokenManager, make);