Skip to content

Commit 5795f3e

Browse files
committed
fix(showcase): harden railway-token resolver (whitespace/non-object guards, type-safe narrowing)
Treat whitespace-only token strings as empty so the resolver falls through to the next candidate instead of returning a bearer that fails at Railway with a confusing 401. Guard against non-object JSON input (null/undefined/string/number/array) defensively before property access — the config originates from JSON.parse of ~/.railway/config.json (untrusted). Replace non-null assertions on re-accessed expressions with locally-captured values so the nonEmpty narrowing actually applies; no behavior change for the happy path. Comment cleanups: replace stale line-number reference with a symbolic one, reword the Ruby-parity note to acknowledge the per-project token fallback that is intentionally not honored, drop the rotting "(43+ chars)" parenthetical, and soften the deprecation timeline to "a future release."
1 parent f7bb1dc commit 5795f3e

2 files changed

Lines changed: 109 additions & 15 deletions

File tree

showcase/scripts/lib/__tests__/railway-token.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,83 @@ describe("resolveRailwayTokenFromConfig", () => {
6666
const result = resolveRailwayTokenFromConfig(cfg, { warn });
6767
expect(result).toBeUndefined();
6868
});
69+
70+
it("treats whitespace-only user.accessToken as empty and falls through", () => {
71+
const cfg: RailwayConfigShape = {
72+
user: { accessToken: " " },
73+
};
74+
const warn = vi.fn();
75+
const result = resolveRailwayTokenFromConfig(cfg, { warn });
76+
expect(result).toBeUndefined();
77+
expect(warn).not.toHaveBeenCalled();
78+
});
79+
80+
it("falls through whitespace-only user.accessToken to legacy user.token with warn", () => {
81+
const cfg: RailwayConfigShape = {
82+
user: {
83+
accessToken: "\n\t ",
84+
token: "legacy-short-token-aaaaaaaaaa",
85+
},
86+
};
87+
const warn = vi.fn();
88+
const result = resolveRailwayTokenFromConfig(cfg, { warn });
89+
expect(result).toBe("legacy-short-token-aaaaaaaaaa");
90+
expect(warn).toHaveBeenCalledTimes(1);
91+
});
92+
93+
it("treats whitespace-only tokens at every layer as empty", () => {
94+
const cfg: RailwayConfigShape = {
95+
user: { accessToken: " ", token: "\t" },
96+
accessToken: "\n",
97+
token: " ",
98+
};
99+
const warn = vi.fn();
100+
const result = resolveRailwayTokenFromConfig(cfg, { warn });
101+
expect(result).toBeUndefined();
102+
expect(warn).not.toHaveBeenCalled();
103+
});
104+
105+
it("returns undefined when config is null", () => {
106+
const warn = vi.fn();
107+
const result = resolveRailwayTokenFromConfig(null, { warn });
108+
expect(result).toBeUndefined();
109+
expect(warn).not.toHaveBeenCalled();
110+
});
111+
112+
it("returns undefined when config is undefined", () => {
113+
const warn = vi.fn();
114+
const result = resolveRailwayTokenFromConfig(undefined, { warn });
115+
expect(result).toBeUndefined();
116+
expect(warn).not.toHaveBeenCalled();
117+
});
118+
119+
it("returns undefined when config is a string (non-object JSON)", () => {
120+
const warn = vi.fn();
121+
const result = resolveRailwayTokenFromConfig(
122+
"not-an-object" as unknown as RailwayConfigShape,
123+
{ warn },
124+
);
125+
expect(result).toBeUndefined();
126+
expect(warn).not.toHaveBeenCalled();
127+
});
128+
129+
it("returns undefined when config is a number", () => {
130+
const warn = vi.fn();
131+
const result = resolveRailwayTokenFromConfig(
132+
42 as unknown as RailwayConfigShape,
133+
{ warn },
134+
);
135+
expect(result).toBeUndefined();
136+
expect(warn).not.toHaveBeenCalled();
137+
});
138+
139+
it("returns undefined when config is an array", () => {
140+
const warn = vi.fn();
141+
const result = resolveRailwayTokenFromConfig(
142+
[] as unknown as RailwayConfigShape,
143+
{ warn },
144+
);
145+
expect(result).toBeUndefined();
146+
expect(warn).not.toHaveBeenCalled();
147+
});
69148
});

