ninepm 0.41.0 → 0.44.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.
- package/README.md +1 -1
- package/dist/index.d.ts +37 -1
- package/dist/index.js +613 -45
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -180,7 +180,7 @@ Service token secrets returned by `access service create` are also server-side s
|
|
|
180
180
|
9pm deploy [directory] --runtime container --port 8080 [--health-path /health] [--region auto] [--with-db]
|
|
181
181
|
9pm signup
|
|
182
182
|
9pm login
|
|
183
|
-
9pm logout
|
|
183
|
+
9pm logout [--api-url <url>] [--local]
|
|
184
184
|
9pm doctor [--json]
|
|
185
185
|
9pm whoami
|
|
186
186
|
9pm apps [--json]
|
package/dist/index.d.ts
CHANGED
|
@@ -114,8 +114,12 @@ export interface CreatedServiceToken {
|
|
|
114
114
|
clientSecret: string;
|
|
115
115
|
}
|
|
116
116
|
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
117
|
-
export declare const CLI_VERSION = "0.
|
|
117
|
+
export declare const CLI_VERSION = "0.44.0";
|
|
118
118
|
type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
|
|
119
|
+
export interface StoredCredentialEntry {
|
|
120
|
+
token: string;
|
|
121
|
+
apiUrl: string | undefined;
|
|
122
|
+
}
|
|
119
123
|
export interface CredentialDiagnostics {
|
|
120
124
|
platform: NodeJS.Platform;
|
|
121
125
|
preferredBackend: CredentialBackend;
|
|
@@ -136,6 +140,7 @@ export interface WhoamiResult {
|
|
|
136
140
|
role?: string;
|
|
137
141
|
};
|
|
138
142
|
apiKey: {
|
|
143
|
+
id?: string;
|
|
139
144
|
prefix: string;
|
|
140
145
|
expiresAt: string | null;
|
|
141
146
|
};
|
|
@@ -204,6 +209,32 @@ export declare function measureWorkerModuleFiles(root: string, modulePaths: read
|
|
|
204
209
|
export declare function checkDeployDirectory(options: DeployOptions): Promise<DeployCheckResult>;
|
|
205
210
|
export declare function whoami(options: ApiOptions): Promise<WhoamiResult>;
|
|
206
211
|
export declare function publicWhoamiResult(result: WhoamiResult): PublicWhoamiResult;
|
|
212
|
+
export type CredentialRevocation = {
|
|
213
|
+
kind: "revoked";
|
|
214
|
+
} | {
|
|
215
|
+
kind: "already_invalid";
|
|
216
|
+
} | {
|
|
217
|
+
kind: "failed";
|
|
218
|
+
reason: string;
|
|
219
|
+
};
|
|
220
|
+
export declare function revokeCredential(options: ApiOptions): Promise<CredentialRevocation>;
|
|
221
|
+
export interface RevocationAttempt {
|
|
222
|
+
issuerKnown: boolean;
|
|
223
|
+
apiUrl: string;
|
|
224
|
+
outcome: CredentialRevocation;
|
|
225
|
+
}
|
|
226
|
+
export type LogoutSummary = {
|
|
227
|
+
kind: "failed";
|
|
228
|
+
reason: string;
|
|
229
|
+
} | {
|
|
230
|
+
kind: "unproven";
|
|
231
|
+
apiUrl: string;
|
|
232
|
+
} | {
|
|
233
|
+
kind: "revoked";
|
|
234
|
+
} | {
|
|
235
|
+
kind: "already_invalid";
|
|
236
|
+
};
|
|
237
|
+
export declare function summarizeRevocations(attempts: RevocationAttempt[]): LogoutSummary;
|
|
207
238
|
export declare function getSiteAccess(options: SiteAccessOptions): Promise<AccessState>;
|
|
208
239
|
export declare function setSiteAccessPublic(options: SiteAccessOptions): Promise<AccessState["access"]>;
|
|
209
240
|
export declare function setSiteAccessOtp(options: SiteAccessOptions): Promise<AccessState["access"]>;
|
|
@@ -263,6 +294,7 @@ export interface DeviceAuthFeedbackOptions {
|
|
|
263
294
|
openBrowserFn?: (url: string) => BrowserOpenResult;
|
|
264
295
|
storeCredentialFn?: (token: string, options: {
|
|
265
296
|
allowFileFallback?: boolean | undefined;
|
|
297
|
+
apiUrl?: string | undefined;
|
|
266
298
|
}) => string;
|
|
267
299
|
}
|
|
268
300
|
export declare function loginDevice(options: {
|
|
@@ -336,6 +368,9 @@ type CliCommand = {
|
|
|
336
368
|
allowFileCredentialStore: boolean;
|
|
337
369
|
} | {
|
|
338
370
|
kind: "logout";
|
|
371
|
+
apiUrl: string | null;
|
|
372
|
+
apiUrlError?: string;
|
|
373
|
+
local: boolean;
|
|
339
374
|
} | {
|
|
340
375
|
kind: "doctor";
|
|
341
376
|
apiUrl: string;
|
|
@@ -492,6 +527,7 @@ export declare function parseEnvFileEntries(content: string, kind: "secret" | "v
|
|
|
492
527
|
value: string;
|
|
493
528
|
kind: "secret" | "var";
|
|
494
529
|
}>;
|
|
530
|
+
export declare function storedCredentialTokens(): StoredCredentialEntry[];
|
|
495
531
|
export declare function credentialDiagnostics(): CredentialDiagnostics;
|
|
496
532
|
export type BrowserOpenResult = "opened" | "failed" | "unknown";
|
|
497
533
|
export declare function classifyBrowserOpen(input: {
|
package/dist/index.js
CHANGED
|
@@ -14,12 +14,17 @@ import { MAX_DEPLOY_DESCRIPTION_LENGTH, normalizeDeployDescription } from "./dep
|
|
|
14
14
|
import { MAX_STATIC_ASSET_FILE_BYTES, MAX_WORKER_ENTRY_COMPRESSED_BYTES, MAX_WORKER_SCRIPT_COMPRESSED_BYTES, WORKER_ENTRY_COMPRESSED_WARN_BYTES, assetFileTooLargeMessage, formatMebibytes, formatMegabytes, oversizedAssets, workerBundleNearLimitMessage, workerBundleTooLargeMessage, workerEntryNearLimitMessage, workerEntryTooLargeMessage, } from "./deploy-limits.js";
|
|
15
15
|
import { buildFileManifest, readManifestFile } from "./manifest.js";
|
|
16
16
|
import { API_KEY_HELP, API_URL_HELP, CLI_CLIENT_ID, DEFAULT_API_URL, reportCliError } from "./cli-config.js";
|
|
17
|
-
export const CLI_VERSION = "0.
|
|
17
|
+
export const CLI_VERSION = "0.44.0";
|
|
18
18
|
const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
|
|
19
19
|
const TOKEN_SERVICE = "9pm";
|
|
20
20
|
const TOKEN_ACCOUNT = "default";
|
|
21
|
+
// The protected store's second slot, holding the control plane that issued the
|
|
22
|
+
// token in the adjacent slot. Deliberately not the same item — see
|
|
23
|
+
// readSecureCredentialEntry.
|
|
24
|
+
const ISSUER_ACCOUNT = "default-issuer";
|
|
21
25
|
const CREDENTIAL_METADATA_FILE = "credentials.json";
|
|
22
26
|
const WINDOWS_CREDENTIAL_FILE = "credential.dpapi";
|
|
27
|
+
const WINDOWS_ISSUER_FILE = "issuer.dpapi";
|
|
23
28
|
const PLAINTEXT_CREDENTIAL_FILE = "credential.plaintext.json";
|
|
24
29
|
const ALLOW_FILE_CREDENTIAL_STORE_ENV = "NINEPM_ALLOW_FILE_CREDENTIAL_STORE";
|
|
25
30
|
const DISABLE_NATIVE_CREDENTIAL_STORE_ENV = "NINEPM_DISABLE_NATIVE_CREDENTIAL_STORE";
|
|
@@ -700,6 +705,130 @@ export function publicWhoamiResult(result) {
|
|
|
700
705
|
},
|
|
701
706
|
};
|
|
702
707
|
}
|
|
708
|
+
// How long each revocation request may take before logout gives up and reports
|
|
709
|
+
// the honest failure instead of hanging (a black-holed connection accepts TCP
|
|
710
|
+
// and never answers). Longer than the 5s reachability probe because this
|
|
711
|
+
// request is worth waiting for, but still bounded.
|
|
712
|
+
const REVOKE_TIMEOUT_MS = 10_000;
|
|
713
|
+
// Revoke the API key `options.token` authenticates as (#822). The token itself
|
|
714
|
+
// carries no key id, so /v1/me — whose response names the calling key — is how
|
|
715
|
+
// the CLI learns which row to revoke. Every branch returns instead of throwing:
|
|
716
|
+
// logout deletes the local credential whatever happens here, and needs to tell
|
|
717
|
+
// the user which of the three outcomes it got.
|
|
718
|
+
export async function revokeCredential(options) {
|
|
719
|
+
const fetchFn = withFetchDiagnostics(options.fetchFn ?? fetch);
|
|
720
|
+
const apiUrl = normalizeApiUrl(options.apiUrl);
|
|
721
|
+
let identify;
|
|
722
|
+
try {
|
|
723
|
+
identify = await fetchFn(`${apiUrl}/v1/me`, {
|
|
724
|
+
method: "GET",
|
|
725
|
+
headers: jsonHeaders(options.token),
|
|
726
|
+
signal: AbortSignal.timeout(REVOKE_TIMEOUT_MS),
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
catch (error) {
|
|
730
|
+
return { kind: "failed", reason: revocationFailureReason(error) };
|
|
731
|
+
}
|
|
732
|
+
if (identify.status === 401 || identify.status === 403) {
|
|
733
|
+
// Only the control plane's own auth refusal proves the credential is the
|
|
734
|
+
// thing being rejected. A WAF, an SSO proxy, or an Access-protected host in
|
|
735
|
+
// front of the API answers 401/403 too, and treating that as "the key is
|
|
736
|
+
// dead" would report a clean logout for a key that is still live.
|
|
737
|
+
return (await isAuthRefusal(identify))
|
|
738
|
+
? { kind: "already_invalid" }
|
|
739
|
+
: { kind: "failed", reason: `${identify.status} from something other than the control plane's own authentication` };
|
|
740
|
+
}
|
|
741
|
+
if (!identify.ok) {
|
|
742
|
+
return { kind: "failed", reason: `the control plane answered ${identify.status}` };
|
|
743
|
+
}
|
|
744
|
+
let keyId;
|
|
745
|
+
try {
|
|
746
|
+
keyId = (await identify.json()).apiKey?.id;
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
keyId = undefined;
|
|
750
|
+
}
|
|
751
|
+
if (!keyId) {
|
|
752
|
+
return { kind: "failed", reason: "the control plane did not identify the credential" };
|
|
753
|
+
}
|
|
754
|
+
let revoke;
|
|
755
|
+
try {
|
|
756
|
+
revoke = await fetchFn(`${apiUrl}/v1/me/api-keys/${encodeURIComponent(keyId)}/revoke`, {
|
|
757
|
+
method: "POST",
|
|
758
|
+
headers: jsonHeaders(options.token),
|
|
759
|
+
signal: AbortSignal.timeout(REVOKE_TIMEOUT_MS),
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
catch (error) {
|
|
763
|
+
return { kind: "failed", reason: revocationFailureReason(error) };
|
|
764
|
+
}
|
|
765
|
+
if (revoke.ok) {
|
|
766
|
+
return { kind: "revoked" };
|
|
767
|
+
}
|
|
768
|
+
// 401/403 here means the key died between the two calls (revoked elsewhere):
|
|
769
|
+
// it can no longer authenticate, which is the goal state. Same intermediary
|
|
770
|
+
// caveat as the /v1/me call above.
|
|
771
|
+
if (revoke.status === 401 || revoke.status === 403) {
|
|
772
|
+
return (await isAuthRefusal(revoke))
|
|
773
|
+
? { kind: "already_invalid" }
|
|
774
|
+
: { kind: "failed", reason: `${revoke.status} from something other than the control plane's own authentication` };
|
|
775
|
+
}
|
|
776
|
+
if (revoke.status === 404) {
|
|
777
|
+
// Only the real handler's not-found means the key row is already gone. A
|
|
778
|
+
// control plane without the revoke route (an older or self-hosted build)
|
|
779
|
+
// answers a bare 404 too, and trusting that would report a clean logout
|
|
780
|
+
// while the key stays live.
|
|
781
|
+
return (await responseErrorCode(revoke)) === "api_key_not_found"
|
|
782
|
+
? { kind: "already_invalid" }
|
|
783
|
+
: { kind: "failed", reason: "the control plane answered 404 for the revoke route — it may not support revocation" };
|
|
784
|
+
}
|
|
785
|
+
return { kind: "failed", reason: `the control plane answered ${revoke.status}` };
|
|
786
|
+
}
|
|
787
|
+
// Worst-first, so one credential's success can never mask another's unresolved
|
|
788
|
+
// state: a machine holding both a revoked production key and an issuerless one
|
|
789
|
+
// the guess rejected has NOT been fully logged out, and reporting the success
|
|
790
|
+
// would delete the second copy while it may still authenticate somewhere.
|
|
791
|
+
export function summarizeRevocations(attempts) {
|
|
792
|
+
const failed = attempts.find((attempt) => attempt.outcome.kind === "failed");
|
|
793
|
+
if (failed && failed.outcome.kind === "failed") {
|
|
794
|
+
return { kind: "failed", reason: failed.outcome.reason };
|
|
795
|
+
}
|
|
796
|
+
const unproven = attempts.find((attempt) => !attempt.issuerKnown && attempt.outcome.kind === "already_invalid");
|
|
797
|
+
if (unproven) {
|
|
798
|
+
return { kind: "unproven", apiUrl: unproven.apiUrl };
|
|
799
|
+
}
|
|
800
|
+
return attempts.some((attempt) => attempt.outcome.kind === "revoked") ? { kind: "revoked" } : { kind: "already_invalid" };
|
|
801
|
+
}
|
|
802
|
+
function revocationFailureReason(error) {
|
|
803
|
+
// AbortSignal.timeout surfaces as a TimeoutError whose message ("The
|
|
804
|
+
// operation was aborted due to timeout") names neither the duration nor the
|
|
805
|
+
// request — say what actually ran out.
|
|
806
|
+
if (error instanceof Error && error.name === "TimeoutError") {
|
|
807
|
+
return `the control plane did not answer within ${REVOKE_TIMEOUT_MS / 1000}s`;
|
|
808
|
+
}
|
|
809
|
+
return error instanceof Error ? error.message : String(error);
|
|
810
|
+
}
|
|
811
|
+
// The ONLY refusal that proves anything about the stored credential:
|
|
812
|
+
// requireAuth emits invalid_api_key after looking the presented token up
|
|
813
|
+
// (src/server/routes/guards.ts). Its siblings do not — missing_api_key means no
|
|
814
|
+
// credential arrived at all and malformed_api_key means the header was not a
|
|
815
|
+
// bearer token, both of which a proxy that strips or rewrites Authorization
|
|
816
|
+
// produces for a perfectly live key. Anything else answering 401/403 is an
|
|
817
|
+
// intermediary rather than the control plane judging this credential.
|
|
818
|
+
async function isAuthRefusal(response) {
|
|
819
|
+
return (await responseErrorCode(response)) === "invalid_api_key";
|
|
820
|
+
}
|
|
821
|
+
// The `code` from the control plane's {error:{code}} envelope, or "" when the
|
|
822
|
+
// body is not that shape (proxy error page, empty body).
|
|
823
|
+
async function responseErrorCode(response) {
|
|
824
|
+
try {
|
|
825
|
+
const parsed = (await response.json());
|
|
826
|
+
return typeof parsed.error?.code === "string" ? parsed.error.code : "";
|
|
827
|
+
}
|
|
828
|
+
catch {
|
|
829
|
+
return "";
|
|
830
|
+
}
|
|
831
|
+
}
|
|
703
832
|
async function doctor(options) {
|
|
704
833
|
const credentials = credentialDiagnostics();
|
|
705
834
|
const token = process.env.NINEPM_API_KEY ?? readStoredCredential();
|
|
@@ -991,6 +1120,9 @@ async function deviceAuthFlow(options) {
|
|
|
991
1120
|
progress.stop();
|
|
992
1121
|
const storage = (options.storeCredentialFn ?? storeCredential)(poll.token, {
|
|
993
1122
|
allowFileFallback: options.allowFileCredentialStore,
|
|
1123
|
+
// Recorded so logout can revoke against the control plane that issued
|
|
1124
|
+
// this credential rather than whatever URL it resolves later.
|
|
1125
|
+
apiUrl,
|
|
994
1126
|
});
|
|
995
1127
|
const me = await whoami({ apiUrl, token: poll.token, fetchFn });
|
|
996
1128
|
return { email: me.user.email, storage };
|
|
@@ -1753,7 +1885,18 @@ export function parseArgs(argv) {
|
|
|
1753
1885
|
return { kind: "signup", apiUrl: readApiUrl(flags, DEFAULT_API_URL), allowFileCredentialStore: flags.has("allow-file-credential-store") };
|
|
1754
1886
|
}
|
|
1755
1887
|
if (command === "logout") {
|
|
1756
|
-
|
|
1888
|
+
const flags = parseFlags(argv);
|
|
1889
|
+
const local = flags.has("local");
|
|
1890
|
+
const explicit = readStringFlag(flags, "api-url") ?? process.env.NINEPM_API_URL;
|
|
1891
|
+
if (!explicit) {
|
|
1892
|
+
return { kind: "logout", apiUrl: null, local };
|
|
1893
|
+
}
|
|
1894
|
+
try {
|
|
1895
|
+
return { kind: "logout", apiUrl: validateApiUrl(explicit), local };
|
|
1896
|
+
}
|
|
1897
|
+
catch (error) {
|
|
1898
|
+
return { kind: "logout", apiUrl: null, local, apiUrlError: error instanceof Error ? error.message : String(error) };
|
|
1899
|
+
}
|
|
1757
1900
|
}
|
|
1758
1901
|
if (command === "whoami") {
|
|
1759
1902
|
const flags = parseFlags(argv);
|
|
@@ -2222,6 +2365,13 @@ async function runLogsCommand(command) {
|
|
|
2222
2365
|
}
|
|
2223
2366
|
const deployment = result.deployment;
|
|
2224
2367
|
console.log(`Deployment ${deployment.id}\t${deployment.status}\thealth: ${deployment.healthStatus}\t${deployment.finishedAt ?? deployment.createdAt}`);
|
|
2368
|
+
// The line above describes the LATEST deployment, which is not the one serving
|
|
2369
|
+
// traffic while a newer attempt is queued or failed - and its health is that
|
|
2370
|
+
// attempt's, not the running app's. Name the serving one's own verdict rather
|
|
2371
|
+
// than leaving the only health figure on screen belonging to something else.
|
|
2372
|
+
if (result.runtimeDeployment && result.runtimeDeployment.id !== deployment.id && result.runtimeDeployment.health) {
|
|
2373
|
+
console.log(`Serving ${result.runtimeDeployment.id}\thealth: ${result.runtimeDeployment.health}`);
|
|
2374
|
+
}
|
|
2225
2375
|
if (result.reason) {
|
|
2226
2376
|
console.log(`Reason: ${result.reason}`);
|
|
2227
2377
|
}
|
|
@@ -2240,11 +2390,52 @@ async function runLogsCommand(command) {
|
|
|
2240
2390
|
for (const line of lines) {
|
|
2241
2391
|
console.log(line);
|
|
2242
2392
|
}
|
|
2393
|
+
}
|
|
2394
|
+
// #820: the SERVING deployment's runtime half — restart/exit summary plus
|
|
2395
|
+
// recent app output. The serving deployment can differ from the
|
|
2396
|
+
// failure-diagnostics one above (a queued or failed replacement is newer),
|
|
2397
|
+
// so the section names whose runtime it describes.
|
|
2398
|
+
const servingLabel = result.runtimeDeployment && result.runtimeDeployment.id !== deployment.id
|
|
2399
|
+
? ` (serving deployment ${result.runtimeDeployment.id})`
|
|
2400
|
+
: "";
|
|
2401
|
+
if (result.runtime && (result.runtime.restartCount > 0 || result.runtime.lastExitCode !== null)) {
|
|
2402
|
+
const parts = [`restarts: ${result.runtime.restartCount}`];
|
|
2403
|
+
if (result.runtime.lastExitCode !== null)
|
|
2404
|
+
parts.push(`last exit code: ${result.runtime.lastExitCode}`);
|
|
2405
|
+
if (result.runtime.lastExitAt)
|
|
2406
|
+
parts.push(`last exit: ${result.runtime.lastExitAt}`);
|
|
2407
|
+
console.log("");
|
|
2408
|
+
console.log(`Runtime${servingLabel}: ${parts.join("\t")}`);
|
|
2409
|
+
}
|
|
2410
|
+
if (result.runtimeLogs) {
|
|
2411
|
+
let lines = result.runtimeLogs.split("\n");
|
|
2412
|
+
if (command.lines !== undefined) {
|
|
2413
|
+
lines = lines.slice(-command.lines);
|
|
2414
|
+
}
|
|
2415
|
+
console.log("");
|
|
2416
|
+
console.log(`Recent runtime log${servingLabel}:`);
|
|
2417
|
+
for (const line of lines) {
|
|
2418
|
+
console.log(line);
|
|
2419
|
+
}
|
|
2420
|
+
return;
|
|
2421
|
+
}
|
|
2422
|
+
if (result.runtimeLogsUnavailable) {
|
|
2423
|
+
const copy = {
|
|
2424
|
+
placement_unsupported: "Runtime logs are not yet available for this app's placement.",
|
|
2425
|
+
not_configured: "Runtime logs are not configured on this platform.",
|
|
2426
|
+
nothing_retained: "No recent runtime output is retained for the serving deployment.",
|
|
2427
|
+
provider_unavailable: "Runtime logs are temporarily unavailable; try again shortly.",
|
|
2428
|
+
};
|
|
2429
|
+
console.log("");
|
|
2430
|
+
console.log(copy[result.runtimeLogsUnavailable] ?? "Runtime logs are unavailable.");
|
|
2431
|
+
return;
|
|
2432
|
+
}
|
|
2433
|
+
if (result.logs) {
|
|
2243
2434
|
return;
|
|
2244
2435
|
}
|
|
2245
2436
|
console.log(deployment.status === "failed"
|
|
2246
2437
|
? "No detailed logs were captured for this failure."
|
|
2247
|
-
: "No logs recorded. Logs are captured when a deployment fails to come up
|
|
2438
|
+
: "No logs recorded. Logs are captured when a deployment fails to come up.");
|
|
2248
2439
|
}
|
|
2249
2440
|
function parseLogsArgs(argv) {
|
|
2250
2441
|
const site = argv[0];
|
|
@@ -2571,6 +2762,11 @@ function credentialMetadataPath() {
|
|
|
2571
2762
|
function windowsCredentialPath() {
|
|
2572
2763
|
return path.join(credentialConfigDir(), WINDOWS_CREDENTIAL_FILE);
|
|
2573
2764
|
}
|
|
2765
|
+
// DPAPI-protected like the token beside it, so tampering with it requires the
|
|
2766
|
+
// same user context rather than plain write access to the directory.
|
|
2767
|
+
function windowsIssuerPath() {
|
|
2768
|
+
return path.join(credentialConfigDir(), WINDOWS_ISSUER_FILE);
|
|
2769
|
+
}
|
|
2574
2770
|
function plaintextCredentialPath() {
|
|
2575
2771
|
return path.join(credentialConfigDir(), PLAINTEXT_CREDENTIAL_FILE);
|
|
2576
2772
|
}
|
|
@@ -2588,26 +2784,107 @@ function readStoredCredential() {
|
|
|
2588
2784
|
}
|
|
2589
2785
|
return plaintext.token;
|
|
2590
2786
|
}
|
|
2787
|
+
// Every distinct token logout is about to delete, from every store the delete
|
|
2788
|
+
// will touch. Reads without side effects — readStoredCredential() migrates a
|
|
2789
|
+
// legacy plaintext file into the keychain, pointless work one statement before
|
|
2790
|
+
// deletion. Two stores can hold two different live tokens (a file-fallback
|
|
2791
|
+
// login followed by a native-store one); each needs its own revocation or
|
|
2792
|
+
// logout silently orphans one — the leak #822 is about.
|
|
2793
|
+
export function storedCredentialTokens() {
|
|
2794
|
+
// Each store reports its own issuer, carried with the token rather than read
|
|
2795
|
+
// from the shared metadata file: two stores can hold credentials from two
|
|
2796
|
+
// different control planes (a file-fallback staging login followed by a
|
|
2797
|
+
// keychain production login), and a key can only be revoked where it was
|
|
2798
|
+
// issued — a foreign plane answers 403, which reads as "already invalid".
|
|
2799
|
+
const entries = [];
|
|
2800
|
+
const secure = readSecureCredentialEntry();
|
|
2801
|
+
if (secure) {
|
|
2802
|
+
entries.push(secure);
|
|
2803
|
+
}
|
|
2804
|
+
// BOTH plaintext files, not just the first that parses: the current file and
|
|
2805
|
+
// the legacy one can hold different tokens (a fallback login here, then one
|
|
2806
|
+
// from a still-installed older CLI), and deleteStoredCredential removes both
|
|
2807
|
+
// — so revoking only the first would discard the other while it is still live.
|
|
2808
|
+
for (const plaintext of readPlaintextCredentials()) {
|
|
2809
|
+
if (entries.some((entry) => entry.token === plaintext.token)) {
|
|
2810
|
+
continue;
|
|
2811
|
+
}
|
|
2812
|
+
// The plaintext file's own body: no less protected than the token it sits
|
|
2813
|
+
// next to, and immune to a later login through the other store.
|
|
2814
|
+
entries.push({ token: plaintext.token, apiUrl: safeApiUrl(plaintext.apiUrl) });
|
|
2815
|
+
}
|
|
2816
|
+
return entries;
|
|
2817
|
+
}
|
|
2818
|
+
// Whether a protected credential was ever stored from this config directory.
|
|
2819
|
+
// Read from the unprotected metadata file, which is safe here because the
|
|
2820
|
+
// answer only decides whether to WARN — it never selects a destination for a
|
|
2821
|
+
// token. Reads the sticky flag as well as the last-login backend, so a later
|
|
2822
|
+
// file-fallback login cannot erase the evidence.
|
|
2823
|
+
//
|
|
2824
|
+
// Best-effort by construction: the metadata file is per-config-directory while
|
|
2825
|
+
// the OS store is machine-wide, so a credential stored under a different
|
|
2826
|
+
// XDG_CONFIG_HOME is invisible here. Confirming it would mean querying the very
|
|
2827
|
+
// store the caller asked this process not to touch (and on macOS risking an
|
|
2828
|
+
// unlock prompt in the CI runs that variable exists for), so absent evidence
|
|
2829
|
+
// stays silent rather than warning every file-only logout about a credential
|
|
2830
|
+
// the machine may not have.
|
|
2831
|
+
function nativeCredentialMayExist() {
|
|
2832
|
+
try {
|
|
2833
|
+
const parsed = JSON.parse(readFileSync(credentialMetadataPath(), "utf8"));
|
|
2834
|
+
return parsed.nativeCredentialStored === true || (typeof parsed.backend === "string" && isNativeBackend(parsed.backend));
|
|
2835
|
+
}
|
|
2836
|
+
catch {
|
|
2837
|
+
return false;
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2840
|
+
// Validated because the metadata and plaintext files sit unprotected on disk
|
|
2841
|
+
// and a tampered URL must not receive the bearer token. Invalid or absent
|
|
2842
|
+
// (pre-0.42 logins never recorded one) reads as undefined.
|
|
2843
|
+
function safeApiUrl(value) {
|
|
2844
|
+
if (!value) {
|
|
2845
|
+
return undefined;
|
|
2846
|
+
}
|
|
2847
|
+
try {
|
|
2848
|
+
return validateApiUrl(value);
|
|
2849
|
+
}
|
|
2850
|
+
catch {
|
|
2851
|
+
return undefined;
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2591
2854
|
function storeCredential(token, options = {}) {
|
|
2592
|
-
const secure = storeSecureCredential(token);
|
|
2855
|
+
const secure = storeSecureCredential(token, options.apiUrl);
|
|
2593
2856
|
if (secure) {
|
|
2594
2857
|
return secure;
|
|
2595
2858
|
}
|
|
2596
2859
|
if (!isFileCredentialStoreAllowed(options.allowFileFallback)) {
|
|
2597
2860
|
throw new Error(`Secure credential storage is unavailable. Re-run with --allow-file-credential-store or set ${ALLOW_FILE_CREDENTIAL_STORE_ENV}=1 to store a plaintext fallback file.`);
|
|
2598
2861
|
}
|
|
2599
|
-
return storePlaintextCredential(token);
|
|
2862
|
+
return storePlaintextCredential(token, options.apiUrl);
|
|
2600
2863
|
}
|
|
2601
2864
|
function deleteStoredCredential() {
|
|
2865
|
+
// Honor the native-store opt-out on delete as well as read/store: a process
|
|
2866
|
+
// told not to touch the OS keychain cannot see a keychain credential, so
|
|
2867
|
+
// deleting it would wipe a token that was never revoked.
|
|
2868
|
+
// Preserved when the protected store is skipped: the credential there is
|
|
2869
|
+
// untouched, so the record that it exists must outlive this deletion or the
|
|
2870
|
+
// NEXT logout has nothing left to warn from.
|
|
2871
|
+
const keepNativeMarker = isNativeCredentialStoreDisabled() && nativeCredentialMayExist();
|
|
2872
|
+
const nativeRemovals = isNativeCredentialStoreDisabled()
|
|
2873
|
+
? []
|
|
2874
|
+
: [deleteMacKeychainCredential(), deleteWindowsDpapiCredential(), deleteLinuxSecretServiceCredential(), deleteSecureIssuer()];
|
|
2602
2875
|
const removed = [
|
|
2603
|
-
|
|
2604
|
-
deleteWindowsDpapiCredential(),
|
|
2605
|
-
deleteLinuxSecretServiceCredential(),
|
|
2876
|
+
...nativeRemovals,
|
|
2606
2877
|
removeFileIfExists(credentialMetadataPath()),
|
|
2607
|
-
|
|
2878
|
+
// windowsCredentialPath() is deliberately NOT removed here: it IS the DPAPI
|
|
2879
|
+
// store, so deleteWindowsDpapiCredential owns it. Removing it again outside
|
|
2880
|
+
// the guard above would delete a Windows credential that this process was
|
|
2881
|
+
// told not to touch, and therefore never read or revoked.
|
|
2608
2882
|
removeFileIfExists(plaintextCredentialPath()),
|
|
2609
2883
|
removeFileIfExists(legacyCredentialPath()),
|
|
2610
2884
|
];
|
|
2885
|
+
if (keepNativeMarker) {
|
|
2886
|
+
writeCredentialMetadata("none", {}, true);
|
|
2887
|
+
}
|
|
2611
2888
|
return removed.some(Boolean);
|
|
2612
2889
|
}
|
|
2613
2890
|
export function credentialDiagnostics() {
|
|
@@ -2648,34 +2925,157 @@ export function credentialDiagnostics() {
|
|
|
2648
2925
|
};
|
|
2649
2926
|
}
|
|
2650
2927
|
function readSecureCredential() {
|
|
2928
|
+
return readSecureCredentialEntry()?.token;
|
|
2929
|
+
}
|
|
2930
|
+
// The issuer is not a secret, but it decides where a full-scope bearer token
|
|
2931
|
+
// gets sent, so it needs the same INTEGRITY protection as the token itself: the
|
|
2932
|
+
// metadata file beside it is unprotected, and trusting that to name the
|
|
2933
|
+
// destination would make "can write to the home directory" enough to redirect
|
|
2934
|
+
// the token to a host of the attacker's choosing — handing out a key the
|
|
2935
|
+
// keychain exists to keep unreadable.
|
|
2936
|
+
//
|
|
2937
|
+
// It is kept as a SEPARATE item rather than wrapped around the token, because
|
|
2938
|
+
// the token slot is shared with every other 9pm CLI on the machine: a payload
|
|
2939
|
+
// there would be read whole and sent as the bearer by any pre-0.42 CLI (this
|
|
2940
|
+
// repo's own `npm run cli` next to a global `ninepm`), failing with a
|
|
2941
|
+
// malformed-token error that names nothing. An absent item — every credential
|
|
2942
|
+
// stored before 0.42 — reads as an unknown issuer.
|
|
2943
|
+
function readSecureCredentialEntry() {
|
|
2651
2944
|
if (isNativeCredentialStoreDisabled()) {
|
|
2652
2945
|
return undefined;
|
|
2653
2946
|
}
|
|
2947
|
+
const token = process.platform === "darwin"
|
|
2948
|
+
? readMacKeychainCredential()
|
|
2949
|
+
: process.platform === "win32"
|
|
2950
|
+
? readWindowsDpapiCredential()
|
|
2951
|
+
: process.platform === "linux"
|
|
2952
|
+
? readLinuxSecretServiceCredential()
|
|
2953
|
+
: undefined;
|
|
2954
|
+
return token ? { token, apiUrl: safeApiUrl(readSecureIssuer(token)) } : undefined;
|
|
2955
|
+
}
|
|
2956
|
+
// The issuer is bound to the exact token it was recorded for, by fingerprint.
|
|
2957
|
+
// Two 9pm CLIs share the token slot, and a pre-0.42 one knows nothing about the
|
|
2958
|
+
// issuer item: it overwrites the token and leaves the issuer behind, pairing
|
|
2959
|
+
// values written by different logins. Without this check a production token
|
|
2960
|
+
// would inherit a staging issuer, be sent there, and staging's invalid_api_key
|
|
2961
|
+
// would be read as conclusive — a silent clean logout for a live key. A
|
|
2962
|
+
// mismatch reads as no issuer, which logout refuses to guess past.
|
|
2963
|
+
function readSecureIssuer(token) {
|
|
2964
|
+
const raw = process.platform === "darwin"
|
|
2965
|
+
? readMacKeychainCredential(ISSUER_ACCOUNT)
|
|
2966
|
+
: process.platform === "win32"
|
|
2967
|
+
? readWindowsDpapiCredential(windowsIssuerPath())
|
|
2968
|
+
: process.platform === "linux"
|
|
2969
|
+
? readLinuxSecretServiceCredential(ISSUER_ACCOUNT)
|
|
2970
|
+
: undefined;
|
|
2971
|
+
if (!raw) {
|
|
2972
|
+
return undefined;
|
|
2973
|
+
}
|
|
2974
|
+
try {
|
|
2975
|
+
const parsed = JSON.parse(raw);
|
|
2976
|
+
if (typeof parsed.apiUrl === "string" && parsed.token === tokenFingerprint(token)) {
|
|
2977
|
+
return parsed.apiUrl;
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
catch {
|
|
2981
|
+
// Not the fingerprinted shape (a 0.42.0-dev item) — unusable, so unknown.
|
|
2982
|
+
}
|
|
2983
|
+
return undefined;
|
|
2984
|
+
}
|
|
2985
|
+
function storeSecureIssuer(apiUrl, token) {
|
|
2986
|
+
return storeSecureIssuerRaw(JSON.stringify({ apiUrl, token: tokenFingerprint(token) }));
|
|
2987
|
+
}
|
|
2988
|
+
// The stored item verbatim, so a failed token write can put back exactly what
|
|
2989
|
+
// was there — including its fingerprint, which still matches the token that
|
|
2990
|
+
// remains in the store.
|
|
2991
|
+
function readSecureIssuerRaw() {
|
|
2654
2992
|
if (process.platform === "darwin") {
|
|
2655
|
-
return readMacKeychainCredential();
|
|
2993
|
+
return readMacKeychainCredential(ISSUER_ACCOUNT);
|
|
2656
2994
|
}
|
|
2657
2995
|
if (process.platform === "win32") {
|
|
2658
|
-
return readWindowsDpapiCredential();
|
|
2996
|
+
return readWindowsDpapiCredential(windowsIssuerPath());
|
|
2659
2997
|
}
|
|
2660
2998
|
if (process.platform === "linux") {
|
|
2661
|
-
return readLinuxSecretServiceCredential();
|
|
2999
|
+
return readLinuxSecretServiceCredential(ISSUER_ACCOUNT);
|
|
2662
3000
|
}
|
|
2663
3001
|
return undefined;
|
|
2664
3002
|
}
|
|
2665
|
-
function
|
|
3003
|
+
function storeSecureIssuerRaw(payload) {
|
|
3004
|
+
if (process.platform === "darwin") {
|
|
3005
|
+
return storeMacKeychainCredential(payload, ISSUER_ACCOUNT);
|
|
3006
|
+
}
|
|
3007
|
+
if (process.platform === "win32") {
|
|
3008
|
+
return storeWindowsDpapiCredential(payload, windowsIssuerPath());
|
|
3009
|
+
}
|
|
3010
|
+
if (process.platform === "linux") {
|
|
3011
|
+
return storeLinuxSecretServiceCredential(payload, ISSUER_ACCOUNT);
|
|
3012
|
+
}
|
|
3013
|
+
return false;
|
|
3014
|
+
}
|
|
3015
|
+
// Identifies which token an issuer was recorded for without storing a second
|
|
3016
|
+
// copy of the secret.
|
|
3017
|
+
function tokenFingerprint(token) {
|
|
3018
|
+
return createHash("sha256").update(token).digest("hex");
|
|
3019
|
+
}
|
|
3020
|
+
function deleteSecureIssuer() {
|
|
3021
|
+
if (process.platform === "darwin") {
|
|
3022
|
+
return deleteMacKeychainCredential(ISSUER_ACCOUNT);
|
|
3023
|
+
}
|
|
3024
|
+
if (process.platform === "win32") {
|
|
3025
|
+
return removeFileIfExists(windowsIssuerPath());
|
|
3026
|
+
}
|
|
3027
|
+
if (process.platform === "linux") {
|
|
3028
|
+
return deleteLinuxSecretServiceCredential(ISSUER_ACCOUNT);
|
|
3029
|
+
}
|
|
3030
|
+
return false;
|
|
3031
|
+
}
|
|
3032
|
+
function storeSecureCredential(token, apiUrl) {
|
|
2666
3033
|
if (isNativeCredentialStoreDisabled()) {
|
|
2667
3034
|
return undefined;
|
|
2668
3035
|
}
|
|
2669
|
-
|
|
2670
|
-
|
|
3036
|
+
// The previous login's issuer is cleared BEFORE the new token is written, so
|
|
3037
|
+
// the two are never observable as a mismatched pair: a token sent to the
|
|
3038
|
+
// wrong control plane at logout gets a 403 that reads as "already invalid"
|
|
3039
|
+
// while the key stays live at its real issuer. The window this leaves is the
|
|
3040
|
+
// safe one — a token with no issuer — which logout refuses to guess past.
|
|
3041
|
+
// If the token write then fails, the OLD token is still in the store, so its
|
|
3042
|
+
// issuer is put back rather than left erased.
|
|
3043
|
+
const previousIssuer = readSecureIssuerRaw();
|
|
3044
|
+
deleteSecureIssuer();
|
|
3045
|
+
const origin = apiUrl ? { apiUrl } : {};
|
|
3046
|
+
const recordIssuer = () => {
|
|
3047
|
+
if (apiUrl && !storeSecureIssuer(apiUrl, token)) {
|
|
3048
|
+
console.warn("Warning: could not record which control plane issued this credential. 9pm logout will not be able to revoke it without --api-url.");
|
|
3049
|
+
}
|
|
3050
|
+
};
|
|
3051
|
+
const restorePreviousIssuer = () => {
|
|
3052
|
+
if (previousIssuer) {
|
|
3053
|
+
storeSecureIssuerRaw(previousIssuer);
|
|
3054
|
+
}
|
|
3055
|
+
return undefined;
|
|
3056
|
+
};
|
|
3057
|
+
if (process.platform === "darwin") {
|
|
3058
|
+
if (!storeMacKeychainCredential(token)) {
|
|
3059
|
+
return restorePreviousIssuer();
|
|
3060
|
+
}
|
|
3061
|
+
recordIssuer();
|
|
3062
|
+
writeCredentialMetadata("macos-keychain", origin);
|
|
2671
3063
|
return "macOS Keychain";
|
|
2672
3064
|
}
|
|
2673
|
-
if (process.platform === "win32"
|
|
2674
|
-
|
|
3065
|
+
if (process.platform === "win32") {
|
|
3066
|
+
if (!storeWindowsDpapiCredential(token)) {
|
|
3067
|
+
return restorePreviousIssuer();
|
|
3068
|
+
}
|
|
3069
|
+
recordIssuer();
|
|
3070
|
+
writeCredentialMetadata("windows-dpapi", { secretPath: windowsCredentialPath(), ...origin });
|
|
2675
3071
|
return "Windows DPAPI";
|
|
2676
3072
|
}
|
|
2677
|
-
if (process.platform === "linux"
|
|
2678
|
-
|
|
3073
|
+
if (process.platform === "linux") {
|
|
3074
|
+
if (!storeLinuxSecretServiceCredential(token)) {
|
|
3075
|
+
return restorePreviousIssuer();
|
|
3076
|
+
}
|
|
3077
|
+
recordIssuer();
|
|
3078
|
+
writeCredentialMetadata("linux-secret-service", origin);
|
|
2679
3079
|
return "Linux Secret Service";
|
|
2680
3080
|
}
|
|
2681
3081
|
return undefined;
|
|
@@ -2695,44 +3095,82 @@ function preferredCredentialBackend() {
|
|
|
2695
3095
|
}
|
|
2696
3096
|
return "none";
|
|
2697
3097
|
}
|
|
2698
|
-
|
|
3098
|
+
// forceNativeMarker carries the sticky flag across a rewrite that follows the
|
|
3099
|
+
// metadata file's deletion, where it can no longer be read back.
|
|
3100
|
+
function writeCredentialMetadata(backend, extra = {}, forceNativeMarker = false) {
|
|
2699
3101
|
const filePath = credentialMetadataPath();
|
|
2700
3102
|
mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
3103
|
+
// This file is diagnostics only. Its `apiUrl` records where the credential
|
|
3104
|
+
// came from for a human reading it (and for `9pm doctor`), and is deliberately
|
|
3105
|
+
// NEVER read to decide where a token is sent: the file is unprotected, so
|
|
3106
|
+
// trusting it would make "can write to the home directory" enough to redirect
|
|
3107
|
+
// a bearer token. Each store carries its own issuer with the token instead.
|
|
3108
|
+
// `nativeCredentialStored` is sticky: `backend` names only the LAST login, so
|
|
3109
|
+
// a file-fallback login would otherwise erase the record that a protected
|
|
3110
|
+
// credential was ever stored — the one thing that lets logout warn when it
|
|
3111
|
+
// has been told to skip that store. Cleared only by deleting this file, which
|
|
3112
|
+
// happens alongside a real native deletion.
|
|
3113
|
+
const nativeCredentialStored = forceNativeMarker || isNativeBackend(backend) || readNativeCredentialStoredFlag();
|
|
2701
3114
|
writeFileSync(filePath, `${JSON.stringify({
|
|
2702
3115
|
backend,
|
|
2703
3116
|
service: TOKEN_SERVICE,
|
|
2704
3117
|
account: TOKEN_ACCOUNT,
|
|
2705
3118
|
updatedAt: new Date().toISOString(),
|
|
2706
3119
|
...extra,
|
|
3120
|
+
...(nativeCredentialStored ? { nativeCredentialStored } : {}),
|
|
2707
3121
|
}, null, 2)}\n`, { mode: 0o600 });
|
|
2708
3122
|
chmodIfSupported(filePath, 0o600);
|
|
2709
3123
|
}
|
|
2710
|
-
function
|
|
3124
|
+
function isNativeBackend(backend) {
|
|
3125
|
+
return backend === "macos-keychain" || backend === "windows-dpapi" || backend === "linux-secret-service";
|
|
3126
|
+
}
|
|
3127
|
+
function readNativeCredentialStoredFlag() {
|
|
3128
|
+
try {
|
|
3129
|
+
const parsed = JSON.parse(readFileSync(credentialMetadataPath(), "utf8"));
|
|
3130
|
+
return parsed.nativeCredentialStored === true;
|
|
3131
|
+
}
|
|
3132
|
+
catch {
|
|
3133
|
+
return false;
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
function storePlaintextCredential(token, apiUrl) {
|
|
2711
3137
|
const filePath = plaintextCredentialPath();
|
|
2712
3138
|
mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
2713
3139
|
writeFileSync(filePath, `${JSON.stringify({
|
|
2714
3140
|
token,
|
|
3141
|
+
...(apiUrl ? { apiUrl } : {}),
|
|
2715
3142
|
warning: "Plaintext fallback credential. Prefer OS-protected credential storage.",
|
|
2716
3143
|
}, null, 2)}\n`, { mode: 0o600 });
|
|
2717
3144
|
chmodIfSupported(filePath, 0o600);
|
|
2718
|
-
writeCredentialMetadata("file-plaintext", { plaintextPath: filePath });
|
|
3145
|
+
writeCredentialMetadata("file-plaintext", { plaintextPath: filePath, ...(apiUrl ? { apiUrl } : {}) });
|
|
2719
3146
|
console.warn(`Warning: stored credential in plaintext fallback file ${filePath} with mode 0600 where supported.`);
|
|
2720
3147
|
return filePath;
|
|
2721
3148
|
}
|
|
3149
|
+
// The highest-precedence plaintext credential — what the CLI authenticates
|
|
3150
|
+
// with. Logout needs every one of them instead; see readPlaintextCredentials.
|
|
2722
3151
|
function readPlaintextCredential() {
|
|
3152
|
+
return readPlaintextCredentials()[0];
|
|
3153
|
+
}
|
|
3154
|
+
function readPlaintextCredentials() {
|
|
2723
3155
|
const candidates = [plaintextCredentialPath(), legacyCredentialPath()].filter((candidate, index, list) => list.indexOf(candidate) === index);
|
|
3156
|
+
const found = [];
|
|
2724
3157
|
for (const filePath of candidates) {
|
|
2725
3158
|
try {
|
|
2726
3159
|
const parsed = JSON.parse(readFileSync(filePath, "utf8"));
|
|
2727
3160
|
if (typeof parsed.token === "string" && parsed.token) {
|
|
2728
|
-
|
|
3161
|
+
found.push({
|
|
3162
|
+
token: parsed.token,
|
|
3163
|
+
path: filePath,
|
|
3164
|
+
legacy: filePath === legacyCredentialPath(),
|
|
3165
|
+
...(typeof parsed.apiUrl === "string" && parsed.apiUrl ? { apiUrl: parsed.apiUrl } : {}),
|
|
3166
|
+
});
|
|
2729
3167
|
}
|
|
2730
3168
|
}
|
|
2731
3169
|
catch {
|
|
2732
3170
|
continue;
|
|
2733
3171
|
}
|
|
2734
3172
|
}
|
|
2735
|
-
return
|
|
3173
|
+
return found;
|
|
2736
3174
|
}
|
|
2737
3175
|
function migrateLegacyPlaintextCredential(credential) {
|
|
2738
3176
|
const secure = storeSecureCredential(credential.token);
|
|
@@ -2766,33 +3204,33 @@ function removeFileIfExists(filePath) {
|
|
|
2766
3204
|
rmSync(filePath, { force: true });
|
|
2767
3205
|
return true;
|
|
2768
3206
|
}
|
|
2769
|
-
function readMacKeychainCredential() {
|
|
3207
|
+
function readMacKeychainCredential(account = TOKEN_ACCOUNT) {
|
|
2770
3208
|
if (process.platform !== "darwin") {
|
|
2771
3209
|
return undefined;
|
|
2772
3210
|
}
|
|
2773
|
-
const result = spawnSync("security", ["find-generic-password", "-a",
|
|
3211
|
+
const result = spawnSync("security", ["find-generic-password", "-a", account, "-s", TOKEN_SERVICE, "-w"], { encoding: "utf8" });
|
|
2774
3212
|
return result.status === 0 ? result.stdout.trim() || undefined : undefined;
|
|
2775
3213
|
}
|
|
2776
|
-
function storeMacKeychainCredential(
|
|
3214
|
+
function storeMacKeychainCredential(value, account = TOKEN_ACCOUNT) {
|
|
2777
3215
|
if (process.platform !== "darwin") {
|
|
2778
3216
|
return false;
|
|
2779
3217
|
}
|
|
2780
|
-
const result = spawnSync("security", ["add-generic-password", "-a",
|
|
3218
|
+
const result = spawnSync("security", ["add-generic-password", "-a", account, "-s", TOKEN_SERVICE, "-w", value, "-U"], { encoding: "utf8" });
|
|
2781
3219
|
return result.status === 0;
|
|
2782
3220
|
}
|
|
2783
|
-
function deleteMacKeychainCredential() {
|
|
3221
|
+
function deleteMacKeychainCredential(account = TOKEN_ACCOUNT) {
|
|
2784
3222
|
if (process.platform !== "darwin") {
|
|
2785
3223
|
return false;
|
|
2786
3224
|
}
|
|
2787
|
-
const result = spawnSync("security", ["delete-generic-password", "-a",
|
|
3225
|
+
const result = spawnSync("security", ["delete-generic-password", "-a", account, "-s", TOKEN_SERVICE], { encoding: "utf8" });
|
|
2788
3226
|
return result.status === 0;
|
|
2789
3227
|
}
|
|
2790
|
-
function readWindowsDpapiCredential() {
|
|
3228
|
+
function readWindowsDpapiCredential(filePath = windowsCredentialPath()) {
|
|
2791
3229
|
if (process.platform !== "win32") {
|
|
2792
3230
|
return undefined;
|
|
2793
3231
|
}
|
|
2794
3232
|
try {
|
|
2795
|
-
const encrypted = readFileSync(
|
|
3233
|
+
const encrypted = readFileSync(filePath, "utf8").trim();
|
|
2796
3234
|
if (!encrypted) {
|
|
2797
3235
|
return undefined;
|
|
2798
3236
|
}
|
|
@@ -2810,7 +3248,7 @@ $plain = [Security.Cryptography.ProtectedData]::Unprotect($bytes, $null, [Securi
|
|
|
2810
3248
|
return undefined;
|
|
2811
3249
|
}
|
|
2812
3250
|
}
|
|
2813
|
-
function storeWindowsDpapiCredential(
|
|
3251
|
+
function storeWindowsDpapiCredential(value, targetPath = windowsCredentialPath()) {
|
|
2814
3252
|
if (process.platform !== "win32") {
|
|
2815
3253
|
return false;
|
|
2816
3254
|
}
|
|
@@ -2821,11 +3259,11 @@ $plain = [Console]::In.ReadToEnd()
|
|
|
2821
3259
|
$bytes = [Text.Encoding]::UTF8.GetBytes($plain)
|
|
2822
3260
|
$protected = [Security.Cryptography.ProtectedData]::Protect($bytes, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser)
|
|
2823
3261
|
[Console]::Out.Write([Convert]::ToBase64String($protected))
|
|
2824
|
-
`,
|
|
3262
|
+
`, value);
|
|
2825
3263
|
if (result.status !== 0 || !result.stdout.trim()) {
|
|
2826
3264
|
return false;
|
|
2827
3265
|
}
|
|
2828
|
-
const filePath =
|
|
3266
|
+
const filePath = targetPath;
|
|
2829
3267
|
mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
2830
3268
|
writeFileSync(filePath, `${result.stdout.trim()}\n`, { mode: 0o600 });
|
|
2831
3269
|
chmodIfSupported(filePath, 0o600);
|
|
@@ -2859,28 +3297,29 @@ function hasLinuxSecretTool() {
|
|
|
2859
3297
|
const result = spawnSync("secret-tool", ["--version"], { encoding: "utf8" });
|
|
2860
3298
|
return !result.error && result.status === 0;
|
|
2861
3299
|
}
|
|
2862
|
-
function readLinuxSecretServiceCredential() {
|
|
3300
|
+
function readLinuxSecretServiceCredential(account = TOKEN_ACCOUNT) {
|
|
2863
3301
|
if (process.platform !== "linux" || !hasLinuxSecretTool()) {
|
|
2864
3302
|
return undefined;
|
|
2865
3303
|
}
|
|
2866
|
-
const result = spawnSync("secret-tool", ["lookup", "service", TOKEN_SERVICE, "account",
|
|
3304
|
+
const result = spawnSync("secret-tool", ["lookup", "service", TOKEN_SERVICE, "account", account], { encoding: "utf8" });
|
|
2867
3305
|
return result.status === 0 ? result.stdout.trim() || undefined : undefined;
|
|
2868
3306
|
}
|
|
2869
|
-
function storeLinuxSecretServiceCredential(
|
|
3307
|
+
function storeLinuxSecretServiceCredential(value, account = TOKEN_ACCOUNT) {
|
|
2870
3308
|
if (process.platform !== "linux" || !hasLinuxSecretTool()) {
|
|
2871
3309
|
return false;
|
|
2872
3310
|
}
|
|
2873
|
-
const
|
|
2874
|
-
|
|
3311
|
+
const label = account === TOKEN_ACCOUNT ? "9pm.ai API token" : "9pm.ai API token issuer";
|
|
3312
|
+
const result = spawnSync("secret-tool", ["store", "--label", label, "service", TOKEN_SERVICE, "account", account], {
|
|
3313
|
+
input: value,
|
|
2875
3314
|
encoding: "utf8",
|
|
2876
3315
|
});
|
|
2877
3316
|
return result.status === 0;
|
|
2878
3317
|
}
|
|
2879
|
-
function deleteLinuxSecretServiceCredential() {
|
|
3318
|
+
function deleteLinuxSecretServiceCredential(account = TOKEN_ACCOUNT) {
|
|
2880
3319
|
if (process.platform !== "linux" || !hasLinuxSecretTool()) {
|
|
2881
3320
|
return false;
|
|
2882
3321
|
}
|
|
2883
|
-
const result = spawnSync("secret-tool", ["clear", "service", TOKEN_SERVICE, "account",
|
|
3322
|
+
const result = spawnSync("secret-tool", ["clear", "service", TOKEN_SERVICE, "account", account], { encoding: "utf8" });
|
|
2884
3323
|
return result.status === 0;
|
|
2885
3324
|
}
|
|
2886
3325
|
// Pure and exported so the per-platform mapping is unit-testable without
|
|
@@ -3049,8 +3488,137 @@ async function main() {
|
|
|
3049
3488
|
return;
|
|
3050
3489
|
}
|
|
3051
3490
|
if (command.kind === "logout") {
|
|
3052
|
-
|
|
3053
|
-
|
|
3491
|
+
// Revoke before deleting: once the local copies are gone the tokens can
|
|
3492
|
+
// never be revoked from this machine again. The local delete then happens
|
|
3493
|
+
// on every path — a credential that outlives logout on disk is strictly
|
|
3494
|
+
// worse than one that outlives it server-side (#822 is about the latter).
|
|
3495
|
+
const stored = storedCredentialTokens();
|
|
3496
|
+
// Removal is only done when no store hands a token back. The per-store
|
|
3497
|
+
// deletes can partially fail (a locked keychain) while the metadata file
|
|
3498
|
+
// is removed, and reporting that as success would tell someone a live
|
|
3499
|
+
// credential is gone from a machine that still holds it.
|
|
3500
|
+
const clearLocalCredential = () => {
|
|
3501
|
+
deleteStoredCredential();
|
|
3502
|
+
return storedCredentialTokens().length === 0;
|
|
3503
|
+
};
|
|
3504
|
+
// A skipped protected store makes ANY logout incomplete, including one
|
|
3505
|
+
// that successfully revoked a plaintext credential sitting beside it: the
|
|
3506
|
+
// protected token was neither read, revoked, nor removed, and every later
|
|
3507
|
+
// command run without this variable set will use it. Computed before
|
|
3508
|
+
// anything is deleted, since it reads the metadata file.
|
|
3509
|
+
const skippedNativeStore = isNativeCredentialStoreDisabled() && nativeCredentialMayExist();
|
|
3510
|
+
const reportSkippedNativeStore = () => {
|
|
3511
|
+
console.error(`${DISABLE_NATIVE_CREDENTIAL_STORE_ENV} is set, so the protected credential store was skipped: a credential stored there was neither revoked nor removed.`);
|
|
3512
|
+
console.error(`Unset ${DISABLE_NATIVE_CREDENTIAL_STORE_ENV} and run 9pm logout again to retire it.`);
|
|
3513
|
+
process.exitCode = 1;
|
|
3514
|
+
};
|
|
3515
|
+
if (stored.length === 0) {
|
|
3516
|
+
const removedLeftovers = deleteStoredCredential();
|
|
3517
|
+
if (skippedNativeStore) {
|
|
3518
|
+
reportSkippedNativeStore();
|
|
3519
|
+
return;
|
|
3520
|
+
}
|
|
3521
|
+
console.log(removedLeftovers ? "Removed leftover credential files; no readable credential was stored." : "No stored credential found.");
|
|
3522
|
+
return;
|
|
3523
|
+
}
|
|
3524
|
+
// Resolved PER TOKEN, not once: two stores can hold credentials from two
|
|
3525
|
+
// different control planes, and a key can only be revoked where it was
|
|
3526
|
+
// issued. The issuer recorded with the credential wins — it is what
|
|
3527
|
+
// minted the key, and an exported NINEPM_API_URL pointing somewhere else
|
|
3528
|
+
// (routine on a dev machine) would otherwise send a production token to
|
|
3529
|
+
// staging and read the resulting 403 as a clean logout. --api-url and
|
|
3530
|
+
// NINEPM_API_URL name the plane only for credentials that recorded none
|
|
3531
|
+
// (stored before 0.42); `issuerKnown` is false when nobody stated one and
|
|
3532
|
+
// the CLI fell back to the default.
|
|
3533
|
+
const targets = stored.map((entry) => ({
|
|
3534
|
+
token: entry.token,
|
|
3535
|
+
apiUrl: entry.apiUrl ?? command.apiUrl ?? DEFAULT_API_URL,
|
|
3536
|
+
issuerKnown: Boolean(entry.apiUrl ?? command.apiUrl),
|
|
3537
|
+
}));
|
|
3538
|
+
if (command.local) {
|
|
3539
|
+
const cleared = clearLocalCredential();
|
|
3540
|
+
if (cleared) {
|
|
3541
|
+
console.log("Removed the stored credential from this machine without revoking it. It can still authenticate.");
|
|
3542
|
+
}
|
|
3543
|
+
else {
|
|
3544
|
+
console.error("The credential could not be removed (is the OS keychain locked?) and remains on this machine.");
|
|
3545
|
+
process.exitCode = 1;
|
|
3546
|
+
}
|
|
3547
|
+
if (skippedNativeStore) {
|
|
3548
|
+
reportSkippedNativeStore();
|
|
3549
|
+
}
|
|
3550
|
+
return;
|
|
3551
|
+
}
|
|
3552
|
+
const results = [];
|
|
3553
|
+
for (const target of targets) {
|
|
3554
|
+
// A malformed --api-url/NINEPM_API_URL only sinks the credentials that
|
|
3555
|
+
// NEED it — the ones that recorded no issuer of their own. One that
|
|
3556
|
+
// carries a trusted issuer is revoked there regardless, rather than
|
|
3557
|
+
// being deleted unrevoked because an unused override failed to parse.
|
|
3558
|
+
if (!target.issuerKnown && command.apiUrlError) {
|
|
3559
|
+
results.push({ ...target, outcome: { kind: "failed", reason: command.apiUrlError } });
|
|
3560
|
+
continue;
|
|
3561
|
+
}
|
|
3562
|
+
results.push({ ...target, outcome: await revokeCredential({ apiUrl: target.apiUrl, token: target.token }) });
|
|
3563
|
+
}
|
|
3564
|
+
const summary = summarizeRevocations(results);
|
|
3565
|
+
const removedLocally = clearLocalCredential();
|
|
3566
|
+
const removalWarning = removedLocally
|
|
3567
|
+
? null
|
|
3568
|
+
: "Warning: the local credential copy could not be removed (is the OS keychain locked?) and remains on this machine.";
|
|
3569
|
+
if (!removedLocally) {
|
|
3570
|
+
// Even after a successful revoke: the credential is dead but still on
|
|
3571
|
+
// the machine, so later commands load it and fail to authenticate.
|
|
3572
|
+
// Logout did not finish, and a script must not read that as success.
|
|
3573
|
+
process.exitCode = 1;
|
|
3574
|
+
}
|
|
3575
|
+
if (summary.kind === "revoked") {
|
|
3576
|
+
console.log("Logged out. The credential was revoked and can no longer authenticate.");
|
|
3577
|
+
if (removalWarning) {
|
|
3578
|
+
console.warn(removalWarning);
|
|
3579
|
+
}
|
|
3580
|
+
}
|
|
3581
|
+
else if (summary.kind === "unproven") {
|
|
3582
|
+
// Logout still happens — refusing would block the ordinary upgrade
|
|
3583
|
+
// path, where a pre-0.42 credential really was issued by the default
|
|
3584
|
+
// plane and really is dead — but it cannot be called confirmed.
|
|
3585
|
+
console.error(`Removed the local credential, but could not confirm it was revoked: ${summary.apiUrl} does not recognise it.`);
|
|
3586
|
+
console.error("An older CLI stored it without recording which control plane issued it. If you used another control plane, revoke it under Connected devices there.");
|
|
3587
|
+
if (removalWarning) {
|
|
3588
|
+
console.warn(removalWarning);
|
|
3589
|
+
}
|
|
3590
|
+
process.exitCode = 1;
|
|
3591
|
+
}
|
|
3592
|
+
else if (summary.kind === "already_invalid") {
|
|
3593
|
+
// "Rejects" rather than "was revoked": the control plane answers the
|
|
3594
|
+
// same 403 for an expired, revoked, or suspended-account credential,
|
|
3595
|
+
// and only the first two are permanent. Nothing can be revoked from
|
|
3596
|
+
// here in any of the three — the revoke route needs the same
|
|
3597
|
+
// authentication the server just refused.
|
|
3598
|
+
console.log("Logged out. The server already rejects this credential; nothing to revoke from here.");
|
|
3599
|
+
if (removalWarning) {
|
|
3600
|
+
console.warn(removalWarning);
|
|
3601
|
+
}
|
|
3602
|
+
}
|
|
3603
|
+
else {
|
|
3604
|
+
// Deliberate partial outcome, exit 1: "logged out" would be a lie — a
|
|
3605
|
+
// live full-scope token remains on the account, and only the user can
|
|
3606
|
+
// retire it now.
|
|
3607
|
+
console.error(removedLocally
|
|
3608
|
+
? `Removed the local credential, but could not revoke it (${summary.reason}).`
|
|
3609
|
+
: `Could not revoke the credential (${summary.reason}), and the local copy could not be removed.`);
|
|
3610
|
+
// Only name the production dashboard when every credential belongs to
|
|
3611
|
+
// it; a staging or self-hosted key cannot be revoked there, and a
|
|
3612
|
+
// rejected URL leaves the origin unknown.
|
|
3613
|
+
const settingsHint = !command.apiUrlError && targets.every((target) => target.apiUrl === DEFAULT_API_URL)
|
|
3614
|
+
? `Revoke it under Connected devices: ${PUBLIC_DASHBOARD_URL}/settings`
|
|
3615
|
+
: "Revoke it under Connected devices in its control plane's dashboard settings.";
|
|
3616
|
+
console.error(`It can still authenticate. ${settingsHint}`);
|
|
3617
|
+
process.exitCode = 1;
|
|
3618
|
+
}
|
|
3619
|
+
if (skippedNativeStore) {
|
|
3620
|
+
reportSkippedNativeStore();
|
|
3621
|
+
}
|
|
3054
3622
|
return;
|
|
3055
3623
|
}
|
|
3056
3624
|
if (command.kind === "doctor") {
|
|
@@ -3320,7 +3888,7 @@ function printUsage() {
|
|
|
3320
3888
|
9pm deploy [directory] --check
|
|
3321
3889
|
9pm signup
|
|
3322
3890
|
9pm login
|
|
3323
|
-
9pm logout
|
|
3891
|
+
9pm logout [--api-url <url>] [--local] revokes the credential server-side, then removes it from this machine (--local skips the revoke)
|
|
3324
3892
|
9pm doctor [--json]
|
|
3325
3893
|
9pm whoami [--json]
|
|
3326
3894
|
9pm apps [--json]
|