ninepm 0.41.0 → 0.43.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 +606 -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.43.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.43.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);
|
|
@@ -2240,11 +2383,52 @@ async function runLogsCommand(command) {
|
|
|
2240
2383
|
for (const line of lines) {
|
|
2241
2384
|
console.log(line);
|
|
2242
2385
|
}
|
|
2386
|
+
}
|
|
2387
|
+
// #820: the SERVING deployment's runtime half — restart/exit summary plus
|
|
2388
|
+
// recent app output. The serving deployment can differ from the
|
|
2389
|
+
// failure-diagnostics one above (a queued or failed replacement is newer),
|
|
2390
|
+
// so the section names whose runtime it describes.
|
|
2391
|
+
const servingLabel = result.runtimeDeployment && result.runtimeDeployment.id !== deployment.id
|
|
2392
|
+
? ` (serving deployment ${result.runtimeDeployment.id})`
|
|
2393
|
+
: "";
|
|
2394
|
+
if (result.runtime && (result.runtime.restartCount > 0 || result.runtime.lastExitCode !== null)) {
|
|
2395
|
+
const parts = [`restarts: ${result.runtime.restartCount}`];
|
|
2396
|
+
if (result.runtime.lastExitCode !== null)
|
|
2397
|
+
parts.push(`last exit code: ${result.runtime.lastExitCode}`);
|
|
2398
|
+
if (result.runtime.lastExitAt)
|
|
2399
|
+
parts.push(`last exit: ${result.runtime.lastExitAt}`);
|
|
2400
|
+
console.log("");
|
|
2401
|
+
console.log(`Runtime${servingLabel}: ${parts.join("\t")}`);
|
|
2402
|
+
}
|
|
2403
|
+
if (result.runtimeLogs) {
|
|
2404
|
+
let lines = result.runtimeLogs.split("\n");
|
|
2405
|
+
if (command.lines !== undefined) {
|
|
2406
|
+
lines = lines.slice(-command.lines);
|
|
2407
|
+
}
|
|
2408
|
+
console.log("");
|
|
2409
|
+
console.log(`Recent runtime log${servingLabel}:`);
|
|
2410
|
+
for (const line of lines) {
|
|
2411
|
+
console.log(line);
|
|
2412
|
+
}
|
|
2413
|
+
return;
|
|
2414
|
+
}
|
|
2415
|
+
if (result.runtimeLogsUnavailable) {
|
|
2416
|
+
const copy = {
|
|
2417
|
+
placement_unsupported: "Runtime logs are not yet available for this app's placement.",
|
|
2418
|
+
not_configured: "Runtime logs are not configured on this platform.",
|
|
2419
|
+
nothing_retained: "No recent runtime output is retained for the serving deployment.",
|
|
2420
|
+
provider_unavailable: "Runtime logs are temporarily unavailable; try again shortly.",
|
|
2421
|
+
};
|
|
2422
|
+
console.log("");
|
|
2423
|
+
console.log(copy[result.runtimeLogsUnavailable] ?? "Runtime logs are unavailable.");
|
|
2424
|
+
return;
|
|
2425
|
+
}
|
|
2426
|
+
if (result.logs) {
|
|
2243
2427
|
return;
|
|
2244
2428
|
}
|
|
2245
2429
|
console.log(deployment.status === "failed"
|
|
2246
2430
|
? "No detailed logs were captured for this failure."
|
|
2247
|
-
: "No logs recorded. Logs are captured when a deployment fails to come up
|
|
2431
|
+
: "No logs recorded. Logs are captured when a deployment fails to come up.");
|
|
2248
2432
|
}
|
|
2249
2433
|
function parseLogsArgs(argv) {
|
|
2250
2434
|
const site = argv[0];
|
|
@@ -2571,6 +2755,11 @@ function credentialMetadataPath() {
|
|
|
2571
2755
|
function windowsCredentialPath() {
|
|
2572
2756
|
return path.join(credentialConfigDir(), WINDOWS_CREDENTIAL_FILE);
|
|
2573
2757
|
}
|
|
2758
|
+
// DPAPI-protected like the token beside it, so tampering with it requires the
|
|
2759
|
+
// same user context rather than plain write access to the directory.
|
|
2760
|
+
function windowsIssuerPath() {
|
|
2761
|
+
return path.join(credentialConfigDir(), WINDOWS_ISSUER_FILE);
|
|
2762
|
+
}
|
|
2574
2763
|
function plaintextCredentialPath() {
|
|
2575
2764
|
return path.join(credentialConfigDir(), PLAINTEXT_CREDENTIAL_FILE);
|
|
2576
2765
|
}
|
|
@@ -2588,26 +2777,107 @@ function readStoredCredential() {
|
|
|
2588
2777
|
}
|
|
2589
2778
|
return plaintext.token;
|
|
2590
2779
|
}
|
|
2780
|
+
// Every distinct token logout is about to delete, from every store the delete
|
|
2781
|
+
// will touch. Reads without side effects — readStoredCredential() migrates a
|
|
2782
|
+
// legacy plaintext file into the keychain, pointless work one statement before
|
|
2783
|
+
// deletion. Two stores can hold two different live tokens (a file-fallback
|
|
2784
|
+
// login followed by a native-store one); each needs its own revocation or
|
|
2785
|
+
// logout silently orphans one — the leak #822 is about.
|
|
2786
|
+
export function storedCredentialTokens() {
|
|
2787
|
+
// Each store reports its own issuer, carried with the token rather than read
|
|
2788
|
+
// from the shared metadata file: two stores can hold credentials from two
|
|
2789
|
+
// different control planes (a file-fallback staging login followed by a
|
|
2790
|
+
// keychain production login), and a key can only be revoked where it was
|
|
2791
|
+
// issued — a foreign plane answers 403, which reads as "already invalid".
|
|
2792
|
+
const entries = [];
|
|
2793
|
+
const secure = readSecureCredentialEntry();
|
|
2794
|
+
if (secure) {
|
|
2795
|
+
entries.push(secure);
|
|
2796
|
+
}
|
|
2797
|
+
// BOTH plaintext files, not just the first that parses: the current file and
|
|
2798
|
+
// the legacy one can hold different tokens (a fallback login here, then one
|
|
2799
|
+
// from a still-installed older CLI), and deleteStoredCredential removes both
|
|
2800
|
+
// — so revoking only the first would discard the other while it is still live.
|
|
2801
|
+
for (const plaintext of readPlaintextCredentials()) {
|
|
2802
|
+
if (entries.some((entry) => entry.token === plaintext.token)) {
|
|
2803
|
+
continue;
|
|
2804
|
+
}
|
|
2805
|
+
// The plaintext file's own body: no less protected than the token it sits
|
|
2806
|
+
// next to, and immune to a later login through the other store.
|
|
2807
|
+
entries.push({ token: plaintext.token, apiUrl: safeApiUrl(plaintext.apiUrl) });
|
|
2808
|
+
}
|
|
2809
|
+
return entries;
|
|
2810
|
+
}
|
|
2811
|
+
// Whether a protected credential was ever stored from this config directory.
|
|
2812
|
+
// Read from the unprotected metadata file, which is safe here because the
|
|
2813
|
+
// answer only decides whether to WARN — it never selects a destination for a
|
|
2814
|
+
// token. Reads the sticky flag as well as the last-login backend, so a later
|
|
2815
|
+
// file-fallback login cannot erase the evidence.
|
|
2816
|
+
//
|
|
2817
|
+
// Best-effort by construction: the metadata file is per-config-directory while
|
|
2818
|
+
// the OS store is machine-wide, so a credential stored under a different
|
|
2819
|
+
// XDG_CONFIG_HOME is invisible here. Confirming it would mean querying the very
|
|
2820
|
+
// store the caller asked this process not to touch (and on macOS risking an
|
|
2821
|
+
// unlock prompt in the CI runs that variable exists for), so absent evidence
|
|
2822
|
+
// stays silent rather than warning every file-only logout about a credential
|
|
2823
|
+
// the machine may not have.
|
|
2824
|
+
function nativeCredentialMayExist() {
|
|
2825
|
+
try {
|
|
2826
|
+
const parsed = JSON.parse(readFileSync(credentialMetadataPath(), "utf8"));
|
|
2827
|
+
return parsed.nativeCredentialStored === true || (typeof parsed.backend === "string" && isNativeBackend(parsed.backend));
|
|
2828
|
+
}
|
|
2829
|
+
catch {
|
|
2830
|
+
return false;
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
// Validated because the metadata and plaintext files sit unprotected on disk
|
|
2834
|
+
// and a tampered URL must not receive the bearer token. Invalid or absent
|
|
2835
|
+
// (pre-0.42 logins never recorded one) reads as undefined.
|
|
2836
|
+
function safeApiUrl(value) {
|
|
2837
|
+
if (!value) {
|
|
2838
|
+
return undefined;
|
|
2839
|
+
}
|
|
2840
|
+
try {
|
|
2841
|
+
return validateApiUrl(value);
|
|
2842
|
+
}
|
|
2843
|
+
catch {
|
|
2844
|
+
return undefined;
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2591
2847
|
function storeCredential(token, options = {}) {
|
|
2592
|
-
const secure = storeSecureCredential(token);
|
|
2848
|
+
const secure = storeSecureCredential(token, options.apiUrl);
|
|
2593
2849
|
if (secure) {
|
|
2594
2850
|
return secure;
|
|
2595
2851
|
}
|
|
2596
2852
|
if (!isFileCredentialStoreAllowed(options.allowFileFallback)) {
|
|
2597
2853
|
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
2854
|
}
|
|
2599
|
-
return storePlaintextCredential(token);
|
|
2855
|
+
return storePlaintextCredential(token, options.apiUrl);
|
|
2600
2856
|
}
|
|
2601
2857
|
function deleteStoredCredential() {
|
|
2858
|
+
// Honor the native-store opt-out on delete as well as read/store: a process
|
|
2859
|
+
// told not to touch the OS keychain cannot see a keychain credential, so
|
|
2860
|
+
// deleting it would wipe a token that was never revoked.
|
|
2861
|
+
// Preserved when the protected store is skipped: the credential there is
|
|
2862
|
+
// untouched, so the record that it exists must outlive this deletion or the
|
|
2863
|
+
// NEXT logout has nothing left to warn from.
|
|
2864
|
+
const keepNativeMarker = isNativeCredentialStoreDisabled() && nativeCredentialMayExist();
|
|
2865
|
+
const nativeRemovals = isNativeCredentialStoreDisabled()
|
|
2866
|
+
? []
|
|
2867
|
+
: [deleteMacKeychainCredential(), deleteWindowsDpapiCredential(), deleteLinuxSecretServiceCredential(), deleteSecureIssuer()];
|
|
2602
2868
|
const removed = [
|
|
2603
|
-
|
|
2604
|
-
deleteWindowsDpapiCredential(),
|
|
2605
|
-
deleteLinuxSecretServiceCredential(),
|
|
2869
|
+
...nativeRemovals,
|
|
2606
2870
|
removeFileIfExists(credentialMetadataPath()),
|
|
2607
|
-
|
|
2871
|
+
// windowsCredentialPath() is deliberately NOT removed here: it IS the DPAPI
|
|
2872
|
+
// store, so deleteWindowsDpapiCredential owns it. Removing it again outside
|
|
2873
|
+
// the guard above would delete a Windows credential that this process was
|
|
2874
|
+
// told not to touch, and therefore never read or revoked.
|
|
2608
2875
|
removeFileIfExists(plaintextCredentialPath()),
|
|
2609
2876
|
removeFileIfExists(legacyCredentialPath()),
|
|
2610
2877
|
];
|
|
2878
|
+
if (keepNativeMarker) {
|
|
2879
|
+
writeCredentialMetadata("none", {}, true);
|
|
2880
|
+
}
|
|
2611
2881
|
return removed.some(Boolean);
|
|
2612
2882
|
}
|
|
2613
2883
|
export function credentialDiagnostics() {
|
|
@@ -2648,34 +2918,157 @@ export function credentialDiagnostics() {
|
|
|
2648
2918
|
};
|
|
2649
2919
|
}
|
|
2650
2920
|
function readSecureCredential() {
|
|
2921
|
+
return readSecureCredentialEntry()?.token;
|
|
2922
|
+
}
|
|
2923
|
+
// The issuer is not a secret, but it decides where a full-scope bearer token
|
|
2924
|
+
// gets sent, so it needs the same INTEGRITY protection as the token itself: the
|
|
2925
|
+
// metadata file beside it is unprotected, and trusting that to name the
|
|
2926
|
+
// destination would make "can write to the home directory" enough to redirect
|
|
2927
|
+
// the token to a host of the attacker's choosing — handing out a key the
|
|
2928
|
+
// keychain exists to keep unreadable.
|
|
2929
|
+
//
|
|
2930
|
+
// It is kept as a SEPARATE item rather than wrapped around the token, because
|
|
2931
|
+
// the token slot is shared with every other 9pm CLI on the machine: a payload
|
|
2932
|
+
// there would be read whole and sent as the bearer by any pre-0.42 CLI (this
|
|
2933
|
+
// repo's own `npm run cli` next to a global `ninepm`), failing with a
|
|
2934
|
+
// malformed-token error that names nothing. An absent item — every credential
|
|
2935
|
+
// stored before 0.42 — reads as an unknown issuer.
|
|
2936
|
+
function readSecureCredentialEntry() {
|
|
2651
2937
|
if (isNativeCredentialStoreDisabled()) {
|
|
2652
2938
|
return undefined;
|
|
2653
2939
|
}
|
|
2940
|
+
const token = process.platform === "darwin"
|
|
2941
|
+
? readMacKeychainCredential()
|
|
2942
|
+
: process.platform === "win32"
|
|
2943
|
+
? readWindowsDpapiCredential()
|
|
2944
|
+
: process.platform === "linux"
|
|
2945
|
+
? readLinuxSecretServiceCredential()
|
|
2946
|
+
: undefined;
|
|
2947
|
+
return token ? { token, apiUrl: safeApiUrl(readSecureIssuer(token)) } : undefined;
|
|
2948
|
+
}
|
|
2949
|
+
// The issuer is bound to the exact token it was recorded for, by fingerprint.
|
|
2950
|
+
// Two 9pm CLIs share the token slot, and a pre-0.42 one knows nothing about the
|
|
2951
|
+
// issuer item: it overwrites the token and leaves the issuer behind, pairing
|
|
2952
|
+
// values written by different logins. Without this check a production token
|
|
2953
|
+
// would inherit a staging issuer, be sent there, and staging's invalid_api_key
|
|
2954
|
+
// would be read as conclusive — a silent clean logout for a live key. A
|
|
2955
|
+
// mismatch reads as no issuer, which logout refuses to guess past.
|
|
2956
|
+
function readSecureIssuer(token) {
|
|
2957
|
+
const raw = process.platform === "darwin"
|
|
2958
|
+
? readMacKeychainCredential(ISSUER_ACCOUNT)
|
|
2959
|
+
: process.platform === "win32"
|
|
2960
|
+
? readWindowsDpapiCredential(windowsIssuerPath())
|
|
2961
|
+
: process.platform === "linux"
|
|
2962
|
+
? readLinuxSecretServiceCredential(ISSUER_ACCOUNT)
|
|
2963
|
+
: undefined;
|
|
2964
|
+
if (!raw) {
|
|
2965
|
+
return undefined;
|
|
2966
|
+
}
|
|
2967
|
+
try {
|
|
2968
|
+
const parsed = JSON.parse(raw);
|
|
2969
|
+
if (typeof parsed.apiUrl === "string" && parsed.token === tokenFingerprint(token)) {
|
|
2970
|
+
return parsed.apiUrl;
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2973
|
+
catch {
|
|
2974
|
+
// Not the fingerprinted shape (a 0.42.0-dev item) — unusable, so unknown.
|
|
2975
|
+
}
|
|
2976
|
+
return undefined;
|
|
2977
|
+
}
|
|
2978
|
+
function storeSecureIssuer(apiUrl, token) {
|
|
2979
|
+
return storeSecureIssuerRaw(JSON.stringify({ apiUrl, token: tokenFingerprint(token) }));
|
|
2980
|
+
}
|
|
2981
|
+
// The stored item verbatim, so a failed token write can put back exactly what
|
|
2982
|
+
// was there — including its fingerprint, which still matches the token that
|
|
2983
|
+
// remains in the store.
|
|
2984
|
+
function readSecureIssuerRaw() {
|
|
2654
2985
|
if (process.platform === "darwin") {
|
|
2655
|
-
return readMacKeychainCredential();
|
|
2986
|
+
return readMacKeychainCredential(ISSUER_ACCOUNT);
|
|
2656
2987
|
}
|
|
2657
2988
|
if (process.platform === "win32") {
|
|
2658
|
-
return readWindowsDpapiCredential();
|
|
2989
|
+
return readWindowsDpapiCredential(windowsIssuerPath());
|
|
2659
2990
|
}
|
|
2660
2991
|
if (process.platform === "linux") {
|
|
2661
|
-
return readLinuxSecretServiceCredential();
|
|
2992
|
+
return readLinuxSecretServiceCredential(ISSUER_ACCOUNT);
|
|
2662
2993
|
}
|
|
2663
2994
|
return undefined;
|
|
2664
2995
|
}
|
|
2665
|
-
function
|
|
2996
|
+
function storeSecureIssuerRaw(payload) {
|
|
2997
|
+
if (process.platform === "darwin") {
|
|
2998
|
+
return storeMacKeychainCredential(payload, ISSUER_ACCOUNT);
|
|
2999
|
+
}
|
|
3000
|
+
if (process.platform === "win32") {
|
|
3001
|
+
return storeWindowsDpapiCredential(payload, windowsIssuerPath());
|
|
3002
|
+
}
|
|
3003
|
+
if (process.platform === "linux") {
|
|
3004
|
+
return storeLinuxSecretServiceCredential(payload, ISSUER_ACCOUNT);
|
|
3005
|
+
}
|
|
3006
|
+
return false;
|
|
3007
|
+
}
|
|
3008
|
+
// Identifies which token an issuer was recorded for without storing a second
|
|
3009
|
+
// copy of the secret.
|
|
3010
|
+
function tokenFingerprint(token) {
|
|
3011
|
+
return createHash("sha256").update(token).digest("hex");
|
|
3012
|
+
}
|
|
3013
|
+
function deleteSecureIssuer() {
|
|
3014
|
+
if (process.platform === "darwin") {
|
|
3015
|
+
return deleteMacKeychainCredential(ISSUER_ACCOUNT);
|
|
3016
|
+
}
|
|
3017
|
+
if (process.platform === "win32") {
|
|
3018
|
+
return removeFileIfExists(windowsIssuerPath());
|
|
3019
|
+
}
|
|
3020
|
+
if (process.platform === "linux") {
|
|
3021
|
+
return deleteLinuxSecretServiceCredential(ISSUER_ACCOUNT);
|
|
3022
|
+
}
|
|
3023
|
+
return false;
|
|
3024
|
+
}
|
|
3025
|
+
function storeSecureCredential(token, apiUrl) {
|
|
2666
3026
|
if (isNativeCredentialStoreDisabled()) {
|
|
2667
3027
|
return undefined;
|
|
2668
3028
|
}
|
|
2669
|
-
|
|
2670
|
-
|
|
3029
|
+
// The previous login's issuer is cleared BEFORE the new token is written, so
|
|
3030
|
+
// the two are never observable as a mismatched pair: a token sent to the
|
|
3031
|
+
// wrong control plane at logout gets a 403 that reads as "already invalid"
|
|
3032
|
+
// while the key stays live at its real issuer. The window this leaves is the
|
|
3033
|
+
// safe one — a token with no issuer — which logout refuses to guess past.
|
|
3034
|
+
// If the token write then fails, the OLD token is still in the store, so its
|
|
3035
|
+
// issuer is put back rather than left erased.
|
|
3036
|
+
const previousIssuer = readSecureIssuerRaw();
|
|
3037
|
+
deleteSecureIssuer();
|
|
3038
|
+
const origin = apiUrl ? { apiUrl } : {};
|
|
3039
|
+
const recordIssuer = () => {
|
|
3040
|
+
if (apiUrl && !storeSecureIssuer(apiUrl, token)) {
|
|
3041
|
+
console.warn("Warning: could not record which control plane issued this credential. 9pm logout will not be able to revoke it without --api-url.");
|
|
3042
|
+
}
|
|
3043
|
+
};
|
|
3044
|
+
const restorePreviousIssuer = () => {
|
|
3045
|
+
if (previousIssuer) {
|
|
3046
|
+
storeSecureIssuerRaw(previousIssuer);
|
|
3047
|
+
}
|
|
3048
|
+
return undefined;
|
|
3049
|
+
};
|
|
3050
|
+
if (process.platform === "darwin") {
|
|
3051
|
+
if (!storeMacKeychainCredential(token)) {
|
|
3052
|
+
return restorePreviousIssuer();
|
|
3053
|
+
}
|
|
3054
|
+
recordIssuer();
|
|
3055
|
+
writeCredentialMetadata("macos-keychain", origin);
|
|
2671
3056
|
return "macOS Keychain";
|
|
2672
3057
|
}
|
|
2673
|
-
if (process.platform === "win32"
|
|
2674
|
-
|
|
3058
|
+
if (process.platform === "win32") {
|
|
3059
|
+
if (!storeWindowsDpapiCredential(token)) {
|
|
3060
|
+
return restorePreviousIssuer();
|
|
3061
|
+
}
|
|
3062
|
+
recordIssuer();
|
|
3063
|
+
writeCredentialMetadata("windows-dpapi", { secretPath: windowsCredentialPath(), ...origin });
|
|
2675
3064
|
return "Windows DPAPI";
|
|
2676
3065
|
}
|
|
2677
|
-
if (process.platform === "linux"
|
|
2678
|
-
|
|
3066
|
+
if (process.platform === "linux") {
|
|
3067
|
+
if (!storeLinuxSecretServiceCredential(token)) {
|
|
3068
|
+
return restorePreviousIssuer();
|
|
3069
|
+
}
|
|
3070
|
+
recordIssuer();
|
|
3071
|
+
writeCredentialMetadata("linux-secret-service", origin);
|
|
2679
3072
|
return "Linux Secret Service";
|
|
2680
3073
|
}
|
|
2681
3074
|
return undefined;
|
|
@@ -2695,44 +3088,82 @@ function preferredCredentialBackend() {
|
|
|
2695
3088
|
}
|
|
2696
3089
|
return "none";
|
|
2697
3090
|
}
|
|
2698
|
-
|
|
3091
|
+
// forceNativeMarker carries the sticky flag across a rewrite that follows the
|
|
3092
|
+
// metadata file's deletion, where it can no longer be read back.
|
|
3093
|
+
function writeCredentialMetadata(backend, extra = {}, forceNativeMarker = false) {
|
|
2699
3094
|
const filePath = credentialMetadataPath();
|
|
2700
3095
|
mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
3096
|
+
// This file is diagnostics only. Its `apiUrl` records where the credential
|
|
3097
|
+
// came from for a human reading it (and for `9pm doctor`), and is deliberately
|
|
3098
|
+
// NEVER read to decide where a token is sent: the file is unprotected, so
|
|
3099
|
+
// trusting it would make "can write to the home directory" enough to redirect
|
|
3100
|
+
// a bearer token. Each store carries its own issuer with the token instead.
|
|
3101
|
+
// `nativeCredentialStored` is sticky: `backend` names only the LAST login, so
|
|
3102
|
+
// a file-fallback login would otherwise erase the record that a protected
|
|
3103
|
+
// credential was ever stored — the one thing that lets logout warn when it
|
|
3104
|
+
// has been told to skip that store. Cleared only by deleting this file, which
|
|
3105
|
+
// happens alongside a real native deletion.
|
|
3106
|
+
const nativeCredentialStored = forceNativeMarker || isNativeBackend(backend) || readNativeCredentialStoredFlag();
|
|
2701
3107
|
writeFileSync(filePath, `${JSON.stringify({
|
|
2702
3108
|
backend,
|
|
2703
3109
|
service: TOKEN_SERVICE,
|
|
2704
3110
|
account: TOKEN_ACCOUNT,
|
|
2705
3111
|
updatedAt: new Date().toISOString(),
|
|
2706
3112
|
...extra,
|
|
3113
|
+
...(nativeCredentialStored ? { nativeCredentialStored } : {}),
|
|
2707
3114
|
}, null, 2)}\n`, { mode: 0o600 });
|
|
2708
3115
|
chmodIfSupported(filePath, 0o600);
|
|
2709
3116
|
}
|
|
2710
|
-
function
|
|
3117
|
+
function isNativeBackend(backend) {
|
|
3118
|
+
return backend === "macos-keychain" || backend === "windows-dpapi" || backend === "linux-secret-service";
|
|
3119
|
+
}
|
|
3120
|
+
function readNativeCredentialStoredFlag() {
|
|
3121
|
+
try {
|
|
3122
|
+
const parsed = JSON.parse(readFileSync(credentialMetadataPath(), "utf8"));
|
|
3123
|
+
return parsed.nativeCredentialStored === true;
|
|
3124
|
+
}
|
|
3125
|
+
catch {
|
|
3126
|
+
return false;
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
function storePlaintextCredential(token, apiUrl) {
|
|
2711
3130
|
const filePath = plaintextCredentialPath();
|
|
2712
3131
|
mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
2713
3132
|
writeFileSync(filePath, `${JSON.stringify({
|
|
2714
3133
|
token,
|
|
3134
|
+
...(apiUrl ? { apiUrl } : {}),
|
|
2715
3135
|
warning: "Plaintext fallback credential. Prefer OS-protected credential storage.",
|
|
2716
3136
|
}, null, 2)}\n`, { mode: 0o600 });
|
|
2717
3137
|
chmodIfSupported(filePath, 0o600);
|
|
2718
|
-
writeCredentialMetadata("file-plaintext", { plaintextPath: filePath });
|
|
3138
|
+
writeCredentialMetadata("file-plaintext", { plaintextPath: filePath, ...(apiUrl ? { apiUrl } : {}) });
|
|
2719
3139
|
console.warn(`Warning: stored credential in plaintext fallback file ${filePath} with mode 0600 where supported.`);
|
|
2720
3140
|
return filePath;
|
|
2721
3141
|
}
|
|
3142
|
+
// The highest-precedence plaintext credential — what the CLI authenticates
|
|
3143
|
+
// with. Logout needs every one of them instead; see readPlaintextCredentials.
|
|
2722
3144
|
function readPlaintextCredential() {
|
|
3145
|
+
return readPlaintextCredentials()[0];
|
|
3146
|
+
}
|
|
3147
|
+
function readPlaintextCredentials() {
|
|
2723
3148
|
const candidates = [plaintextCredentialPath(), legacyCredentialPath()].filter((candidate, index, list) => list.indexOf(candidate) === index);
|
|
3149
|
+
const found = [];
|
|
2724
3150
|
for (const filePath of candidates) {
|
|
2725
3151
|
try {
|
|
2726
3152
|
const parsed = JSON.parse(readFileSync(filePath, "utf8"));
|
|
2727
3153
|
if (typeof parsed.token === "string" && parsed.token) {
|
|
2728
|
-
|
|
3154
|
+
found.push({
|
|
3155
|
+
token: parsed.token,
|
|
3156
|
+
path: filePath,
|
|
3157
|
+
legacy: filePath === legacyCredentialPath(),
|
|
3158
|
+
...(typeof parsed.apiUrl === "string" && parsed.apiUrl ? { apiUrl: parsed.apiUrl } : {}),
|
|
3159
|
+
});
|
|
2729
3160
|
}
|
|
2730
3161
|
}
|
|
2731
3162
|
catch {
|
|
2732
3163
|
continue;
|
|
2733
3164
|
}
|
|
2734
3165
|
}
|
|
2735
|
-
return
|
|
3166
|
+
return found;
|
|
2736
3167
|
}
|
|
2737
3168
|
function migrateLegacyPlaintextCredential(credential) {
|
|
2738
3169
|
const secure = storeSecureCredential(credential.token);
|
|
@@ -2766,33 +3197,33 @@ function removeFileIfExists(filePath) {
|
|
|
2766
3197
|
rmSync(filePath, { force: true });
|
|
2767
3198
|
return true;
|
|
2768
3199
|
}
|
|
2769
|
-
function readMacKeychainCredential() {
|
|
3200
|
+
function readMacKeychainCredential(account = TOKEN_ACCOUNT) {
|
|
2770
3201
|
if (process.platform !== "darwin") {
|
|
2771
3202
|
return undefined;
|
|
2772
3203
|
}
|
|
2773
|
-
const result = spawnSync("security", ["find-generic-password", "-a",
|
|
3204
|
+
const result = spawnSync("security", ["find-generic-password", "-a", account, "-s", TOKEN_SERVICE, "-w"], { encoding: "utf8" });
|
|
2774
3205
|
return result.status === 0 ? result.stdout.trim() || undefined : undefined;
|
|
2775
3206
|
}
|
|
2776
|
-
function storeMacKeychainCredential(
|
|
3207
|
+
function storeMacKeychainCredential(value, account = TOKEN_ACCOUNT) {
|
|
2777
3208
|
if (process.platform !== "darwin") {
|
|
2778
3209
|
return false;
|
|
2779
3210
|
}
|
|
2780
|
-
const result = spawnSync("security", ["add-generic-password", "-a",
|
|
3211
|
+
const result = spawnSync("security", ["add-generic-password", "-a", account, "-s", TOKEN_SERVICE, "-w", value, "-U"], { encoding: "utf8" });
|
|
2781
3212
|
return result.status === 0;
|
|
2782
3213
|
}
|
|
2783
|
-
function deleteMacKeychainCredential() {
|
|
3214
|
+
function deleteMacKeychainCredential(account = TOKEN_ACCOUNT) {
|
|
2784
3215
|
if (process.platform !== "darwin") {
|
|
2785
3216
|
return false;
|
|
2786
3217
|
}
|
|
2787
|
-
const result = spawnSync("security", ["delete-generic-password", "-a",
|
|
3218
|
+
const result = spawnSync("security", ["delete-generic-password", "-a", account, "-s", TOKEN_SERVICE], { encoding: "utf8" });
|
|
2788
3219
|
return result.status === 0;
|
|
2789
3220
|
}
|
|
2790
|
-
function readWindowsDpapiCredential() {
|
|
3221
|
+
function readWindowsDpapiCredential(filePath = windowsCredentialPath()) {
|
|
2791
3222
|
if (process.platform !== "win32") {
|
|
2792
3223
|
return undefined;
|
|
2793
3224
|
}
|
|
2794
3225
|
try {
|
|
2795
|
-
const encrypted = readFileSync(
|
|
3226
|
+
const encrypted = readFileSync(filePath, "utf8").trim();
|
|
2796
3227
|
if (!encrypted) {
|
|
2797
3228
|
return undefined;
|
|
2798
3229
|
}
|
|
@@ -2810,7 +3241,7 @@ $plain = [Security.Cryptography.ProtectedData]::Unprotect($bytes, $null, [Securi
|
|
|
2810
3241
|
return undefined;
|
|
2811
3242
|
}
|
|
2812
3243
|
}
|
|
2813
|
-
function storeWindowsDpapiCredential(
|
|
3244
|
+
function storeWindowsDpapiCredential(value, targetPath = windowsCredentialPath()) {
|
|
2814
3245
|
if (process.platform !== "win32") {
|
|
2815
3246
|
return false;
|
|
2816
3247
|
}
|
|
@@ -2821,11 +3252,11 @@ $plain = [Console]::In.ReadToEnd()
|
|
|
2821
3252
|
$bytes = [Text.Encoding]::UTF8.GetBytes($plain)
|
|
2822
3253
|
$protected = [Security.Cryptography.ProtectedData]::Protect($bytes, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser)
|
|
2823
3254
|
[Console]::Out.Write([Convert]::ToBase64String($protected))
|
|
2824
|
-
`,
|
|
3255
|
+
`, value);
|
|
2825
3256
|
if (result.status !== 0 || !result.stdout.trim()) {
|
|
2826
3257
|
return false;
|
|
2827
3258
|
}
|
|
2828
|
-
const filePath =
|
|
3259
|
+
const filePath = targetPath;
|
|
2829
3260
|
mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
2830
3261
|
writeFileSync(filePath, `${result.stdout.trim()}\n`, { mode: 0o600 });
|
|
2831
3262
|
chmodIfSupported(filePath, 0o600);
|
|
@@ -2859,28 +3290,29 @@ function hasLinuxSecretTool() {
|
|
|
2859
3290
|
const result = spawnSync("secret-tool", ["--version"], { encoding: "utf8" });
|
|
2860
3291
|
return !result.error && result.status === 0;
|
|
2861
3292
|
}
|
|
2862
|
-
function readLinuxSecretServiceCredential() {
|
|
3293
|
+
function readLinuxSecretServiceCredential(account = TOKEN_ACCOUNT) {
|
|
2863
3294
|
if (process.platform !== "linux" || !hasLinuxSecretTool()) {
|
|
2864
3295
|
return undefined;
|
|
2865
3296
|
}
|
|
2866
|
-
const result = spawnSync("secret-tool", ["lookup", "service", TOKEN_SERVICE, "account",
|
|
3297
|
+
const result = spawnSync("secret-tool", ["lookup", "service", TOKEN_SERVICE, "account", account], { encoding: "utf8" });
|
|
2867
3298
|
return result.status === 0 ? result.stdout.trim() || undefined : undefined;
|
|
2868
3299
|
}
|
|
2869
|
-
function storeLinuxSecretServiceCredential(
|
|
3300
|
+
function storeLinuxSecretServiceCredential(value, account = TOKEN_ACCOUNT) {
|
|
2870
3301
|
if (process.platform !== "linux" || !hasLinuxSecretTool()) {
|
|
2871
3302
|
return false;
|
|
2872
3303
|
}
|
|
2873
|
-
const
|
|
2874
|
-
|
|
3304
|
+
const label = account === TOKEN_ACCOUNT ? "9pm.ai API token" : "9pm.ai API token issuer";
|
|
3305
|
+
const result = spawnSync("secret-tool", ["store", "--label", label, "service", TOKEN_SERVICE, "account", account], {
|
|
3306
|
+
input: value,
|
|
2875
3307
|
encoding: "utf8",
|
|
2876
3308
|
});
|
|
2877
3309
|
return result.status === 0;
|
|
2878
3310
|
}
|
|
2879
|
-
function deleteLinuxSecretServiceCredential() {
|
|
3311
|
+
function deleteLinuxSecretServiceCredential(account = TOKEN_ACCOUNT) {
|
|
2880
3312
|
if (process.platform !== "linux" || !hasLinuxSecretTool()) {
|
|
2881
3313
|
return false;
|
|
2882
3314
|
}
|
|
2883
|
-
const result = spawnSync("secret-tool", ["clear", "service", TOKEN_SERVICE, "account",
|
|
3315
|
+
const result = spawnSync("secret-tool", ["clear", "service", TOKEN_SERVICE, "account", account], { encoding: "utf8" });
|
|
2884
3316
|
return result.status === 0;
|
|
2885
3317
|
}
|
|
2886
3318
|
// Pure and exported so the per-platform mapping is unit-testable without
|
|
@@ -3049,8 +3481,137 @@ async function main() {
|
|
|
3049
3481
|
return;
|
|
3050
3482
|
}
|
|
3051
3483
|
if (command.kind === "logout") {
|
|
3052
|
-
|
|
3053
|
-
|
|
3484
|
+
// Revoke before deleting: once the local copies are gone the tokens can
|
|
3485
|
+
// never be revoked from this machine again. The local delete then happens
|
|
3486
|
+
// on every path — a credential that outlives logout on disk is strictly
|
|
3487
|
+
// worse than one that outlives it server-side (#822 is about the latter).
|
|
3488
|
+
const stored = storedCredentialTokens();
|
|
3489
|
+
// Removal is only done when no store hands a token back. The per-store
|
|
3490
|
+
// deletes can partially fail (a locked keychain) while the metadata file
|
|
3491
|
+
// is removed, and reporting that as success would tell someone a live
|
|
3492
|
+
// credential is gone from a machine that still holds it.
|
|
3493
|
+
const clearLocalCredential = () => {
|
|
3494
|
+
deleteStoredCredential();
|
|
3495
|
+
return storedCredentialTokens().length === 0;
|
|
3496
|
+
};
|
|
3497
|
+
// A skipped protected store makes ANY logout incomplete, including one
|
|
3498
|
+
// that successfully revoked a plaintext credential sitting beside it: the
|
|
3499
|
+
// protected token was neither read, revoked, nor removed, and every later
|
|
3500
|
+
// command run without this variable set will use it. Computed before
|
|
3501
|
+
// anything is deleted, since it reads the metadata file.
|
|
3502
|
+
const skippedNativeStore = isNativeCredentialStoreDisabled() && nativeCredentialMayExist();
|
|
3503
|
+
const reportSkippedNativeStore = () => {
|
|
3504
|
+
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.`);
|
|
3505
|
+
console.error(`Unset ${DISABLE_NATIVE_CREDENTIAL_STORE_ENV} and run 9pm logout again to retire it.`);
|
|
3506
|
+
process.exitCode = 1;
|
|
3507
|
+
};
|
|
3508
|
+
if (stored.length === 0) {
|
|
3509
|
+
const removedLeftovers = deleteStoredCredential();
|
|
3510
|
+
if (skippedNativeStore) {
|
|
3511
|
+
reportSkippedNativeStore();
|
|
3512
|
+
return;
|
|
3513
|
+
}
|
|
3514
|
+
console.log(removedLeftovers ? "Removed leftover credential files; no readable credential was stored." : "No stored credential found.");
|
|
3515
|
+
return;
|
|
3516
|
+
}
|
|
3517
|
+
// Resolved PER TOKEN, not once: two stores can hold credentials from two
|
|
3518
|
+
// different control planes, and a key can only be revoked where it was
|
|
3519
|
+
// issued. The issuer recorded with the credential wins — it is what
|
|
3520
|
+
// minted the key, and an exported NINEPM_API_URL pointing somewhere else
|
|
3521
|
+
// (routine on a dev machine) would otherwise send a production token to
|
|
3522
|
+
// staging and read the resulting 403 as a clean logout. --api-url and
|
|
3523
|
+
// NINEPM_API_URL name the plane only for credentials that recorded none
|
|
3524
|
+
// (stored before 0.42); `issuerKnown` is false when nobody stated one and
|
|
3525
|
+
// the CLI fell back to the default.
|
|
3526
|
+
const targets = stored.map((entry) => ({
|
|
3527
|
+
token: entry.token,
|
|
3528
|
+
apiUrl: entry.apiUrl ?? command.apiUrl ?? DEFAULT_API_URL,
|
|
3529
|
+
issuerKnown: Boolean(entry.apiUrl ?? command.apiUrl),
|
|
3530
|
+
}));
|
|
3531
|
+
if (command.local) {
|
|
3532
|
+
const cleared = clearLocalCredential();
|
|
3533
|
+
if (cleared) {
|
|
3534
|
+
console.log("Removed the stored credential from this machine without revoking it. It can still authenticate.");
|
|
3535
|
+
}
|
|
3536
|
+
else {
|
|
3537
|
+
console.error("The credential could not be removed (is the OS keychain locked?) and remains on this machine.");
|
|
3538
|
+
process.exitCode = 1;
|
|
3539
|
+
}
|
|
3540
|
+
if (skippedNativeStore) {
|
|
3541
|
+
reportSkippedNativeStore();
|
|
3542
|
+
}
|
|
3543
|
+
return;
|
|
3544
|
+
}
|
|
3545
|
+
const results = [];
|
|
3546
|
+
for (const target of targets) {
|
|
3547
|
+
// A malformed --api-url/NINEPM_API_URL only sinks the credentials that
|
|
3548
|
+
// NEED it — the ones that recorded no issuer of their own. One that
|
|
3549
|
+
// carries a trusted issuer is revoked there regardless, rather than
|
|
3550
|
+
// being deleted unrevoked because an unused override failed to parse.
|
|
3551
|
+
if (!target.issuerKnown && command.apiUrlError) {
|
|
3552
|
+
results.push({ ...target, outcome: { kind: "failed", reason: command.apiUrlError } });
|
|
3553
|
+
continue;
|
|
3554
|
+
}
|
|
3555
|
+
results.push({ ...target, outcome: await revokeCredential({ apiUrl: target.apiUrl, token: target.token }) });
|
|
3556
|
+
}
|
|
3557
|
+
const summary = summarizeRevocations(results);
|
|
3558
|
+
const removedLocally = clearLocalCredential();
|
|
3559
|
+
const removalWarning = removedLocally
|
|
3560
|
+
? null
|
|
3561
|
+
: "Warning: the local credential copy could not be removed (is the OS keychain locked?) and remains on this machine.";
|
|
3562
|
+
if (!removedLocally) {
|
|
3563
|
+
// Even after a successful revoke: the credential is dead but still on
|
|
3564
|
+
// the machine, so later commands load it and fail to authenticate.
|
|
3565
|
+
// Logout did not finish, and a script must not read that as success.
|
|
3566
|
+
process.exitCode = 1;
|
|
3567
|
+
}
|
|
3568
|
+
if (summary.kind === "revoked") {
|
|
3569
|
+
console.log("Logged out. The credential was revoked and can no longer authenticate.");
|
|
3570
|
+
if (removalWarning) {
|
|
3571
|
+
console.warn(removalWarning);
|
|
3572
|
+
}
|
|
3573
|
+
}
|
|
3574
|
+
else if (summary.kind === "unproven") {
|
|
3575
|
+
// Logout still happens — refusing would block the ordinary upgrade
|
|
3576
|
+
// path, where a pre-0.42 credential really was issued by the default
|
|
3577
|
+
// plane and really is dead — but it cannot be called confirmed.
|
|
3578
|
+
console.error(`Removed the local credential, but could not confirm it was revoked: ${summary.apiUrl} does not recognise it.`);
|
|
3579
|
+
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.");
|
|
3580
|
+
if (removalWarning) {
|
|
3581
|
+
console.warn(removalWarning);
|
|
3582
|
+
}
|
|
3583
|
+
process.exitCode = 1;
|
|
3584
|
+
}
|
|
3585
|
+
else if (summary.kind === "already_invalid") {
|
|
3586
|
+
// "Rejects" rather than "was revoked": the control plane answers the
|
|
3587
|
+
// same 403 for an expired, revoked, or suspended-account credential,
|
|
3588
|
+
// and only the first two are permanent. Nothing can be revoked from
|
|
3589
|
+
// here in any of the three — the revoke route needs the same
|
|
3590
|
+
// authentication the server just refused.
|
|
3591
|
+
console.log("Logged out. The server already rejects this credential; nothing to revoke from here.");
|
|
3592
|
+
if (removalWarning) {
|
|
3593
|
+
console.warn(removalWarning);
|
|
3594
|
+
}
|
|
3595
|
+
}
|
|
3596
|
+
else {
|
|
3597
|
+
// Deliberate partial outcome, exit 1: "logged out" would be a lie — a
|
|
3598
|
+
// live full-scope token remains on the account, and only the user can
|
|
3599
|
+
// retire it now.
|
|
3600
|
+
console.error(removedLocally
|
|
3601
|
+
? `Removed the local credential, but could not revoke it (${summary.reason}).`
|
|
3602
|
+
: `Could not revoke the credential (${summary.reason}), and the local copy could not be removed.`);
|
|
3603
|
+
// Only name the production dashboard when every credential belongs to
|
|
3604
|
+
// it; a staging or self-hosted key cannot be revoked there, and a
|
|
3605
|
+
// rejected URL leaves the origin unknown.
|
|
3606
|
+
const settingsHint = !command.apiUrlError && targets.every((target) => target.apiUrl === DEFAULT_API_URL)
|
|
3607
|
+
? `Revoke it under Connected devices: ${PUBLIC_DASHBOARD_URL}/settings`
|
|
3608
|
+
: "Revoke it under Connected devices in its control plane's dashboard settings.";
|
|
3609
|
+
console.error(`It can still authenticate. ${settingsHint}`);
|
|
3610
|
+
process.exitCode = 1;
|
|
3611
|
+
}
|
|
3612
|
+
if (skippedNativeStore) {
|
|
3613
|
+
reportSkippedNativeStore();
|
|
3614
|
+
}
|
|
3054
3615
|
return;
|
|
3055
3616
|
}
|
|
3056
3617
|
if (command.kind === "doctor") {
|
|
@@ -3320,7 +3881,7 @@ function printUsage() {
|
|
|
3320
3881
|
9pm deploy [directory] --check
|
|
3321
3882
|
9pm signup
|
|
3322
3883
|
9pm login
|
|
3323
|
-
9pm logout
|
|
3884
|
+
9pm logout [--api-url <url>] [--local] revokes the credential server-side, then removes it from this machine (--local skips the revoke)
|
|
3324
3885
|
9pm doctor [--json]
|
|
3325
3886
|
9pm whoami [--json]
|
|
3326
3887
|
9pm apps [--json]
|