1import { Query } from "@distilled.cloud/core/query";
2import {
3 CredentialsFromToken,
4 GraphQLLive,
5 Railway,
6 type GqlTransport,
7} from "@distilled.cloud/railway";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import * as Schedule from "effect/Schedule";
11import * as FetchHttpClient from "effect/http/FetchHttpClient";
12
13/** Dashboard host used by `railway login --browserless` pairing URLs. */
14export const RAILWAY_CLI_LOGIN_HOST = "https://railway.com";
15
16/**
17 * Build the Railway CLI pairing URL for a `loginSessionCreate`
18 * code.
19 *
20 * Mirrors `railway login --browserless`: the payload is
21 * `wordCode={code}&hostname={hostname}` (URL-safe base64) on
22 * `https://railway.com/cli-login?d=…`. The user confirms the pairing code
23 * in the browser; `loginSessionAuth` is the dashboard-side
24 * mutation that marks the session authorized. `loginSessionVerify`
25 * is a liveness check (true while the pairing session exists). The CLI then
26 * polls `loginSessionConsume` until a token is returned.
27 *
28 * Alchemy's AuthProvider `method: "oauth"` runs this flow: create → print
29 * the pairing URL → poll consume → store the token.
30 *
31 * ### Pairing URL
32 * **Example:** From a session code
33 * ```typescript
34 * const code = yield* createLoginSession();
35 * const url = loginSessionUrl(code, { hostname: "dev-box" });
36 * ```
37 */
38export const loginSessionUrl = (
39 code: string,
40 options?: { hostname?: string; host?: string },
41): string => {
42 const hostname = options?.hostname ?? "alchemy";
43 const host = (options?.host ?? RAILWAY_CLI_LOGIN_HOST).replace(/\/+$/, "");
44 const payload = `wordCode=${code}&hostname=${hostname}`;
45 const encoded = Buffer.from(payload)
46 .toString("base64")
47 .replace(/\+/g, "-")
48 .replace(/\//g, "_");
49 return `${host}/cli-login?d=${encoded}`;
50};
51
52/**
53 * Credentials layer that sends no `Authorization` header. Login-session
54 * mutations are public: they run before the user has a token.
55 */
56const anonymousRailwayCredentials = (apiBaseUrl?: string) =>
57 CredentialsFromToken({
58 token: "",
59 tokenKind: "account",
60 apiBaseUrl,
61 });
62
63const anonymousRailway = (apiBaseUrl?: string) =>
64 Layer.mergeAll(
65 GraphQLLive,
66 anonymousRailwayCredentials(apiBaseUrl),
67 FetchHttpClient.layer,
68 );
69
70export const provideAnonymousRailway = <A, E>(
71 effect: Effect.Effect<A, E, GqlTransport>,
72 apiBaseUrl?: string,
73): Effect.Effect<A, E> =>
74 effect.pipe(Effect.provide(anonymousRailway(apiBaseUrl)));
75
76const LOGIN_POLL_TIMES = 300;
77
78export const createLoginSession = Query.fn(() => Railway.loginSessionCreate());
79
80export const cancelLoginSession = Query.fn((code: string) =>
81 Railway.loginSessionCancel({ code }),
82);
83
84const verifyLoginSession = Query.fn((code: string) =>
85 Railway.loginSessionVerify({ code }),
86);
87
88const consumeLoginSession = Query.fn((code: string) =>
89 Railway.loginSessionConsume({ code }),
90);
91
92/**
93 * Poll `loginSessionVerify` then
94 * `loginSessionConsume` until a token is returned, or 5 minutes
95 * elapse. Mirrors `railway login --browserless`: consume is the token source;
96 * verify is a liveness check. Does not cancel the session — the caller should
97 * cancel on timeout or interrupt.
98 *
99 * Exhaustion returns `undefined` (not a failure) so the AuthProvider can
100 * surface a timeout rather than a poll error.
101 */
102export const pollLoginSessionToken = (code: string) =>
103 verifyLoginSession(code).pipe(
104 Effect.catchTag("RailwayNotFound", () => Effect.succeed(false)),
105 Effect.flatMap(() =>
106 consumeLoginSession(code).pipe(
107 Effect.catchTag("RailwayNotFound", () => Effect.succeed(null)),
108 ),
109 ),
110 Effect.map((token) =>
111 token != null && token.length > 0 ? token : undefined,
112 ),
113 Effect.repeat({
114 schedule: Schedule.spaced("1 second"),
115 while: (token) => token == null,
116 times: LOGIN_POLL_TIMES,
117 }),
118 );