agent-dealer 1.2.1 → 1.2.2
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.
- package/bundle/server/dist/adapters/linear-inbox.js +9 -5
- package/bundle/server/dist/capacity/adapter.js +2 -0
- package/bundle/server/dist/capacity/claude-events.js +18 -0
- package/bundle/server/dist/capacity/claude-events.test.js +7 -0
- package/bundle/server/dist/capacity/codex-app-server.js +100 -10
- package/bundle/server/dist/capacity/codex-app-server.test.js +195 -2
- package/bundle/server/dist/capacity/cursor-individual-credentials.js +238 -0
- package/bundle/server/dist/capacity/cursor-individual-credentials.test.js +185 -0
- package/bundle/server/dist/capacity/cursor-individual.js +970 -0
- package/bundle/server/dist/capacity/cursor-individual.test.js +815 -0
- package/bundle/server/dist/capacity/muse.js +94 -43
- package/bundle/server/dist/capacity/muse.test.js +95 -13
- package/bundle/server/dist/capacity/service.js +1 -0
- package/bundle/server/dist/db/index.js +30 -0
- package/bundle/server/dist/db/schema.sql +27 -0
- package/bundle/server/dist/index.js +2 -0
- package/bundle/server/dist/repository/cursor-individual-billing.js +84 -0
- package/bundle/server/dist/repository/repository-mappings.js +46 -0
- package/bundle/server/dist/repository/repository-mappings.test.js +83 -0
- package/bundle/server/dist/repository/runtime-capacity.js +8 -3
- package/bundle/server/dist/repository/runtime-capacity.test.js +4 -0
- package/bundle/server/dist/routes/cursor-individual-billing.test.js +80 -0
- package/bundle/server/dist/routes/index.js +32 -0
- package/bundle/server/dist/routes/intake-linear.test.js +55 -1
- package/bundle/server/dist/routes/repository-mappings.js +22 -0
- package/bundle/server/dist/routes/repository-mappings.test.js +110 -0
- package/bundle/server/dist/routes/runtime-capacity.test.js +72 -2
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-0GDYfbaM.js +60 -0
- package/bundle/server/static-ui/assets/index-DUYzboWh.css +1 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/index.d.ts +6 -6
- package/bundle/shared/dist/linear-intake.d.ts +71 -0
- package/bundle/shared/dist/linear-intake.js +90 -0
- package/bundle/shared/dist/linear-intake.test.js +80 -1
- package/bundle/shared/dist/runtime-capacity.d.ts +89 -0
- package/bundle/shared/dist/runtime-capacity.js +60 -0
- package/bundle/shared/dist/runtime-capacity.test.js +1 -0
- package/bundle/shared/package.json +1 -1
- package/dist/doctor.js +6 -0
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-ChnI0F02.css +0 -1
- package/bundle/server/static-ui/assets/index-CyB1573Q.js +0 -60
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// packages/server/src/capacity/cursor-individual-credentials.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-250: isolated credential lookup for the experimental Cursor Individual
|
|
4
|
+
// dashboard adapter.
|
|
5
|
+
//
|
|
6
|
+
// The dashboard endpoints this feature calls are undocumented: the only
|
|
7
|
+
// credential available is the existing local Cursor login, whose file
|
|
8
|
+
// location and JSON shape are themselves not a supported contract and may
|
|
9
|
+
// change without notice. All of that risk lives in this one module:
|
|
10
|
+
//
|
|
11
|
+
// - Candidate files are tried in order; the first readable file wins.
|
|
12
|
+
// - Accepted JSON shapes are an explicit allowlist below — anything else
|
|
13
|
+
// reads `unparsable` (changed format), never a guess.
|
|
14
|
+
// - `cursorIndividualCredentialStatus()` (the diagnostics surface) returns
|
|
15
|
+
// presence/path/format ONLY — never secret material.
|
|
16
|
+
// - `loadCursorIndividualCredential()` additionally returns an in-memory
|
|
17
|
+
// `authHeader` for the dashboard HTTP call. The header value must never be
|
|
18
|
+
// persisted, returned to the browser, or logged — the adapter, repository,
|
|
19
|
+
// and route layers enforce that, and tests pin it.
|
|
20
|
+
//
|
|
21
|
+
// No silent opt-in or credential migration lives here: this module only
|
|
22
|
+
// *finds* credentials; whether they may be *used* is decided by the
|
|
23
|
+
// explicit `AGENT_DEALER_CURSOR_INDIVIDUAL_CAPACITY=experimental` opt-in in
|
|
24
|
+
// cursor-individual.ts, which short-circuits before touching this module.
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import os from "node:os";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
/** Explicit credential-file override (tests point this at tmp fixtures). */
|
|
29
|
+
export const CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV = "CURSOR_INDIVIDUAL_CREDENTIAL_FILE";
|
|
30
|
+
/** Home-directory override for tests (mirrors how OS home resolution works). */
|
|
31
|
+
export const CURSOR_INDIVIDUAL_HOME_ENV = "CURSOR_INDIVIDUAL_HOME";
|
|
32
|
+
/**
|
|
33
|
+
* Community-observed local Cursor login file. The location is undocumented
|
|
34
|
+
* and may move — when it does, reads degrade to `absent`/`unparsable`, never
|
|
35
|
+
* to a scan of unrelated files. Exactly one default candidate is listed on
|
|
36
|
+
* purpose: this module must not go hunting through the home directory.
|
|
37
|
+
*/
|
|
38
|
+
export const CURSOR_INDIVIDUAL_DEFAULT_RELATIVE_PATH = path.join(".cursor", "auth.json");
|
|
39
|
+
/**
|
|
40
|
+
* Accepted top-level token keys. The credential format is undocumented, so
|
|
41
|
+
* both camelCase and snake_case spellings are tolerated — but ONLY these
|
|
42
|
+
* keys. An object with none of them is a changed format (`unparsable`).
|
|
43
|
+
*/
|
|
44
|
+
export const CURSOR_INDIVIDUAL_TOKEN_KEYS = [
|
|
45
|
+
"token",
|
|
46
|
+
"accessToken",
|
|
47
|
+
"access_token",
|
|
48
|
+
"sessionToken",
|
|
49
|
+
"session_token",
|
|
50
|
+
"authToken",
|
|
51
|
+
"auth_token",
|
|
52
|
+
"apiKey",
|
|
53
|
+
"api_key",
|
|
54
|
+
"jwt",
|
|
55
|
+
];
|
|
56
|
+
/**
|
|
57
|
+
* Accepted top-level WorkOS user-id keys. The dashboard's session cookie is
|
|
58
|
+
* `<userId>%3A%3A<token>` (see `cursorIndividualAuthHeader`) — a bare token
|
|
59
|
+
* is not enough. Same allowlist discipline as the token keys: an explicit
|
|
60
|
+
* field here wins; otherwise the id is derived from the token's own JWT
|
|
61
|
+
* `sub` claim (see `decodeJwtSubject`). Either source is normalized through
|
|
62
|
+
* `normalizeWorkosUserId` to strip a provider prefix.
|
|
63
|
+
*/
|
|
64
|
+
export const CURSOR_INDIVIDUAL_USER_ID_KEYS = [
|
|
65
|
+
"userId",
|
|
66
|
+
"user_id",
|
|
67
|
+
"workosUserId",
|
|
68
|
+
"workos_user_id",
|
|
69
|
+
"sub",
|
|
70
|
+
];
|
|
71
|
+
/** Nested objects tolerated one level deep (same key allowlists apply). */
|
|
72
|
+
const NESTED_KEYS = ["auth", "data", "user"];
|
|
73
|
+
function homeDir() {
|
|
74
|
+
const override = process.env[CURSOR_INDIVIDUAL_HOME_ENV]?.trim();
|
|
75
|
+
if (override)
|
|
76
|
+
return override;
|
|
77
|
+
return os.homedir();
|
|
78
|
+
}
|
|
79
|
+
/** Ordered candidate files: explicit override first, then the local login. */
|
|
80
|
+
export function cursorIndividualCredentialCandidates() {
|
|
81
|
+
const override = process.env[CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV]?.trim();
|
|
82
|
+
if (override)
|
|
83
|
+
return [override];
|
|
84
|
+
return [path.join(homeDir(), CURSOR_INDIVIDUAL_DEFAULT_RELATIVE_PATH)];
|
|
85
|
+
}
|
|
86
|
+
function pickByKeys(obj, keys) {
|
|
87
|
+
for (const key of keys) {
|
|
88
|
+
const value = obj[key];
|
|
89
|
+
if (typeof value === "string" && value.trim())
|
|
90
|
+
return { key, value: value.trim() };
|
|
91
|
+
}
|
|
92
|
+
for (const nest of NESTED_KEYS) {
|
|
93
|
+
const inner = obj[nest];
|
|
94
|
+
if (inner && typeof inner === "object" && !Array.isArray(inner)) {
|
|
95
|
+
for (const key of keys) {
|
|
96
|
+
const value = inner[key];
|
|
97
|
+
if (typeof value === "string" && value.trim()) {
|
|
98
|
+
return { key: `${nest}.${key}`, value: value.trim() };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
function pickToken(obj) {
|
|
106
|
+
const picked = pickByKeys(obj, CURSOR_INDIVIDUAL_TOKEN_KEYS);
|
|
107
|
+
return picked ? { key: picked.key, token: picked.value } : null;
|
|
108
|
+
}
|
|
109
|
+
function pickUserId(obj) {
|
|
110
|
+
return pickByKeys(obj, CURSOR_INDIVIDUAL_USER_ID_KEYS)?.value ?? null;
|
|
111
|
+
}
|
|
112
|
+
function base64UrlDecode(segment) {
|
|
113
|
+
try {
|
|
114
|
+
const padded = segment.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(segment.length / 4) * 4, "=");
|
|
115
|
+
return Buffer.from(padded, "base64").toString("utf8");
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The WorkOS session id is normally the JWT's own `sub` claim — decoded
|
|
123
|
+
* locally (no network, no signature verification: this only *reads* an id
|
|
124
|
+
* already present in a credential the caller trusts) so a plain access-token
|
|
125
|
+
* file still yields a usable id without a separate stored field. WorkOS
|
|
126
|
+
* subjects are commonly provider-prefixed (e.g. `google-oauth2|user_abc`);
|
|
127
|
+
* the raw claim is returned as-is here — `normalizeWorkosUserId` strips the
|
|
128
|
+
* prefix, applied uniformly to both this and an explicit `userId` field so
|
|
129
|
+
* either source lands on the same bare id.
|
|
130
|
+
*/
|
|
131
|
+
export function decodeJwtSubject(token) {
|
|
132
|
+
const parts = token.split(".");
|
|
133
|
+
if (parts.length !== 3)
|
|
134
|
+
return null;
|
|
135
|
+
const json = base64UrlDecode(parts[1]);
|
|
136
|
+
if (json === null)
|
|
137
|
+
return null;
|
|
138
|
+
try {
|
|
139
|
+
const payload = JSON.parse(json);
|
|
140
|
+
if (!payload || typeof payload !== "object")
|
|
141
|
+
return null;
|
|
142
|
+
const sub = payload.sub;
|
|
143
|
+
return typeof sub === "string" && sub.trim() ? sub.trim() : null;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Strips a WorkOS connection-type prefix (`google-oauth2|user_abc` →
|
|
151
|
+
* `user_abc`): the dashboard's own session cookie carries the bare id after
|
|
152
|
+
* the final `|`, never the provider-qualified subject.
|
|
153
|
+
*/
|
|
154
|
+
export function normalizeWorkosUserId(id) {
|
|
155
|
+
const at = id.lastIndexOf("|");
|
|
156
|
+
return at === -1 ? id : id.slice(at + 1);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Dashboard auth scheme: a `WorkosCursorSessionToken` cookie of
|
|
160
|
+
* `<userId>%3A%3A<token>` (community-observed: cursor-pulse, oh-my-pi,
|
|
161
|
+
* vct-core) — the dashboard rejects a Bearer `Authorization` header, and the
|
|
162
|
+
* `::` delimiter itself is percent-encoded in the live cookie value, not
|
|
163
|
+
* sent literally. The scheme is part of the undocumented surface and may
|
|
164
|
+
* drift (drift reads `unparsable`/`forbidden` at the HTTP layer, never a
|
|
165
|
+
* credential guess here). Returns the `Cookie` header VALUE (the caller
|
|
166
|
+
* sends it under the `Cookie` header name).
|
|
167
|
+
*/
|
|
168
|
+
export function cursorIndividualAuthHeader(userId, token) {
|
|
169
|
+
return `WorkosCursorSessionToken=${userId}%3A%3A${token}`;
|
|
170
|
+
}
|
|
171
|
+
function defaultReadFile(filePath) {
|
|
172
|
+
try {
|
|
173
|
+
return fs.readFileSync(filePath, "utf8");
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Find a usable local Cursor credential. Never throws: unreadable files,
|
|
181
|
+
* invalid JSON, and unknown shapes all map to `absent`/`unparsable`.
|
|
182
|
+
* The raw token leaves this module only inside `authHeader`, held in memory
|
|
183
|
+
* for the single dashboard call the adapter makes with it.
|
|
184
|
+
*/
|
|
185
|
+
export function loadCursorIndividualCredential(readFile = defaultReadFile) {
|
|
186
|
+
for (const candidate of cursorIndividualCredentialCandidates()) {
|
|
187
|
+
const raw = readFile(candidate);
|
|
188
|
+
if (raw === null)
|
|
189
|
+
continue;
|
|
190
|
+
let parsed;
|
|
191
|
+
try {
|
|
192
|
+
parsed = JSON.parse(raw);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return { status: "unparsable", path: candidate, format: null };
|
|
196
|
+
}
|
|
197
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
198
|
+
return { status: "unparsable", path: candidate, format: null };
|
|
199
|
+
}
|
|
200
|
+
const picked = pickToken(parsed);
|
|
201
|
+
if (!picked)
|
|
202
|
+
return { status: "unparsable", path: candidate, format: null };
|
|
203
|
+
// The cookie needs a user id too — an explicit field wins; otherwise
|
|
204
|
+
// derive it from the token's own JWT `sub` claim. Neither present means
|
|
205
|
+
// this credential cannot build a usable session, same as any other
|
|
206
|
+
// format drift. Either source may be provider-prefixed
|
|
207
|
+
// (`google-oauth2|user_abc`) — normalize both the same way.
|
|
208
|
+
const rawUserId = pickUserId(parsed) ?? decodeJwtSubject(picked.token);
|
|
209
|
+
if (!rawUserId)
|
|
210
|
+
return { status: "unparsable", path: candidate, format: picked.key };
|
|
211
|
+
// Re-check AFTER normalization: an id ending in `|` (e.g. a bare
|
|
212
|
+
// `"google-oauth2|"` with nothing after it) is non-empty here but
|
|
213
|
+
// normalizes to "" — that must degrade too, not build a cookie with an
|
|
214
|
+
// empty user id.
|
|
215
|
+
const userId = normalizeWorkosUserId(rawUserId);
|
|
216
|
+
if (!userId)
|
|
217
|
+
return { status: "unparsable", path: candidate, format: picked.key };
|
|
218
|
+
return {
|
|
219
|
+
status: "found",
|
|
220
|
+
path: candidate,
|
|
221
|
+
format: picked.key,
|
|
222
|
+
authHeader: cursorIndividualAuthHeader(userId, picked.token),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
return { status: "absent", path: null, format: null };
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Diagnostics-safe credential presence: delegates to the loader, then
|
|
229
|
+
* strips the in-memory secret. Safe to expose via logs and API responses.
|
|
230
|
+
*/
|
|
231
|
+
export function cursorIndividualCredentialStatus(readFile = defaultReadFile) {
|
|
232
|
+
const credential = loadCursorIndividualCredential(readFile);
|
|
233
|
+
return {
|
|
234
|
+
present: credential.status === "found",
|
|
235
|
+
path: credential.path,
|
|
236
|
+
format: credential.format,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// packages/server/src/capacity/cursor-individual-credentials.test.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-250: credential lookup is isolated behind an adapter, returns no
|
|
4
|
+
// secret on diagnostics surfaces, and degrades on absent/changed formats.
|
|
5
|
+
// Every case uses temporary fixture files (or an injected reader) — CI
|
|
6
|
+
// never touches the real local Cursor login.
|
|
7
|
+
import { test, beforeEach, afterEach } from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV, CURSOR_INDIVIDUAL_HOME_ENV, cursorIndividualAuthHeader, cursorIndividualCredentialCandidates, cursorIndividualCredentialStatus, decodeJwtSubject, loadCursorIndividualCredential, normalizeWorkosUserId, } from "./cursor-individual-credentials.js";
|
|
13
|
+
const SECRET = "fixture-secret-token-abc123";
|
|
14
|
+
const USER_ID = "user_fixture_abc123";
|
|
15
|
+
/** header.payload.signature with { sub: USER_ID } — no signature verification, so any value works. */
|
|
16
|
+
function fixtureJwt(sub) {
|
|
17
|
+
const b64url = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
18
|
+
return `${b64url({ alg: "none" })}.${b64url({ sub })}.sig`;
|
|
19
|
+
}
|
|
20
|
+
let dir;
|
|
21
|
+
let savedFileEnv;
|
|
22
|
+
let savedHomeEnv;
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-cursor-indiv-creds-"));
|
|
25
|
+
savedFileEnv = process.env[CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV];
|
|
26
|
+
savedHomeEnv = process.env[CURSOR_INDIVIDUAL_HOME_ENV];
|
|
27
|
+
});
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
if (savedFileEnv === undefined)
|
|
30
|
+
delete process.env[CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV];
|
|
31
|
+
else
|
|
32
|
+
process.env[CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV] = savedFileEnv;
|
|
33
|
+
if (savedHomeEnv === undefined)
|
|
34
|
+
delete process.env[CURSOR_INDIVIDUAL_HOME_ENV];
|
|
35
|
+
else
|
|
36
|
+
process.env[CURSOR_INDIVIDUAL_HOME_ENV] = savedHomeEnv;
|
|
37
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
function fixture(name, content) {
|
|
40
|
+
const file = path.join(dir, name);
|
|
41
|
+
fs.writeFileSync(file, content);
|
|
42
|
+
process.env[CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV] = file;
|
|
43
|
+
return file;
|
|
44
|
+
}
|
|
45
|
+
test("absent credential file reads absent with no path", () => {
|
|
46
|
+
process.env[CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV] = path.join(dir, "does-not-exist.json");
|
|
47
|
+
const credential = loadCursorIndividualCredential();
|
|
48
|
+
assert.equal(credential.status, "absent");
|
|
49
|
+
assert.equal(credential.path, null);
|
|
50
|
+
assert.equal(credential.format, null);
|
|
51
|
+
assert.equal(credential.authHeader, undefined);
|
|
52
|
+
const status = cursorIndividualCredentialStatus();
|
|
53
|
+
assert.deepEqual(status, { present: false, path: null, format: null });
|
|
54
|
+
});
|
|
55
|
+
test("empty home directory reads absent (no real login touched)", () => {
|
|
56
|
+
delete process.env[CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV];
|
|
57
|
+
process.env[CURSOR_INDIVIDUAL_HOME_ENV] = dir;
|
|
58
|
+
assert.deepEqual(cursorIndividualCredentialCandidates(), [
|
|
59
|
+
path.join(dir, ".cursor", "auth.json"),
|
|
60
|
+
]);
|
|
61
|
+
assert.equal(loadCursorIndividualCredential().status, "absent");
|
|
62
|
+
assert.equal(cursorIndividualCredentialStatus().present, false);
|
|
63
|
+
});
|
|
64
|
+
test("accepted token shapes load with an in-memory auth header", () => {
|
|
65
|
+
const cases = [
|
|
66
|
+
{ body: { token: SECRET, userId: USER_ID }, format: "token" },
|
|
67
|
+
{ body: { accessToken: SECRET, userId: USER_ID }, format: "accessToken" },
|
|
68
|
+
{ body: { access_token: SECRET, user_id: USER_ID }, format: "access_token" },
|
|
69
|
+
{ body: { sessionToken: SECRET, userId: USER_ID }, format: "sessionToken" },
|
|
70
|
+
{ body: { apiKey: SECRET, userId: USER_ID }, format: "apiKey" },
|
|
71
|
+
{ body: { auth: { accessToken: SECRET }, userId: USER_ID }, format: "auth.accessToken" },
|
|
72
|
+
];
|
|
73
|
+
for (const { body, format } of cases) {
|
|
74
|
+
const file = fixture("auth.json", JSON.stringify(body));
|
|
75
|
+
const credential = loadCursorIndividualCredential();
|
|
76
|
+
assert.equal(credential.status, "found");
|
|
77
|
+
assert.equal(credential.path, file);
|
|
78
|
+
assert.equal(credential.format, format);
|
|
79
|
+
assert.equal(credential.authHeader, cursorIndividualAuthHeader(USER_ID, SECRET));
|
|
80
|
+
assert.ok(credential.authHeader.includes(SECRET), "header carries the token in memory only");
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
test("a user id is derived from the token's own JWT `sub` claim when no explicit field is present", () => {
|
|
84
|
+
const jwt = fixtureJwt(USER_ID);
|
|
85
|
+
assert.equal(decodeJwtSubject(jwt), USER_ID);
|
|
86
|
+
fixture("auth.json", JSON.stringify({ token: jwt }));
|
|
87
|
+
const credential = loadCursorIndividualCredential();
|
|
88
|
+
assert.equal(credential.status, "found");
|
|
89
|
+
assert.equal(credential.authHeader, cursorIndividualAuthHeader(USER_ID, jwt));
|
|
90
|
+
});
|
|
91
|
+
test("an explicit user-id field wins over the token's own JWT `sub` claim", () => {
|
|
92
|
+
const jwt = fixtureJwt("jwt-subject-should-lose");
|
|
93
|
+
fixture("auth.json", JSON.stringify({ token: jwt, userId: USER_ID }));
|
|
94
|
+
const credential = loadCursorIndividualCredential();
|
|
95
|
+
assert.equal(credential.authHeader, cursorIndividualAuthHeader(USER_ID, jwt));
|
|
96
|
+
});
|
|
97
|
+
test("a token with no derivable user id reads unparsable — never a Bearer-only guess", () => {
|
|
98
|
+
fixture("auth.json", JSON.stringify({ token: SECRET }));
|
|
99
|
+
const credential = loadCursorIndividualCredential();
|
|
100
|
+
assert.equal(credential.status, "unparsable");
|
|
101
|
+
assert.equal(credential.authHeader, undefined);
|
|
102
|
+
});
|
|
103
|
+
test("the auth header is the WorkosCursorSessionToken cookie with the :: delimiter percent-encoded", () => {
|
|
104
|
+
const header = cursorIndividualAuthHeader(USER_ID, SECRET);
|
|
105
|
+
assert.equal(header, `WorkosCursorSessionToken=${USER_ID}%3A%3A${SECRET}`);
|
|
106
|
+
// Never the raw, unencoded delimiter the dashboard rejects.
|
|
107
|
+
assert.ok(!header.includes(`${USER_ID}::${SECRET}`));
|
|
108
|
+
});
|
|
109
|
+
test("normalizeWorkosUserId strips a provider connection-type prefix", () => {
|
|
110
|
+
assert.equal(normalizeWorkosUserId("google-oauth2|user_abc"), "user_abc");
|
|
111
|
+
assert.equal(normalizeWorkosUserId("github|12345"), "12345");
|
|
112
|
+
// A bare id (no prefix) passes through unchanged.
|
|
113
|
+
assert.equal(normalizeWorkosUserId(USER_ID), USER_ID);
|
|
114
|
+
// Only the LAST `|` matters, in case a value itself contains one.
|
|
115
|
+
assert.equal(normalizeWorkosUserId("a|b|c"), "c");
|
|
116
|
+
});
|
|
117
|
+
test("a provider-prefixed JWT subject is normalized before it reaches the cookie", () => {
|
|
118
|
+
const jwt = fixtureJwt("google-oauth2|user_fixture");
|
|
119
|
+
fixture("auth.json", JSON.stringify({ token: jwt }));
|
|
120
|
+
const credential = loadCursorIndividualCredential();
|
|
121
|
+
assert.equal(credential.status, "found");
|
|
122
|
+
// The cookie carries the bare id, never the provider-qualified subject.
|
|
123
|
+
assert.equal(credential.authHeader, cursorIndividualAuthHeader("user_fixture", jwt));
|
|
124
|
+
assert.ok(!credential.authHeader.includes("google-oauth2"));
|
|
125
|
+
});
|
|
126
|
+
test("a provider-prefixed explicit userId field is normalized the same way", () => {
|
|
127
|
+
fixture("auth.json", JSON.stringify({ token: SECRET, userId: "github|user_fixture" }));
|
|
128
|
+
const credential = loadCursorIndividualCredential();
|
|
129
|
+
assert.equal(credential.authHeader, cursorIndividualAuthHeader("user_fixture", SECRET));
|
|
130
|
+
});
|
|
131
|
+
test("an id that normalizes to empty (nothing after the final |) reads unparsable, never an empty-id cookie", () => {
|
|
132
|
+
// Non-empty before normalization (passes the raw presence check), but
|
|
133
|
+
// normalizeWorkosUserId("github|") === "" — must still degrade, not build
|
|
134
|
+
// WorkosCursorSessionToken=%3A%3A<token>.
|
|
135
|
+
assert.equal(normalizeWorkosUserId("github|"), "");
|
|
136
|
+
// Explicit-field path.
|
|
137
|
+
fixture("auth.json", JSON.stringify({ token: SECRET, userId: "github|" }));
|
|
138
|
+
const fromField = loadCursorIndividualCredential();
|
|
139
|
+
assert.equal(fromField.status, "unparsable");
|
|
140
|
+
assert.equal(fromField.authHeader, undefined);
|
|
141
|
+
// JWT-`sub`-derived path.
|
|
142
|
+
const jwt = fixtureJwt("github|");
|
|
143
|
+
fixture("auth.json", JSON.stringify({ token: jwt }));
|
|
144
|
+
const fromJwt = loadCursorIndividualCredential();
|
|
145
|
+
assert.equal(fromJwt.status, "unparsable");
|
|
146
|
+
assert.equal(fromJwt.authHeader, undefined);
|
|
147
|
+
});
|
|
148
|
+
test("changed credential formats read unparsable, never a guess", () => {
|
|
149
|
+
const changed = [
|
|
150
|
+
{ totallyNewShape: true, session: { id: "abc" } },
|
|
151
|
+
{ token: "" },
|
|
152
|
+
{ token: " " },
|
|
153
|
+
{ token: 12345 },
|
|
154
|
+
{ accessToken: null },
|
|
155
|
+
["token-array"],
|
|
156
|
+
"just-a-string",
|
|
157
|
+
42,
|
|
158
|
+
null,
|
|
159
|
+
];
|
|
160
|
+
for (const body of changed) {
|
|
161
|
+
fixture("auth.json", JSON.stringify(body));
|
|
162
|
+
const credential = loadCursorIndividualCredential();
|
|
163
|
+
assert.equal(credential.status, "unparsable", `body ${JSON.stringify(body)} must not parse`);
|
|
164
|
+
assert.equal(credential.authHeader, undefined);
|
|
165
|
+
}
|
|
166
|
+
fixture("auth.json", "{ not valid json{{{");
|
|
167
|
+
assert.equal(loadCursorIndividualCredential().status, "unparsable");
|
|
168
|
+
});
|
|
169
|
+
test("diagnostics status never carries secret material", () => {
|
|
170
|
+
fixture("auth.json", JSON.stringify({ token: SECRET, userId: USER_ID }));
|
|
171
|
+
const status = cursorIndividualCredentialStatus();
|
|
172
|
+
assert.equal(status.present, true);
|
|
173
|
+
assert.equal(status.format, "token");
|
|
174
|
+
assert.ok(!JSON.stringify(status).includes(SECRET), "raw token must not appear in status output");
|
|
175
|
+
});
|
|
176
|
+
test("unreadable files degrade to absent without throwing", () => {
|
|
177
|
+
// The default reader swallows read errors (missing file, directory at the
|
|
178
|
+
// candidate path, permissions): none of them throw out of the loader.
|
|
179
|
+
process.env[CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV] = dir;
|
|
180
|
+
assert.equal(loadCursorIndividualCredential().status, "absent");
|
|
181
|
+
process.env[CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV] = path.join(dir, "missing.json");
|
|
182
|
+
assert.equal(loadCursorIndividualCredential().status, "absent");
|
|
183
|
+
const dirAsFile = loadCursorIndividualCredential((_p) => null);
|
|
184
|
+
assert.equal(dirAsFile.status, "absent");
|
|
185
|
+
});
|