ninepm 0.46.0 → 0.48.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
@@ -108,7 +108,7 @@ Deploy a container app from a local Docker build:
108
108
  9pm deploy . --name container-app --slug container-app --runtime container --port 8080 --health-path /health --region auto
109
109
  ```
110
110
 
111
- Container deploys require Docker locally. The CLI builds a `linux/amd64` image, exports it, checks image size against the per-app limit and account retained-image quota, uploads through a short-lived session, and finalizes a queued deployment. The container must listen on `0.0.0.0:$PORT` or the selected `--port`; set `--health-path` when the app has a cheap readiness endpoint.
111
+ Container deploys require Docker locally. The CLI builds a `linux/amd64` image, exports it, checks image size against the per-app limit and account retained-image quota, uploads through a short-lived session, and finalizes a queued deployment. The container must listen on `0.0.0.0:$PORT` - always read the `PORT` env var rather than hardcoding the selected `--port`, since on some placements the platform holds the selected port and hands the app a different one; set `--health-path` when the app has a cheap readiness endpoint.
112
112
 
113
113
  Use `--with-db` when the container needs managed SQL. The app receives `NINEPM_SQL_URL` and `NINEPM_DB_BINDINGS`; send `POST` requests to `NINEPM_SQL_URL` with `{ "binding": "DB", "sql": "SELECT 1", "params": [] }`. For local development, use the app's normal local database adapter behind a repository layer and keep deployed SQL compatible with SQLite.
114
114
 
package/dist/index.d.ts CHANGED
@@ -114,7 +114,7 @@ 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.46.0";
117
+ export declare const CLI_VERSION = "0.48.0";
118
118
  type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
119
119
  export interface StoredCredentialEntry {
120
120
  token: string;
@@ -529,6 +529,22 @@ export declare function parseEnvFileEntries(content: string, kind: "secret" | "v
529
529
  }>;
530
530
  export declare function healthWithReason(status: string | null | undefined, reason: string | null | undefined): string;
531
531
  export declare function storedCredentialTokens(): StoredCredentialEntry[];
532
+ /**
533
+ * Whether a credential provably cannot be stored, decided WITHOUT writing one (#859).
534
+ *
535
+ * Deliberately one-directional: it answers only when the answer is certain, and
536
+ * `preferredCredentialBackend()` is what makes that possible — it reports "none"
537
+ * exactly when the native store is disabled by env, or the platform has no store
538
+ * this CLI can reach (Linux without a secret tool, or an unsupported platform).
539
+ * In those cases `storeSecureCredential` cannot succeed.
540
+ *
541
+ * It does NOT try to predict a keychain that exists but errors: on macOS and
542
+ * Windows the backend is assumed present, so this returns null and the real
543
+ * failure is caught after the fact. A pre-flight that guessed wrong in the other
544
+ * direction would refuse a login that would have worked, which is worse than the
545
+ * browser round-trip it saves.
546
+ */
547
+ export declare function secureStoreRefusalReason(allowFileFallback?: boolean): string | null;
532
548
  export declare function credentialDiagnostics(): CredentialDiagnostics;
533
549
  export type BrowserOpenResult = "opened" | "failed" | "unknown";
534
550
  export declare function classifyBrowserOpen(input: {
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ 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.46.0";
17
+ export const CLI_VERSION = "0.48.0";
18
18
  const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
19
19
  const TOKEN_SERVICE = "9pm";
20
20
  const TOKEN_ACCOUNT = "default";
@@ -710,6 +710,12 @@ export function publicWhoamiResult(result) {
710
710
  // and never answers). Longer than the 5s reachability probe because this
711
711
  // request is worth waiting for, but still bounded.
712
712
  const REVOKE_TIMEOUT_MS = 10_000;
713
+ // The post-storage identity lookup's bound (#859). Same reasoning and same value as
714
+ // the revocation above — a hung connection must degrade this call, not the login it
715
+ // decorates. Kept separate so shortening one does not silently shorten the other:
716
+ // this one only costs the account name, while that one decides whether a live
717
+ // credential is given back.
718
+ const IDENTITY_TIMEOUT_MS = 10_000;
713
719
  // Revoke the API key `options.token` authenticates as (#822). The token itself
714
720
  // carries no key id, so /v1/me — whose response names the calling key — is how
715
721
  // the CLI learns which row to revoke. Every branch returns instead of throwing:
@@ -1035,6 +1041,11 @@ export async function signupDevice(options) {
1035
1041
  async function deviceAuthFlow(options) {
1036
1042
  const fetchFn = options.fetchFn ?? fetch;
1037
1043
  const apiUrl = normalizeApiUrl(options.apiUrl);
1044
+ // #859: refuse before the browser round-trip when the credential provably has
1045
+ // nowhere to go. The control plane mints the key inside the poll and consumes the
1046
+ // authorization with it, so approving first and discovering this afterwards leaves
1047
+ // a live key the user never received and cannot revoke. Only a certain refusal is
1048
+ // made here; an unpredictable store failure is caught after the poll instead.
1038
1049
  const startResponse = await fetchFn(`${apiUrl}/v1/oauth/device/start`, {
1039
1050
  method: "POST",
1040
1051
  headers: { "content-type": "application/json" },
@@ -1052,6 +1063,27 @@ async function deviceAuthFlow(options) {
1052
1063
  if (!userCode || !deviceCode) {
1053
1064
  throw new Error(`Device ${options.purpose} response did not include a device code.`);
1054
1065
  }
1066
+ // #859: refuse before the browser round-trip when the credential provably has
1067
+ // nowhere to go. The control plane mints the key inside the POLL and consumes the
1068
+ // authorization with it, so approving first and discovering this afterwards leaves
1069
+ // a live key the user never received and cannot revoke. Only a certain refusal is
1070
+ // made here; an unpredictable store failure is caught after the poll instead.
1071
+ //
1072
+ // Deliberately AFTER the start response rather than before it: that response is
1073
+ // what carries the actionable refusal for a signup that can never be approved
1074
+ // (#507), and a storage complaint arriving first would send the user to fix their
1075
+ // keychain only to learn afterwards that signup was closed all along. Nothing is
1076
+ // minted at start, so the ordering costs nothing but a lapsed pending
1077
+ // authorization.
1078
+ //
1079
+ // Skipped when the caller injects its own store: that seam replaces the credential
1080
+ // backend wholesale, so probing the real one says nothing about whether the
1081
+ // caller's will work — and refusing on its behalf would be a refusal we cannot
1082
+ // justify.
1083
+ const refusal = options.storeCredentialFn ? null : secureStoreRefusalReason(options.allowFileCredentialStore);
1084
+ if (refusal) {
1085
+ throw new Error(refusal);
1086
+ }
1055
1087
  console.log(`Open ${start.verification_uri}`);
1056
1088
  console.log(`Device code: ${userCode}`);
1057
1089
  // Everything new below goes to stderr (#509): stdout keeps exactly the two
@@ -1118,14 +1150,72 @@ async function deviceAuthFlow(options) {
1118
1150
  // login (#514 review). stop() is idempotent, so the finally below stays as
1119
1151
  // the fallback for every other exit path.
1120
1152
  progress.stop();
1121
- const storage = (options.storeCredentialFn ?? storeCredential)(poll.token, {
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,
1126
- });
1127
- const me = await whoami({ apiUrl, token: poll.token, fetchFn });
1128
- return { email: me.user.email, storage };
1153
+ let storage;
1154
+ try {
1155
+ storage = (options.storeCredentialFn ?? storeCredential)(poll.token, {
1156
+ allowFileFallback: options.allowFileCredentialStore,
1157
+ // Recorded so logout can revoke against the control plane that issued
1158
+ // this credential rather than whatever URL it resolves later.
1159
+ apiUrl,
1160
+ });
1161
+ }
1162
+ catch (error) {
1163
+ // #859: the key exists on the account by now, and this process is the only
1164
+ // thing that will ever hold its token — the poll consumed the authorization,
1165
+ // so a retry cannot re-deliver it and the user cannot revoke what they never
1166
+ // saw. Give it back before failing.
1167
+ const revocation = await revokeCredential({
1168
+ apiUrl,
1169
+ token: poll.token,
1170
+ ...(options.fetchFn ? { fetchFn: options.fetchFn } : {}),
1171
+ });
1172
+ discardRevokedCredential(poll.token);
1173
+ throw new Error(unstorableCredentialMessage(error, revocation));
1174
+ }
1175
+ // The identity lookup is decoration: the credential is stored, so a transient
1176
+ // failure must not fail the login — that would send the user back to `login`,
1177
+ // mint a second key and orphan this one, which is this issue in reverse (#859).
1178
+ // An authentication refusal is not transient, though: it proves the stored
1179
+ // credential cannot authenticate, and reporting "logged in" for it would be a
1180
+ // lie the user only discovers on their next command. The response is read
1181
+ // directly rather than through whoami because the status reaches whoami's
1182
+ // caller only as prose inside an error message.
1183
+ let identity;
1184
+ try {
1185
+ identity = await fetchFn(`${apiUrl}/v1/me`, {
1186
+ method: "GET",
1187
+ headers: jsonHeaders(poll.token),
1188
+ // Bounded for the same reason the revocation calls are: a black-holed
1189
+ // connection accepts TCP and never answers. Unbounded, this would not fail
1190
+ // the login — it would wedge it open indefinitely, which is worse than the
1191
+ // graceful warning this branch exists to give. The timeout lands in the
1192
+ // catch below and degrades to "signed in, name unavailable".
1193
+ signal: AbortSignal.timeout(IDENTITY_TIMEOUT_MS),
1194
+ });
1195
+ }
1196
+ catch {
1197
+ identity = null;
1198
+ }
1199
+ if (identity && (identity.status === 401 || identity.status === 403) && (await isAuthRefusal(identity))) {
1200
+ const revocation = await revokeCredential({
1201
+ apiUrl,
1202
+ token: poll.token,
1203
+ ...(options.fetchFn ? { fetchFn: options.fetchFn } : {}),
1204
+ });
1205
+ discardRevokedCredential(poll.token);
1206
+ throw new Error(joinSentences("The credential this login created was stored but cannot authenticate.", disposalSentence(revocation)));
1207
+ }
1208
+ if (!identity?.ok) {
1209
+ // Said out loud rather than swallowed: the login DID succeed, and the user
1210
+ // should know which half of it did not.
1211
+ progressStream.write(`Warning: signed in, but could not read the account name (${identity ? `the control plane answered ${identity.status}` : "the request failed"}).\n`);
1212
+ return { storage };
1213
+ }
1214
+ const email = await identity
1215
+ .json()
1216
+ .then((body) => body.user?.email)
1217
+ .catch(() => undefined);
1218
+ return email ? { email, storage } : { storage };
1129
1219
  }
1130
1220
  }
1131
1221
  finally {
@@ -2879,13 +2969,101 @@ function safeApiUrl(value) {
2879
2969
  return undefined;
2880
2970
  }
2881
2971
  }
2972
+ /**
2973
+ * What became of the key a failed login had already been issued (#859).
2974
+ *
2975
+ * Reported rather than assumed: when giving it back fails, the user is the only one
2976
+ * who can finish the job, and saying nothing would leave a live full-scope credential
2977
+ * they have no reason to go looking for.
2978
+ */
2979
+ function disposalSentence(revocation) {
2980
+ return revocation.kind === "failed"
2981
+ ? `The credential this login created could NOT be revoked automatically (${revocation.reason}) — revoke it from Settings in the dashboard, or it stays valid.`
2982
+ : "The credential this login created has been revoked, so nothing was left active.";
2983
+ }
2984
+ // Join two sentences without doubling or dropping the separator: a store failure's
2985
+ // message may or may not end in punctuation ("EACCES: permission denied, open '…'"
2986
+ // does not), and the result is read by a person.
2987
+ function joinSentences(first, second) {
2988
+ const head = first.trim();
2989
+ return `${head}${/[.!?]$/.test(head) ? "" : "."} ${second}`;
2990
+ }
2991
+ /**
2992
+ * Remove any credential that holds a token we have just revoked (#859).
2993
+ *
2994
+ * `storePlaintextCredential` writes the token file before its metadata, so a store
2995
+ * that threw part-way can leave the new token on disk — over the top of whatever
2996
+ * valid credential was there. Left alone, every later command would authenticate
2997
+ * with a dead key.
2998
+ *
2999
+ * Every store is matched on its OWN value rather than cleared as a group. The three
3000
+ * of them (native, plaintext, legacy) can hold different tokens, and only the token
3001
+ * this login minted has been revoked: sweeping them together would delete a
3002
+ * different, still-valid credential locally without revoking it — turning one
3003
+ * orphan into two, which is the failure this whole change exists to stop.
3004
+ *
3005
+ * The shared metadata file is deliberately left alone for the same reason: it
3006
+ * describes whichever credential survives, and a stale backend hint is a much
3007
+ * cheaper wrong than erasing a live session's record.
3008
+ */
3009
+ function discardRevokedCredential(token) {
3010
+ if (readSecureCredential() === token) {
3011
+ // One platform's store exists per host; the others no-op, as at logout.
3012
+ deleteMacKeychainCredential();
3013
+ deleteWindowsDpapiCredential();
3014
+ deleteLinuxSecretServiceCredential();
3015
+ deleteSecureIssuer();
3016
+ }
3017
+ for (const entry of readPlaintextCredentials()) {
3018
+ if (entry.token === token) {
3019
+ removeFileIfExists(entry.path);
3020
+ }
3021
+ }
3022
+ }
3023
+ /**
3024
+ * What to tell a user whose credential was issued but could not be stored (#859).
3025
+ * The store failure leads, because it is the part they can act on.
3026
+ */
3027
+ function unstorableCredentialMessage(storeError, revocation) {
3028
+ const cause = storeError instanceof Error ? storeError.message : String(storeError);
3029
+ return joinSentences(cause, disposalSentence(revocation));
3030
+ }
3031
+ // One message for both the pre-flight refusal and the real store's failure, so the
3032
+ // two can never drift into telling a user different things about the same state.
3033
+ function secureStoreUnavailableMessage() {
3034
+ return `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.`;
3035
+ }
3036
+ /**
3037
+ * Whether a credential provably cannot be stored, decided WITHOUT writing one (#859).
3038
+ *
3039
+ * Deliberately one-directional: it answers only when the answer is certain, and
3040
+ * `preferredCredentialBackend()` is what makes that possible — it reports "none"
3041
+ * exactly when the native store is disabled by env, or the platform has no store
3042
+ * this CLI can reach (Linux without a secret tool, or an unsupported platform).
3043
+ * In those cases `storeSecureCredential` cannot succeed.
3044
+ *
3045
+ * It does NOT try to predict a keychain that exists but errors: on macOS and
3046
+ * Windows the backend is assumed present, so this returns null and the real
3047
+ * failure is caught after the fact. A pre-flight that guessed wrong in the other
3048
+ * direction would refuse a login that would have worked, which is worse than the
3049
+ * browser round-trip it saves.
3050
+ */
3051
+ export function secureStoreRefusalReason(allowFileFallback) {
3052
+ if (preferredCredentialBackend() !== "none") {
3053
+ return null;
3054
+ }
3055
+ if (isFileCredentialStoreAllowed(allowFileFallback)) {
3056
+ return null;
3057
+ }
3058
+ return secureStoreUnavailableMessage();
3059
+ }
2882
3060
  function storeCredential(token, options = {}) {
2883
3061
  const secure = storeSecureCredential(token, options.apiUrl);
2884
3062
  if (secure) {
2885
3063
  return secure;
2886
3064
  }
2887
3065
  if (!isFileCredentialStoreAllowed(options.allowFileFallback)) {
2888
- 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.`);
3066
+ throw new Error(secureStoreUnavailableMessage());
2889
3067
  }
