1import {
2 DEFAULT_API_BASE_URL,
3 type GqlTransport,
4} from "@distilled.cloud/railway";
5import * as Effect from "effect/Effect";
6import * as Match from "effect/Match";
7import * as Redacted from "effect/Redacted";
8import * as Schema from "effect/Schema";
9import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner";
10import * as Os from "node:os";
11import {
12 AuthError,
13 AuthProviderLayer,
14 NeedsReauth,
15 type ConfigureField,
16 type ConfigureMethod,
17 type ProviderDetails,
18} from "../Auth/AuthProvider.ts";
19import { displayRedacted } from "../Auth/Credentials.ts";
20import {
21 getEnv,
22 getEnvRedacted,
23 getEnvRedactedRequired,
24 mapPromptCancellation,
25} from "../Auth/Env.ts";
26import {
27 storedSecret,
28 storedValueText,
29 validateFieldValues,
30} from "../Auth/StoredAuthProvider.ts";
31import * as Interaction from "../Interaction.ts";
32import {
33 cancelLoginSession,
34 createLoginSession,
35 loginSessionUrl,
36 pollLoginSessionToken,
37 provideAnonymousRailway,
38} from "./LoginSession.ts";
39
40export const RAILWAY_AUTH_PROVIDER_NAME = "Railway";
41export const RAILWAY_API_TOKEN_ENV = "RAILWAY_API_TOKEN";
42export const RAILWAY_API_URL_ENV = "RAILWAY_API_URL";
43
44/** Typed values stored in a Railway provider profile document. */
45export const RailwayAuthConfigSchema = Schema.Union([
46 Schema.Struct({ method: Schema.Literal("env") }),
47 Schema.Struct({
48 method: Schema.Literal("stored"),
49 token: Schema.String,
50 apiBaseUrl: Schema.optional(Schema.String),
51 }),
52 Schema.Struct({
53 method: Schema.Literal("oauth"),
54 token: Schema.String,
55 apiBaseUrl: Schema.optional(Schema.String),
56 }),
57]);
58export type RailwayAuthConfig = typeof RailwayAuthConfigSchema.Type;
59
60export type RailwayResolvedCredentials = {
61 type: "token";
62 token: Redacted.Redacted<string>;
63 tokenKind: "account";
64 apiBaseUrl: string;
65 source: { type: RailwayAuthConfig["method"]; details?: string };
66};
67
68const options: Array<{
69 value: RailwayAuthConfig["method"];
70 label: string;
71 description?: string;
72}> = [
73 {
74 value: "oauth",
75 label: "OAuth (CLI login session)",
76 description:
77 "recommended — open railway.com/cli-login, confirm the pairing code",
78 },
79 {
80 value: "env",
81 label: "Environment Variables",
82 description: `${RAILWAY_API_TOKEN_ENV} + optional ${RAILWAY_API_URL_ENV}`,
83 },
84 {
85 value: "stored",
86 label: "API Token",
87 description: "enter interactively and store inline in the provider file",
88 },
89];
90
91const normalizeApiBaseUrl = (explicit?: string) => {
92 const trimmed = (explicit ?? "").trim().replace(/\/+$/, "");
93 return trimmed.length > 0 ? trimmed : DEFAULT_API_BASE_URL;
94};
95
96const resolveApiBaseUrl = (explicit?: string) =>
97 getEnv(RAILWAY_API_URL_ENV).pipe(
98 Effect.map((fromEnv) => normalizeApiBaseUrl(explicit ?? fromEnv)),
99 );
100
101/**
102 * Layer that registers the Railway {@link AuthProvider} into the
103 * {@link AuthProviders} registry. Include this in the Railway `providers()`
104 * layer so the alchemy CLI can discover it.
105 *
106 * Supported methods:
107 * - `env`: reads `RAILWAY_API_TOKEN` (account Bearer). Project tokens are
108 * not used — they cannot reach workspace-wide operations.
109 * - `stored`: prompts for an API token and stores it in the provider file.
110 * - `oauth`: CLI login session (`loginSessionCreate` → open the pairing URL →
111 * poll `loginSessionVerify` / `loginSessionConsume` → store the token).
112 * Does not require a pre-existing token. An optional `RAILWAY_API_URL`
113 * overrides the backboard host (default `https://backboard.railway.com`).
114 */
115export const RailwayAuth = AuthProviderLayer<
116 RailwayAuthConfig,
117 RailwayResolvedCredentials
118>()(
119 RAILWAY_AUTH_PROVIDER_NAME,
120 Effect.gen(function* () {
121 const interaction = Interaction.accessors;
122 const loginStored = Effect.fn(function* (_profileName: string) {
123 const token = yield* interaction.prompt
124 .password({
125 message: "Railway API Token",
126 validate: (v) => (v.length === 0 ? "Required" : undefined),
127 })
128 .pipe(mapPromptCancellation);
129
130 const envUrl = yield* getEnv(RAILWAY_API_URL_ENV);
131 const urlPrompt = yield* interaction.prompt
132 .text({
133 message: "Railway API URL (Enter for default)",
134 placeholder: DEFAULT_API_BASE_URL,
135 defaultValue: envUrl ?? DEFAULT_API_BASE_URL,
136 })
137 .pipe(mapPromptCancellation);
138 const trimmed = (urlPrompt ?? "").trim();
139 const apiBaseUrl =
140 trimmed.length > 0 && trimmed !== DEFAULT_API_BASE_URL
141 ? trimmed
142 : undefined;
143
144 yield* interaction.output.success("Railway: credentials saved.");
145 return { method: "stored" as const, token, apiBaseUrl };
146 });
147
148 const loginOAuth = Effect.fn(function* (_profileName: string) {
149 const apiBaseUrl = yield* resolveApiBaseUrl();
150 const hostname = yield* Effect.sync(() => {
151 try {
152 return Os.hostname();
153 } catch {
154 return "alchemy";
155 }
156 });
157
158 const withAnonymous = <A, E>(effect: Effect.Effect<A, E, GqlTransport>) =>
159 provideAnonymousRailway(effect, apiBaseUrl);
160
161 const code = yield* withAnonymous(createLoginSession()).pipe(
162 Effect.mapError(
163 (e) =>
164 new AuthError({
165 message: "Railway login session create failed",
166 cause: e,
167 }),
168 ),
169 );
170
171 const url = loginSessionUrl(code, { hostname });
172 // `awaitExternal` is only on the Interaction service, not the `accessors`
173 // helper (same acquisition as BrowserOAuth / the AWS SSO flow).
174 const interaction = yield* Interaction.Interaction;
175 const services = yield* Effect.context<ChildProcessSpawner>();
176 // Invoked later by the prompt's keyboard event boundary, not while the
177 // surrounding Effect is executing.
178 const runOpenUrl = Effect.runPromiseWith(services);
179 const openFailed = yield* Interaction.openUrl(url).pipe(
180 Effect.as(false),
181 Effect.catch(() => Effect.succeed(true)),
182 );
183
184 const cancel = withAnonymous(cancelLoginSession(code)).pipe(
185 Effect.catch(() => Effect.void),
186 );
187
188 // The token only ever arrives through the poll; the awaitExternal
189 // prompt renders the pairing URL + code and re-opens the browser on
190 // demand, so it must never win the race — park it after it returns.
191 const token = yield* withAnonymous(pollLoginSessionToken(code)).pipe(
192 Effect.raceFirst(
193 interaction.prompt
194 .awaitExternal({
195 message: "Railway authorization",
196 waitingLabel:
197 "waiting for browser authorization (up to 5 minutes)…",
198 url,
199 code,
200 openFailed,
201 onOpen: () => runOpenUrl(Interaction.openUrl(url)),
202 allowManualInput: false,
203 })
204 .pipe(mapPromptCancellation, Effect.andThen(Effect.never)),
205 ),
206 Effect.onInterrupt(() => cancel),
207 Effect.tapError(() => cancel),
208 Effect.mapError((e) =>
209 e instanceof AuthError
210 ? e
211 : new AuthError({
212 message: "Railway login session poll failed",
213 cause: e,
214 }),
215 ),
216 );
217
218 if (token == null || token.length === 0) {
219 yield* cancel;
220 return yield* new AuthError({
221 message: "Railway login session timed out after 5 minutes.",
222 });
223 }
224
225 yield* interaction.output.success("Railway: OAuth credentials saved.");
226 return {
227 method: "oauth" as const,
228 token,
229 apiBaseUrl:
230 apiBaseUrl === DEFAULT_API_BASE_URL ? undefined : apiBaseUrl,
231 };
232 });
233
234 const configureInteractive = (profileName: string) =>
235 interaction.prompt
236 .select({
237 message: "Railway authentication method",
238 options,
239 })
240 .pipe(
241 Effect.flatMap((method) =>
242 Match.value(method).pipe(
243 Match.when("env", () =>
244 Effect.gen(function* () {
245 const token = yield* getEnvRedacted(RAILWAY_API_TOKEN_ENV);
246 if (!token) {
247 yield* interaction.output.warning(
248 `Railway: ${RAILWAY_API_TOKEN_ENV} is not currently set — export it before deploying.`,
249 );
250 }
251 return { method: "env" as const };
252 }),
253 ),
254 Match.when("stored", () => loginStored(profileName)),
255 Match.when("oauth", () => loginOAuth(profileName)),
256 Match.exhaustive,
257 ),
258 ),
259 );
260
261 const configureCredentials = (profileName: string) =>
262 configureInteractive(profileName).pipe(
263 Effect.mapError(
264 (e) =>
265 new AuthError({
266 message: "failed to configure credentials",
267 cause: e,
268 }),
269 ),
270 );
271
272 /**
273 * Flag-driven (`--method stored --set ...` / `--method env`) fields,
274 * mirroring the interactive prompts. OAuth requires a browser and stays
275 * interactive-only, so it is deliberately absent.
276 */
277 const tokenFields: ReadonlyArray<ConfigureField> = [
278 { name: "token", label: "Railway API Token", secret: true },
279 {
280 name: "apiBaseUrl",
281 label: "Railway API URL",
282 placeholder: DEFAULT_API_BASE_URL,
283 optional: true,
284 },
285 ];
286
287 const configureMethods: ReadonlyArray<ConfigureMethod> = [
288 { method: "stored", fields: tokenFields },
289 { method: "env", fields: [] },
290 ];
291
292 const configureWith = (
293 _profileName: string,
294 input: {
295 readonly method: string;
296 readonly values: Record<string, string>;
297 },
298 ): Effect.Effect<RailwayAuthConfig, AuthError, Interaction.Interaction> =>
299 input.method === "stored"
300 ? validateFieldValues(
301 RAILWAY_AUTH_PROVIDER_NAME,
302 tokenFields,
303 input.values,
304 ).pipe(
305 Effect.map((values) => ({
306 method: "stored" as const,
307 token: Redacted.value(
308 storedSecret(values.token) ?? Redacted.make(""),
309 ),
310 apiBaseUrl: storedValueText(values.apiBaseUrl),
311 })),
312 Effect.tap(() =>
313 interaction.output.success("Railway: credentials saved."),
314 ),
315 )
316 : input.method === "env"
317 ? Effect.succeed({ method: "env" as const })
318 : Effect.fail(
319 new AuthError({
320 message: `Railway: unknown method '${input.method}'. Valid methods: stored, env. (OAuth is interactive-only.)`,
321 }),
322 );
323
324 const readStoredToken = (
325 config: Extract<RailwayAuthConfig, { method: "stored" | "oauth" }>,
326 ): Effect.Effect<RailwayResolvedCredentials, AuthError> =>
327 Effect.gen(function* () {
328 const apiBaseUrl = yield* resolveApiBaseUrl(config.apiBaseUrl);
329 return {
330 type: "token" as const,
331 token: Redacted.make(config.token),
332 tokenKind: "account" as const,
333 apiBaseUrl,
334 source: { type: config.method },
335 };
336 });
337
338 const resolveCredentials = (
339 profileName: string,
340 config: RailwayAuthConfig,
341 ): Effect.Effect<RailwayResolvedCredentials, AuthError | NeedsReauth> =>
342 Effect.gen(function* () {
343 return yield* Match.value(config).pipe(
344 Match.when(
345 { method: "env" },
346 Effect.fn(function* () {
347 const token = yield* getEnvRedacted(RAILWAY_API_TOKEN_ENV);
348 if (!token) {
349 return yield* new AuthError({
350 message: `Railway env credentials not found. Set ${RAILWAY_API_TOKEN_ENV}.`,
351 });
352 }
353 const apiBaseUrl = yield* resolveApiBaseUrl();
354 return {
355 type: "token" as const,
356 token,
357 tokenKind: "account" as const,
358 apiBaseUrl,
359 source: {
360 type: "env" as const,
361 details: RAILWAY_API_TOKEN_ENV,
362 },
363 } satisfies RailwayResolvedCredentials;
364 }),
365 ),
366 Match.when({ method: "stored" }, readStoredToken),
367 Match.when({ method: "oauth" }, readStoredToken),
368 Match.exhaustive,
369 );
370 });
371
372 const logout = (_profileName: string, config: RailwayAuthConfig) =>
373 Match.value(config).pipe(
374 Match.when({ method: "env" }, () => Effect.void),
375 Match.when({ method: "stored" }, () => Effect.void),
376 // Railway account tokens have no revocation endpoint for CLI-session
377 // tokens, so logout just drops the locally stored token.
378 Match.when({ method: "oauth" }, () => Effect.void),
379 Match.exhaustive,
380 );
381
382 const login = (profileName: string, config: RailwayAuthConfig) =>
383 Match.value(config)
384 .pipe(
385 Match.when({ method: "env" }, () =>
386 getEnvRedacted(RAILWAY_API_TOKEN_ENV).pipe(
387 Effect.flatMap((token) =>
388 token
389 ? Effect.void
390 : Effect.fail(
391 new AuthError({
392 message:
393 `Railway: ${RAILWAY_API_TOKEN_ENV} is not set. Export it, or run ` +
394 `\`alchemy profile edit --profile ${profileName} --reconfigure ${RAILWAY_AUTH_PROVIDER_NAME}\` to switch methods.`,
395 }),
396 ),
397 ),
398 ),
399 ),
400 // Railway account tokens neither expire nor refresh, so login only
401 // (re-)prompts when no credential is stored yet.
402 Match.when({ method: "stored" }, (config) => Effect.succeed(config)),
403 Match.when({ method: "oauth" }, (config) => Effect.succeed(config)),
404 Match.exhaustive,
405 )
406 .pipe(
407 Effect.mapError((e) =>
408 e instanceof AuthError
409 ? e
410 : new AuthError({ message: "login failed", cause: e }),
411 ),
412 );
413
414 const details = (
415 profileName: string,
416 config: RailwayAuthConfig,
417 ): Effect.Effect<ProviderDetails, AuthError | NeedsReauth> =>
418 resolveCredentials(profileName, config).pipe(
419 Effect.map((creds) => ({
420 lines: [
421 { key: "token", value: displayRedacted(creds.token, 6) },
422 { key: "tokenKind", value: creds.tokenKind },
423 { key: "apiBaseUrl", value: creds.apiBaseUrl },
424 {
425 key: "source",
426 value: creds.source.details
427 ? `${creds.source.type} - ${creds.source.details}`
428 : creds.source.type,
429 },
430 ],
431 })),
432 );
433
434 const readEnvironment = Effect.gen(function* () {
435 const token = yield* getEnvRedactedRequired(RAILWAY_API_TOKEN_ENV);
436 const apiBaseUrl = yield* resolveApiBaseUrl();
437 return {
438 type: "token" as const,
439 token,
440 tokenKind: "account" as const,
441 apiBaseUrl,
442 source: { type: "env" as const, details: RAILWAY_API_TOKEN_ENV },
443 } satisfies RailwayResolvedCredentials;
444 });
445
446 return {
447 configSchema: RailwayAuthConfigSchema,
448 configure: configureCredentials,
449 configureWith,
450 configureMethods,
451 logout,
452 login,
453 details,
454 read: resolveCredentials,
455 readEnvironment,
456 environment: [
457 {
458 name: RAILWAY_API_TOKEN_ENV,
459 required: true,
460 secret: true,
461 description: "Account API token (Bearer).",
462 },
463 {
464 name: RAILWAY_API_URL_ENV,
465 required: false,
466 description: "Backboard API base URL override.",
467 },
468 ],
469 };
470 }),
471);