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 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.41.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.41.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
- return { kind: "logout" };
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; a runtime failure on a deployment reported active is not yet captured here.");
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
- deleteMacKeychainCredential(),
2604
- deleteWindowsDpapiCredential(),
2605
- deleteLinuxSecretServiceCredential(),
2876
+ ...nativeRemovals,
2606
2877
  removeFileIfExists(credentialMetadataPath()),
2607
- removeFileIfExists(windowsCredentialPath()),
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 storeSecureCredential(token) {
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
- if (process.platform === "darwin" && storeMacKeychainCredential(token)) {
2670
- writeCredentialMetadata("macos-keychain");
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" && storeWindowsDpapiCredential(token)) {
2674
- writeCredentialMetadata("windows-dpapi", { secretPath: windowsCredentialPath() });
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" && storeLinuxSecretServiceCredential(token)) {
2678
- writeCredentialMetadata("linux-secret-service");
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
- function writeCredentialMetadata(backend, extra = {}) {
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 storePlaintextCredential(token) {
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
- return { token: parsed.token, path: filePath, legacy: filePath === legacyCredentialPath() };
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 undefined;
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", TOKEN_ACCOUNT, "-s", TOKEN_SERVICE, "-w"], { encoding: "utf8" });
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(token) {
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", TOKEN_ACCOUNT, "-s", TOKEN_SERVICE, "-w", token, "-U"], { encoding: "utf8" });
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", TOKEN_ACCOUNT, "-s", TOKEN_SERVICE], { encoding: "utf8" });
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(windowsCredentialPath(), "utf8").trim();
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(token) {
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
- `, token);
3262
+ `, value);
2825
3263
  if (result.status !== 0 || !result.stdout.trim()) {
2826
3264
  return false;
2827
3265
  }
2828
- const filePath = windowsCredentialPath();
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", TOKEN_ACCOUNT], { encoding: "utf8" });
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(token) {
3307
+ function storeLinuxSecretServiceCredential(value, account = TOKEN_ACCOUNT) {
2870
3308
  if (process.platform !== "linux" || !hasLinuxSecretTool()) {
2871
3309
  return false;
2872
3310
  }
2873
- const result = spawnSync("secret-tool", ["store", "--label", "9pm.ai API token", "service", TOKEN_SERVICE, "account", TOKEN_ACCOUNT], {
2874
- input: token,
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", TOKEN_ACCOUNT], { encoding: "utf8" });
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
- const removed = deleteStoredCredential();
3053
- console.log(removed ? "Logged out." : "No stored credential found.");
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]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ninepm",
3
- "version": "0.41.0",
3
+ "version": "0.44.0",
4
4
  "description": "CLI for deploying built apps to 9pm.ai.",
5
5
  "homepage": "https://9pm.ai",
6
6
  "keywords": [