@quenty/nevermore-cli-helpers 1.8.1 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +10 -47
  2. package/dist/auth/cookie/cookie-parser.d.ts +7 -0
  3. package/dist/auth/cookie/cookie-parser.d.ts.map +1 -0
  4. package/dist/auth/cookie/cookie-parser.js +18 -0
  5. package/dist/auth/cookie/cookie-parser.js.map +1 -0
  6. package/dist/auth/cookie/cookie-parser.test.d.ts +2 -0
  7. package/dist/auth/cookie/cookie-parser.test.d.ts.map +1 -0
  8. package/dist/auth/cookie/cookie-parser.test.js +32 -0
  9. package/dist/auth/cookie/cookie-parser.test.js.map +1 -0
  10. package/dist/auth/cookie/index.d.ts +41 -0
  11. package/dist/auth/cookie/index.d.ts.map +1 -0
  12. package/dist/auth/cookie/index.js +188 -0
  13. package/dist/auth/cookie/index.js.map +1 -0
  14. package/dist/auth/cookie/linux.d.ts +14 -0
  15. package/dist/auth/cookie/linux.d.ts.map +1 -0
  16. package/dist/auth/cookie/linux.js +147 -0
  17. package/dist/auth/cookie/linux.js.map +1 -0
  18. package/dist/auth/cookie/macos.d.ts +2 -0
  19. package/dist/auth/cookie/macos.d.ts.map +1 -0
  20. package/dist/auth/cookie/macos.js +66 -0
  21. package/dist/auth/cookie/macos.js.map +1 -0
  22. package/dist/auth/cookie/validate-cookie.test.d.ts +2 -0
  23. package/dist/auth/cookie/validate-cookie.test.d.ts.map +1 -0
  24. package/dist/auth/cookie/validate-cookie.test.js +27 -0
  25. package/dist/auth/cookie/validate-cookie.test.js.map +1 -0
  26. package/dist/auth/cookie/windows.d.ts +2 -0
  27. package/dist/auth/cookie/windows.d.ts.map +1 -0
  28. package/dist/auth/cookie/windows.js +76 -0
  29. package/dist/auth/cookie/windows.js.map +1 -0
  30. package/dist/auth/open-cloud/credential-store.d.ts +14 -0
  31. package/dist/auth/open-cloud/credential-store.d.ts.map +1 -0
  32. package/dist/auth/open-cloud/credential-store.js +108 -0
  33. package/dist/auth/open-cloud/credential-store.js.map +1 -0
  34. package/dist/utils.d.ts +5 -0
  35. package/dist/utils.d.ts.map +1 -1
  36. package/dist/utils.js +3 -0
  37. package/dist/utils.js.map +1 -1
  38. package/dist/version-checker.d.ts +2 -0
  39. package/dist/version-checker.d.ts.map +1 -1
  40. package/dist/version-checker.js +22 -20
  41. package/dist/version-checker.js.map +1 -1
  42. package/package.json +8 -4
  43. package/src/auth/cookie/cookie-parser.test.ts +38 -0
  44. package/src/auth/cookie/cookie-parser.ts +18 -0
  45. package/src/auth/cookie/index.ts +271 -0
  46. package/src/auth/cookie/linux.ts +175 -0
  47. package/src/auth/cookie/macos.ts +88 -0
  48. package/src/auth/cookie/validate-cookie.test.ts +39 -0
  49. package/src/auth/cookie/windows.ts +96 -0
  50. package/src/auth/open-cloud/credential-store.ts +149 -0
  51. package/src/utils.ts +25 -0
  52. package/src/version-checker.ts +28 -24
  53. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Cookie auth and place creation for Roblox legacy APIs.