showcase/scripts/lib/railway-token.ts

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
/**
22
* railway-token.ts — Shared resolver for the Railway GraphQL bearer.
33
*
4-
* The Railway CLI stores the public-GraphQL bearer in `user.accessToken`
5-
* (43+ chars). The shorter `user.token` is a legacy CLI session token
6-
* that does NOT authenticate to the public GraphQL API. Older configs
7-
* still on disk have `user.token` set and `user.accessToken` empty;
8-
* those callers get a one-cycle deprecation warning and still work.
4+
* The Railway CLI stores the public-GraphQL bearer in `user.accessToken`.
5+
* The shorter `user.token` is a legacy CLI session token that does NOT
6+
* authenticate to the public GraphQL API. Older configs still on disk
7+
* have `user.token` set and `user.accessToken` empty; those callers get
8+
* a one-cycle deprecation warning and still work.
99
*
10-
* Resolution order mirrors `showcase/bin/railway:115-119`:
10+
* Resolution order matches the first four candidates of
11+
* `showcase/bin/railway` `Auth.token`; the per-project
12+
* `projects.<id>.token` fallback is intentionally not honored (no
13+
* project-scoped tokens here):
1114
* 1. user.accessToken
1215
* 2. accessToken (top-level)
1316
* 3. user.token (legacy → warn)
@@ -35,29 +38,41 @@ const DEPRECATION_MESSAGE =
3538
"`user.token` / top-level `token` no longer authenticates the public " +
3639
"GraphQL API. The Railway CLI now writes `user.accessToken`; re-run " +
3740
"`railway login` to refresh ~/.railway/config.json. Support for the " +
38-
"legacy field will be removed in the next release cycle.";
41+
"legacy field will be removed in a future release.";
3942

4043
function nonEmpty(v: unknown): v is string {
41-
return typeof v === "string" && v.length > 0;
44+
return typeof v === "string" && v.trim().length > 0;
4245
}
4346

4447
export function resolveRailwayTokenFromConfig(
4548
config: RailwayConfigShape | null | undefined,
4649
deps: ResolverDeps = {},
4750
): string | undefined {
48-
if (!config) return undefined;
51+
// Defensive guard: config originates from JSON.parse of
52+
// ~/.railway/config.json (untrusted). Reject anything that isn't a
53+
// plain object before property access.
54+
if (config === null || config === undefined) return undefined;
55+
if (typeof config !== "object") return undefined;
56+
if (Array.isArray(config)) return undefined;
57+
4958
const warn = deps.warn ?? ((m: string) => console.warn(m));
5059

51-
if (nonEmpty(config.user?.accessToken)) return config.user!.accessToken;
52-
if (nonEmpty(config.accessToken)) return config.accessToken;
60+
const userAccess = config.user?.accessToken;
61+
if (nonEmpty(userAccess)) return userAccess;
62+
63+
const topAccess = config.accessToken;
64+
if (nonEmpty(topAccess)) return topAccess;
5365

54-
if (nonEmpty(config.user?.token)) {
66+
const userLegacy = config.user?.token;
67+
if (nonEmpty(userLegacy)) {
5568
warn(DEPRECATION_MESSAGE);
56-
return config.user!.token;
69+
return userLegacy;
5770
}
58-
if (nonEmpty(config.token)) {
71+
72+
const topLegacy = config.token;
73+
if (nonEmpty(topLegacy)) {
5974
warn(DEPRECATION_MESSAGE);
60-
return config.token;
75+
return topLegacy;
6176
}
6277
return undefined;
6378
}

0 commit comments

Comments
 (0)