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