3
+ * Based on Mantle: https://github.com/blake-mealey/mantle
4
+ */
5
+
6
+ import inquirer from 'inquirer';
7
+ import { OutputHelper } from '@quenty/cli-output-helpers';
8
+ import { COOKIE_NAME } from './cookie-parser.js';
9
+ import { readCookie as readWindowsCookie } from './windows.js';
10
+ import { readCookie as readMacOSCookie } from './macos.js';
11
+ import { readCookie as readLinuxCookie } from './linux.js';
12
+
13
+ /**
14
+ * Resolve the .ROBLOSECURITY cookie for legacy Roblox API calls.
15
+ *
16
+ * Resolution order (matching Mantle's rbx_cookie crate):
17
+ * 1. ROBLOSECURITY environment variable
18
+ * 2. Platform credential store (Windows Credential Manager / macOS HTTPStorages / Wine Credential Manager)
19
+ * 3. Platform legacy store (Windows Registry / macOS plist)
20
+ * 4. Interactive prompt
21
+ */
22
+ export async function getRobloxCookieAsync(): Promise<string> {
23
+ const envCookie = process.env.ROBLOSECURITY;
24
+ if (envCookie) {
25
+ return envCookie;
26
+ }
27
+
28
+ const platformCookie = readPlatformCookie();
29
+ if (platformCookie) {
30
+ return platformCookie;
31
+ }
32
+
33
+ // No interactive prompt in non-TTY environments (CI)
34
+ if (!process.stdin.isTTY) {
35
+ throw new Error(
36
+ 'No .ROBLOSECURITY cookie available (set ROBLOSECURITY env var for CI)'
37
+ );
38
+ }
39
+
40
+ const { cookie } = await inquirer.prompt([
41
+ {
42
+ type: 'password',
43
+ name: 'cookie',
44
+ message: 'Enter your .ROBLOSECURITY cookie (from browser or Studio):',
45
+ mask: '*',
46
+ validate: (input: string) => input.length > 0 || 'Cookie cannot be empty',
47
+ },
48
+ ]);
49
+
50
+ return cookie;
51
+ }
52
+
53
+ function readPlatformCookie(): string | undefined {
54
+ switch (process.platform) {
55
+ case 'win32':
56
+ return readWindowsCookie();
57
+ case 'darwin':
58
+ return readMacOSCookie();
59
+ case 'linux':
60
+ return readLinuxCookie();
61
+ default:
62
+ return undefined;
63
+ }
64
+ }
65
+
66
+ interface CsrfFetchResult {
67
+ response: Response;
68
+ rotatedCookie?: string;
69
+ }
70
+
71
+ /**
72
+ * Extract a rotated .ROBLOSECURITY cookie from a response's set-cookie header.
73
+ */
74
+ function extractRotatedCookie(response: Response): string | undefined {
75
+ const setCookie = response.headers.get('set-cookie');
76
+ if (!setCookie) {
77
+ return undefined;
78
+ }
79
+
80
+ // set-cookie may contain multiple cookies separated by commas (or multiple headers).
81
+ // Look for .ROBLOSECURITY=<value>.
82
+ const match = setCookie.match(/\.ROBLOSECURITY=([^;,\s]+)/);
83
+ return match?.[1];
84
+ }
85
+
86
+ /**
87
+ * Make a cookie-authenticated request to Roblox, handling CSRF token exchange,
88
+ * cookie rotation capture, and 429 rate-limit retries.
89
+ */
90
+ async function fetchWithCsrfAsync(
91
+ url: string,
92
+ cookie: string,
93
+ options: RequestInit = {}
94
+ ): Promise<CsrfFetchResult> {
95
+ const headers: Record<string, string> = {
96
+ Cookie: `${COOKIE_NAME}=${cookie}`,
97
+ 'User-Agent': 'Roblox/WinInet',
98
+ ...(options.headers as Record<string, string> | undefined),
99
+ };
100
+
101
+ let response = await fetch(url, {
102
+ ...options,
103
+ headers,
104
+ });
105
+
106
+ if (response.status === 403) {
107
+ const csrfToken = response.headers.get('x-csrf-token');
108
+ if (csrfToken) {
109
+ headers['X-CSRF-TOKEN'] = csrfToken;
110
+ response = await fetch(url, {
111
+ ...options,
112
+ headers,
113
+ });
114
+ }
115
+ }
116
+
117
+ // Retry once on rate limit
118
+ if (response.status === 429) {
119
+ const retryAfter = response.headers.get('retry-after');
120
+ const delaySec = retryAfter
121
+ ? Math.min(parseInt(retryAfter, 10) || 2, 30)
122
+ : 2;
123
+ OutputHelper.verbose(`Rate limited (429). Retrying after ${delaySec}s...`);
124
+ await new Promise((resolve) => setTimeout(resolve, delaySec * 1000));
125
+ response = await fetch(url, { ...options, headers });
126
+ }
127
+
128
+ const rotatedCookie = extractRotatedCookie(response);
129
+ if (rotatedCookie) {
130
+ OutputHelper.verbose(
131
+ 'Captured rotated .ROBLOSECURITY cookie from response.'
132
+ );
133
+ }
134
+
135
+ return { response, rotatedCookie };
136
+ }
137
+
138
+ /**
139
+ * Create a new place in a universe using the legacy cookie-authenticated API.
140
+ * Returns the new place ID.
141
+ */
142
+ export async function createPlaceInUniverseAsync(
143
+ cookie: string,
144
+ universeId: number,
145
+ placeName: string
146
+ ): Promise<number> {
147
+ OutputHelper.verbose(
148
+ `Creating place "${placeName}" in universe ${universeId}...`
149
+ );
150
+
151
+ const createResult = await fetchWithCsrfAsync(
152
+ `https://apis.roblox.com/universes/v1/user/universes/${universeId}/places`,
153
+ cookie,
154
+ {
155
+ method: 'POST',
156
+ headers: {
157
+ 'Content-Type': 'application/json',
158
+ },
159
+ body: JSON.stringify({ templatePlaceId: 95206881 }),
160
+ }
161
+ );
162
+
163
+ if (!createResult.response.ok) {
164
+ const text = await createResult.response.text();
165
+ throw new Error(
166
+ `Failed to create place: ${createResult.response.status} ${createResult.response.statusText}: ${text}`
167
+ );
168
+ }
169
+
170
+ const createData = (await createResult.response.json()) as {
171
+ placeId: number;
172
+ };
173
+ const placeId = createData.placeId;
174
+
175
+ // Use rotated cookie if the create request triggered rotation
176
+ const { response: renameResponse } = await fetchWithCsrfAsync(
177
+ `https://develop.roblox.com/v2/places/${placeId}`,
178
+ createResult.rotatedCookie ?? cookie,
179
+ {
180
+ method: 'PATCH',
181
+ headers: {
182
+ 'Content-Type': 'application/json',
183
+ },
184
+ body: JSON.stringify({ name: placeName }),
185
+ }
186
+ );
187
+
188
+ if (!renameResponse.ok) {
189
+ OutputHelper.warn(
190
+ `Place created (${placeId}) but rename failed — you can rename it manually.`
191
+ );
192
+ }
193
+
194
+ OutputHelper.verbose(`Created place "${placeName}" — ID: ${placeId}`);
195
+ return placeId;
196
+ }
197
+
198
+ export interface CookieValidationResult {
199
+ valid: boolean;
200
+ reason?: 'invalid' | 'network_error';
201
+ status?: number;
202
+ }
203
+
204
+ /**
205
+ * Validates the ROBLOSECURITY cookie against the Roblox API.
206
+ * Returns a result indicating whether the cookie is valid.
207
+ * Network errors are treated as "unknown" (not invalid) so callers
208
+ * can decide whether to continue in offline scenarios.
209
+ */
210
+ export async function validateCookieAsync(
211
+ cookie: string
212
+ ): Promise<CookieValidationResult> {
213
+ try {
214
+ const response = await fetch(
215
+ 'https://users.roblox.com/v1/users/authenticated',
216
+ {
217
+ headers: {
218
+ Cookie: `.ROBLOSECURITY=${cookie}`,
219
+ },
220
+ }
221
+ );
222
+
223
+ if (response.status !== 200) {
224
+ return { valid: false, reason: 'invalid', status: response.status };
225
+ }
226
+
227
+ return { valid: true };
228
+ } catch {
229
+ return { valid: false, reason: 'network_error' };
230
+ }
231
+ }
232
+
233
+ export interface RenamePlaceResult {
234
+ success: boolean;
235
+ reason?: 'no_cookie' | 'api_error';
236
+ status?: number;
237
+ }
238
+
239
+ /**
240
+ * Try to rename an existing place via the develop.roblox.com API.
241
+ */
242
+ export async function tryRenamePlaceAsync(
243
+ placeId: number,
244
+ placeName: string
245
+ ): Promise<RenamePlaceResult> {
246
+ let cookie: string;
247
+ try {
248
+ cookie = await getRobloxCookieAsync();
249
+ } catch {
250
+ return { success: false, reason: 'no_cookie' };
251
+ }
252
+
253
+ const { response } = await fetchWithCsrfAsync(
254
+ `https://develop.roblox.com/v2/places/${placeId}`,
255
+ cookie,
256
+ {
257
+ method: 'PATCH',
258
+ headers: {
259
+ 'Content-Type': 'application/json',
260
+ },
261
+ body: JSON.stringify({ name: placeName }),
262
+ }
263
+ );
264
+
265
+ if (response.ok) {
266
+ OutputHelper.verbose(`Renamed place ${placeId} to "${placeName}"`);
267
+ return { success: true };
268
+ }
269
+
270
+ return { success: false, reason: 'api_error', status: response.status };
271
+ }
@@ -0,0 +1,175 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ import { OutputHelper } from '@quenty/cli-output-helpers';
5
+ import { COOKIE_NAME } from './cookie-parser.js';
6
+
7
+ /**
8
+ * Read .ROBLOSECURITY from Wine's Credential Manager stored in the Wine
9
+ * registry file ($WINEPREFIX/user.reg).
10
+ *
11
+ * Wine stores Windows Credential Manager entries as registry keys under
12
+ * [Software\\Wine\\Credential Manager]. Each credential target becomes a
13
+ * subkey with hex-encoded blob values.
14
+ *
15
+ * Resolution order (mirrors windows.ts):
16
+ * 1. Modern: user-specific credential (RobloxStudioAuth.ROBLOSECURITY{userId})
17
+ * 2. Legacy: RobloxStudioAuth.ROBLOSECURITY (no user suffix)
18
+ */
19
+ export function readCookie(): string | undefined {
20
+ const userReg = getWineUserRegPath();
21
+ if (!userReg || !fs.existsSync(userReg)) {
22
+ return undefined;
23
+ }
24
+
25
+ let regContent: string;
26
+ try {
27
+ regContent = fs.readFileSync(userReg, 'utf-8');
28
+ } catch {
29
+ return undefined;
30
+ }
31
+
32
+ const credentials = parseWineCredentials(regContent);
33
+
34
+ // Modern: user-specific credential
35
+ const userId = credentials.get(
36
+ 'https://www.roblox.com:RobloxStudioAuthuserid'
37
+ );
38
+ if (userId) {
39
+ const cookie = credentials.get(
40
+ `https://www.roblox.com:RobloxStudioAuth${COOKIE_NAME}${userId}`
41
+ );
42
+ if (cookie) {
43
+ OutputHelper.verbose(
44
+ `Loaded cookie from Wine Credential Manager (user ${userId}).`
45
+ );
46
+ return cookie;
47
+ }
48
+ }
49
+
50
+ // Legacy: no user suffix
51
+ const legacyCookie = credentials.get(
52
+ `https://www.roblox.com:RobloxStudioAuth${COOKIE_NAME}`
53
+ );
54
+ if (legacyCookie) {
55
+ OutputHelper.verbose(
56
+ 'Loaded cookie from Wine Credential Manager (legacy).'
57
+ );
58
+ return legacyCookie;
59
+ }
60
+
61
+ return undefined;
62
+ }
63
+
64
+ function getWineUserRegPath(): string | undefined {
65
+ const wineprefix = process.env.WINEPREFIX || path.join(os.homedir(), '.wine');
66
+ return path.join(wineprefix, 'user.reg');
67
+ }
68
+
69
+ /**
70
+ * Parse Wine's user.reg file for Credential Manager entries.
71
+ *
72
+ * Wine stores credentials under registry keys like:
73
+ * [Software\\Wine\\Credential Manager]
74
+ *
75
+ * Each credential is a named value where the name is the target and the
76
+ * value is a hex-encoded binary blob. The credential blob (the actual
77
+ * secret) is stored as UTF-8 bytes within the binary structure.
78
+ *
79
+ * Returns a Map of target name -> credential value (decoded string).
80
+ */
81
+ function parseWineCredentials(regContent: string): Map<string, string> {
82
+ const credentials = new Map<string, string>();
83
+
84
+ // Wine Credential Manager stores creds as individual hex blobs under
85
+ // [Software\\Wine\\Credential Manager]. The key format is:
86
+ // "Target Name"=hex:xx,xx,xx,...
87
+ // The hex blob is a serialized CREDENTIAL struct.
88
+ const credSectionMatch = regContent.match(
89
+ /\[Software\\\\Wine\\\\Credential Manager\]([\s\S]*?)(?=\n\[|$)/i
90
+ );
91
+ if (!credSectionMatch) {
92
+ return credentials;
93
+ }
94
+
95
+ const section = credSectionMatch[1];
96
+
97
+ // Match each credential entry: "TargetName"=hex:bytes
98
+ const entryRegex = /^"(.+?)"=hex:(.+)$/gm;
99
+ let match;
100
+ while ((match = entryRegex.exec(section)) !== null) {
101
+ const targetName = unescapeRegString(match[1]);
102
+ const hexStr = match[2].replace(/\\\n\s*/g, '').replace(/,/g, '');
103
+
104
+ try {
105
+ const blob = Buffer.from(hexStr, 'hex');
106
+ const value = extractCredentialBlob(blob);
107
+ if (value) {
108
+ credentials.set(targetName, value);
109
+ }
110
+ } catch {
111
+ // Malformed hex data
112
+ }
113
+ }
114
+
115
+ return credentials;
116
+ }
117
+
118
+ /**
119
+ * Extract the credential value from a Wine serialized CREDENTIAL blob.
120
+ *
121
+ * The blob layout follows the Windows CREDENTIAL struct. The credential
122
+ * value (CredentialBlob) is stored as UTF-8 bytes. We look for the
123
+ * actual cookie/value content by searching for known patterns.
124
+ */
125
+ function extractCredentialBlob(blob: Buffer): string | undefined {
126
+ // Wine's serialized credential format stores the blob data inline.
127
+ // The simplest approach: the credential value for Roblox entries is
128
+ // plain UTF-8 text. Try to find it by looking for cookie-like content
129
+ // or numeric user IDs.
130
+
131
+ // Try interpreting the entire blob as UTF-8 and looking for the value
132
+ const text = blob.toString('utf-8');
133
+
134
+ // For simple values (user IDs, cookie names), the blob may just be
135
+ // the raw UTF-8 string
136
+ if (text && isPrintableAscii(text)) {
137
+ return text;
138
+ }
139
+
140
+ // For structured blobs, search for the credential data section.
141
+ // Wine writes a serialized struct — scan for the longest printable
142
+ // ASCII substring that looks like a credential value.
143
+ let best = '';
144
+ let current = '';
145
+ for (let i = 0; i < blob.length; i++) {
146
+ const byte = blob[i];
147
+ if (byte >= 0x20 && byte < 0x7f) {
148
+ current += String.fromCharCode(byte);
149
+ } else {
150
+ if (current.length > best.length) {
151
+ best = current;
152
+ }
153
+ current = '';
154
+ }
155
+ }
156
+ if (current.length > best.length) {
157
+ best = current;
158
+ }
159
+
160
+ return best.length > 0 ? best : undefined;
161
+ }
162
+
163
+ function isPrintableAscii(str: string): boolean {
164
+ for (let i = 0; i < str.length; i++) {
165
+ const code = str.charCodeAt(i);
166
+ if (code < 0x20 || code > 0x7e) {
167
+ return false;
168
+ }
169
+ }
170
+ return str.length > 0;
171
+ }
172
+
173
+ function unescapeRegString(str: string): string {
174
+ return str.replace(/\\\\/g, '\\');
175
+ }
@@ -0,0 +1,88 @@
1
+ import { execSync } from 'child_process';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import * as os from 'os';
5
+ import { OutputHelper } from '@quenty/cli-output-helpers';
6
+
7
+ /**
8
+ * Read from ~/Library/HTTPStorages/com.Roblox.RobloxStudio.binarycookies.
9
+ * This is a binary format — we shell out to Python to parse it since Node
10
+ * doesn't have a native binarycookies parser.
11
+ */
12
+ function readFromHTTPStorages(): string | undefined {
13
+ const cookiePath = path.join(
14
+ os.homedir(),
15
+ 'Library/HTTPStorages/com.Roblox.RobloxStudio.binarycookies'
16
+ );
17
+
18
+ if (!fs.existsSync(cookiePath)) {
19
+ return undefined;
20
+ }
21
+
22
+ const pyScript = `
23
+ import struct, sys
24
+ with open(sys.argv[1], 'rb') as f:
25
+ data = f.read()
26
+ idx = data.find(b'_|WARNING')
27
+ if idx >= 0:
28
+ end = data.find(b'\\x00', idx)
29
+ if end < 0: end = len(data)
30
+ print(data[idx:end].decode('utf-8', errors='ignore'))
31
+ `.trim();
32
+
33
+ try {
34
+ const result = execSync(
35
+ `python3 -c ${JSON.stringify(pyScript)} ${JSON.stringify(cookiePath)}`,
36
+ { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }
37
+ ).trim();
38
+
39
+ if (result && result.startsWith('_|')) {
40
+ OutputHelper.verbose('Loaded cookie from macOS HTTPStorages.');
41
+ return result;
42
+ }
43
+ } catch {
44
+ // Python parse failed
45
+ }
46
+
47
+ return undefined;
48
+ }
49
+
50
+ function readFromPlist(): string | undefined {
51
+ const plistPath = path.join(
52
+ os.homedir(),
53
+ 'Library/Preferences/com.roblox.RobloxStudioBrowser.plist'
54
+ );
55
+
56
+ if (!fs.existsSync(plistPath)) {
57
+ return undefined;
58
+ }
59
+
60
+ try {
61
+ const result = execSync(
62
+ `defaults read com.roblox.RobloxStudioBrowser 2>/dev/null | grep ROBLOSECURITY`,
63
+ { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }
64
+ ).trim();
65
+
66
+ if (result) {
67
+ const cookieMatch = result.match(/COOK::<(.+?)>/);
68
+ if (cookieMatch) {
69
+ OutputHelper.verbose('Loaded cookie from macOS plist.');
70
+ return cookieMatch[1];
71
+ }
72
+
73
+ const valueMatch = result.match(/"([^"]*_\|[^"]*)"/);
74
+ if (valueMatch) {
75
+ OutputHelper.verbose('Loaded cookie from macOS plist.');
76
+ return valueMatch[1];
77
+ }
78
+ }
79
+ } catch {
80
+ // plist read failed
81
+ }
82
+
83
+ return undefined;
84
+ }
85
+
86
+ export function readCookie(): string | undefined {
87
+ return readFromHTTPStorages() ?? readFromPlist();
88
+ }
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ import { validateCookieAsync } from './index.js';
4
+
5
+ describe('validateCookieAsync', () => {
6
+ const originalFetch = globalThis.fetch;
7
+
8
+ beforeEach(() => {
9
+ vi.restoreAllMocks();
10
+ });
11
+
12
+ afterEach(() => {
13
+ globalThis.fetch = originalFetch;
14
+ });
15
+
16
+ it('returns valid when cookie is accepted (HTTP 200)', async () => {
17
+ globalThis.fetch = vi.fn().mockResolvedValue({ status: 200 });
18
+
19
+ const result = await validateCookieAsync('valid-cookie');
20
+
21
+ expect(result).toEqual({ valid: true });
22
+ });
23
+
24
+ it('returns invalid with status when cookie is rejected (HTTP 401)', async () => {
25
+ globalThis.fetch = vi.fn().mockResolvedValue({ status: 401 });
26
+
27
+ const result = await validateCookieAsync('expired-cookie');
28
+
29
+ expect(result).toEqual({ valid: false, reason: 'invalid', status: 401 });
30
+ });
31
+
32
+ it('returns network_error when fetch throws', async () => {
33
+ globalThis.fetch = vi.fn().mockRejectedValue(new Error('network error'));
34
+
35
+ const result = await validateCookieAsync('some-cookie');
36
+
37
+ expect(result).toEqual({ valid: false, reason: 'network_error' });
38
+ });
39
+ });
@@ -0,0 +1,96 @@
1
+ import { execSync } from 'child_process';
2
+ import { OutputHelper } from '@quenty/cli-output-helpers';
3
+ import { COOKIE_NAME, parseStudioCookieValue } from './cookie-parser.js';
4
+
5
+ /**
6
+ * Read a generic credential from Windows Credential Manager via CredRead.
7
+ * The blob is decoded as UTF-8 (matching Mantle's wincred.rs).
8
+ */
9
+ function winCredRead(target: string): string | undefined {
10
+ const escapedTarget = target.replace(/'/g, "''");
11
+ const script = [
12
+ `Add-Type -TypeDefinition '`,
13
+ `using System; using System.Runtime.InteropServices; using System.Text;`,
14
+ `public class NevCred {`,
15
+ ` [DllImport("advapi32.dll",SetLastError=true,CharSet=CharSet.Unicode)]`,
16
+ ` public static extern bool CredRead(string t,int ty,int f,out IntPtr c);`,
17
+ ` [DllImport("advapi32.dll",SetLastError=true)]`,
18
+ ` public static extern bool CredFree(IntPtr c);`,
19
+ ` [StructLayout(LayoutKind.Sequential,CharSet=CharSet.Unicode)]`,
20
+ ` public struct CRED { public int F; public int T; public string TN; public string Co;`,
21
+ ` public System.Runtime.InteropServices.ComTypes.FILETIME LW;`,
22
+ ` public int CBS; public IntPtr CB; public int P; public int AC; public IntPtr At;`,
23
+ ` public string TA; public string UN; }`,
24
+ ` public static string Read(string t) {`,
25
+ ` IntPtr p; if(!CredRead(t,1,0,out p)) return null;`,
26
+ ` try { CRED c=(CRED)Marshal.PtrToStructure(p,typeof(CRED));`,
27
+ ` if(c.CBS<=0) return "";`,
28
+ ` byte[] b=new byte[c.CBS]; Marshal.Copy(c.CB,b,0,c.CBS);`,
29
+ ` return Encoding.UTF8.GetString(b);`,
30
+ ` } finally { CredFree(p); } } }`,
31
+ `'; [NevCred]::Read('${escapedTarget}')`,
32
+ ].join(' ');
33
+
34
+ try {
35
+ const result = execSync(
36
+ `powershell -NoProfile -ExecutionPolicy Bypass -Command "${script.replace(
37
+ /"/g,
38
+ '\\"'
39
+ )}"`,
40
+ { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] }
41
+ ).trim();
42
+ return result.length > 0 ? result : undefined;
43
+ } catch {
44
+ return undefined;
45
+ }
46
+ }
47
+
48
+ function readFromRegistry(): string | undefined {
49
+ try {
50
+ const script = `(Get-ItemProperty -Path 'HKCU:\\Software\\Roblox\\RobloxStudioBrowser\\roblox.com' -Name '${COOKIE_NAME}' -ErrorAction SilentlyContinue).'${COOKIE_NAME}'`;
51
+ const result = execSync(
52
+ `powershell -NoProfile -ExecutionPolicy Bypass -Command "${script}"`,
53
+ { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }
54
+ ).trim();
55
+
56
+ if (result && result.length > 10) {
57
+ const parsed = parseStudioCookieValue(result);
58
+ if (parsed) {
59
+ OutputHelper.verbose('Loaded cookie from Windows Registry.');
60
+ return parsed;
61
+ }
62
+ }
63
+ } catch {
64
+ // Registry read failed
65
+ }
66
+
67
+ return undefined;
68
+ }
69
+
70
+ export function readCookie(): string | undefined {
71
+ // Modern Studio: user-specific credential
72
+ const userId = winCredRead('https://www.roblox.com:RobloxStudioAuthuserid');
73
+ if (userId) {
74
+ const cookie = winCredRead(
75
+ `https://www.roblox.com:RobloxStudioAuth${COOKIE_NAME}${userId}`
76
+ );
77
+ if (cookie) {
78
+ OutputHelper.verbose(
79
+ `Loaded cookie from Windows Credentials (user ${userId}).`
80
+ );
81
+ return cookie;
82
+ }
83
+ }
84
+
85
+ // Legacy credential (no user ID suffix)
86
+ const legacyCookie = winCredRead(
87
+ `https://www.roblox.com:RobloxStudioAuth${COOKIE_NAME}`
88
+ );
89
+ if (legacyCookie) {
90
+ OutputHelper.verbose('Loaded cookie from Windows Credentials (legacy).');
91
+ return legacyCookie;
92
+ }
93
+
94
+ // Oldest fallback: Windows Registry
95
+ return readFromRegistry();
96
+ }