2890
3068
  return storePlaintextCredential(token, options.apiUrl);
2891
3069
  }
@@ -3961,7 +4139,7 @@ Size limits:
3961
4139
 
3962
4140
  Container deploys:
3963
4141
  Requires Docker locally. The CLI builds linux/amd64, exports the image, uploads it through a short-lived session, then finalizes a queued deployment.
3964
- The app must listen on 0.0.0.0:$PORT or the selected --port. Use --health-path for a cheap readiness endpoint.
4142
+ The app must listen on 0.0.0.0:$PORT - always read the PORT env var; on some placements the platform holds the selected --port and hands the app a different one. Use --health-path for a cheap readiness endpoint.
3965
4143
  With --with-db, containers receive NINEPM_SQL_URL and NINEPM_DB_BINDINGS for the managed SQL bridge.
3966
4144
  With --with-auth, the app gets 9pm-managed end-user accounts (email one-time codes); it implies --with-db and is one-way (enable-only).
3967
4145
  With --with-email, a signed-in end user can send app invitation emails; it implies --with-auth and works on public apps only.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ninepm",
3
- "version": "0.46.0",
3
+ "version": "0.48.0",
4
4
  "description": "CLI for deploying built apps to 9pm.ai.",
5
5
  "homepage": "https://9pm.ai",
6
6
  "keywords": [