ninepm 0.46.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.d.ts +17 -1
- package/dist/index.js +194 -11
- package/package.json +1 -1
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`
|
|
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.
|
|
117
|
+
export declare const CLI_VERSION = "0.49.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.
|
|
17
|
+
export const CLI_VERSION = "0.49.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
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
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 {
|
|
@@ -2452,6 +2542,11 @@ async function runLogsCommand(command) {
|
|
|
2452
2542
|
placement_unsupported: "Runtime logs are not yet available for this app's placement.",
|
|
2453
2543
|
not_configured: "Runtime logs are not configured on this platform.",
|
|
2454
2544
|
nothing_retained: "No recent runtime output is retained for the serving deployment.",
|
|
2545
|
+
// #853: distinct from the line above on purpose. The provider IS holding output for
|
|
2546
|
+
// this app - it just all belongs to a different deployment than the one serving, which
|
|
2547
|
+
// is what a failed retry leaves behind. Telling the owner their app printed nothing
|
|
2548
|
+
// would be false, and it is the sentence that sent one incident out of band.
|
|
2549
|
+
not_this_version: "The retained output all belongs to another deployment of this app, not the one serving now.",
|
|
2455
2550
|
provider_unavailable: "Runtime logs are temporarily unavailable; try again shortly.",
|
|
2456
2551
|
};
|
|
2457
2552
|
console.log("");
|
|
@@ -2879,13 +2974,101 @@ function safeApiUrl(value) {
|
|
|
2879
2974
|
return undefined;
|
|
2880
2975
|
}
|
|
2881
2976
|
}
|
|
2977
|
+
/**
|
|
2978
|
+
* What became of the key a failed login had already been issued (#859).
|
|
2979
|
+
*
|
|
2980
|
+
* Reported rather than assumed: when giving it back fails, the user is the only one
|
|
2981
|
+
* who can finish the job, and saying nothing would leave a live full-scope credential
|
|
2982
|
+
* they have no reason to go looking for.
|
|
2983
|
+
*/
|
|
2984
|
+
function disposalSentence(revocation) {
|
|
2985
|
+
return revocation.kind === "failed"
|
|
2986
|
+
? `The credential this login created could NOT be revoked automatically (${revocation.reason}) — revoke it from Settings in the dashboard, or it stays valid.`
|
|
2987
|
+
: "The credential this login created has been revoked, so nothing was left active.";
|
|
2988
|
+
}
|
|
2989
|
+
// Join two sentences without doubling or dropping the separator: a store failure's
|
|
2990
|
+
// message may or may not end in punctuation ("EACCES: permission denied, open '…'"
|
|
2991
|
+
// does not), and the result is read by a person.
|
|
2992
|
+
function joinSentences(first, second) {
|
|
2993
|
+
const head = first.trim();
|
|
2994
|
+
return `${head}${/[.!?]$/.test(head) ? "" : "."} ${second}`;
|
|
2995
|
+
}
|
|
2996
|
+
/**
|
|
2997
|
+
* Remove any credential that holds a token we have just revoked (#859).
|
|
2998
|
+
*
|
|
2999
|
+
* `storePlaintextCredential` writes the token file before its metadata, so a store
|
|
3000
|
+
* that threw part-way can leave the new token on disk — over the top of whatever
|
|
3001
|
+
* valid credential was there. Left alone, every later command would authenticate
|
|
3002
|
+
* with a dead key.
|
|
3003
|
+
*
|
|
3004
|
+
* Every store is matched on its OWN value rather than cleared as a group. The three
|
|
3005
|
+
* of them (native, plaintext, legacy) can hold different tokens, and only the token
|
|
3006
|
+
* this login minted has been revoked: sweeping them together would delete a
|
|
3007
|
+
* different, still-valid credential locally without revoking it — turning one
|
|
3008
|
+
* orphan into two, which is the failure this whole change exists to stop.
|
|
3009
|
+
*
|
|
3010
|
+
* The shared metadata file is deliberately left alone for the same reason: it
|
|
3011
|
+
* describes whichever credential survives, and a stale backend hint is a much
|
|
3012
|
+
* cheaper wrong than erasing a live session's record.
|
|
3013
|
+
*/
|
|
3014
|
+
function discardRevokedCredential(token) {
|
|
3015
|
+
if (readSecureCredential() === token) {
|
|
3016
|
+
// One platform's store exists per host; the others no-op, as at logout.
|
|
3017
|
+
deleteMacKeychainCredential();
|
|
3018
|
+
deleteWindowsDpapiCredential();
|
|
3019
|
+
deleteLinuxSecretServiceCredential();
|
|
3020
|
+
deleteSecureIssuer();
|
|
3021
|
+
}
|
|
3022
|
+
for (const entry of readPlaintextCredentials()) {
|
|
3023
|
+
if (entry.token === token) {
|
|
3024
|
+
removeFileIfExists(entry.path);
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
/**
|
|
3029
|
+
* What to tell a user whose credential was issued but could not be stored (#859).
|
|
3030
|
+
* The store failure leads, because it is the part they can act on.
|
|
3031
|
+
*/
|
|
3032
|
+
function unstorableCredentialMessage(storeError, revocation) {
|
|
3033
|
+
const cause = storeError instanceof Error ? storeError.message : String(storeError);
|
|
3034
|
+
return joinSentences(cause, disposalSentence(revocation));
|
|
3035
|
+
}
|
|
3036
|
+
// One message for both the pre-flight refusal and the real store's failure, so the
|
|
3037
|
+
// two can never drift into telling a user different things about the same state.
|
|
3038
|
+
function secureStoreUnavailableMessage() {
|
|
3039
|
+
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.`;
|
|
3040
|
+
}
|
|
3041
|
+
/**
|
|
3042
|
+
* Whether a credential provably cannot be stored, decided WITHOUT writing one (#859).
|
|
3043
|
+
*
|
|
3044
|
+
* Deliberately one-directional: it answers only when the answer is certain, and
|
|
3045
|
+
* `preferredCredentialBackend()` is what makes that possible — it reports "none"
|
|
3046
|
+
* exactly when the native store is disabled by env, or the platform has no store
|
|
3047
|
+
* this CLI can reach (Linux without a secret tool, or an unsupported platform).
|
|
3048
|
+
* In those cases `storeSecureCredential` cannot succeed.
|
|
3049
|
+
*
|
|
3050
|
+
* It does NOT try to predict a keychain that exists but errors: on macOS and
|
|
3051
|
+
* Windows the backend is assumed present, so this returns null and the real
|
|
3052
|
+
* failure is caught after the fact. A pre-flight that guessed wrong in the other
|
|
3053
|
+
* direction would refuse a login that would have worked, which is worse than the
|
|
3054
|
+
* browser round-trip it saves.
|
|
3055
|
+
*/
|
|
3056
|
+
export function secureStoreRefusalReason(allowFileFallback) {
|
|
3057
|
+
if (preferredCredentialBackend() !== "none") {
|
|
3058
|
+
return null;
|
|
3059
|
+
}
|
|
3060
|
+
if (isFileCredentialStoreAllowed(allowFileFallback)) {
|
|
3061
|
+
return null;
|
|
3062
|
+
}
|
|
3063
|
+
return secureStoreUnavailableMessage();
|
|
3064
|
+
}
|
|
2882
3065
|
function storeCredential(token, options = {}) {
|
|
2883
3066
|
const secure = storeSecureCredential(token, options.apiUrl);
|
|
2884
3067
|
if (secure) {
|
|
2885
3068
|
return secure;
|
|
2886
3069
|
}
|
|
2887
3070
|
if (!isFileCredentialStoreAllowed(options.allowFileFallback)) {
|
|
2888
|
-
throw new Error(
|
|
3071
|
+
throw new Error(secureStoreUnavailableMessage());
|
|
2889
3072
|
}
|
|
2890
3073
|
return storePlaintextCredential(token, options.apiUrl);
|
|
2891
3074
|
}
|
|
@@ -3961,7 +4144,7 @@ Size limits:
|
|
|
3961
4144
|
|
|
3962
4145
|
Container deploys:
|
|
3963
4146
|
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
|
|
4147
|
+
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
4148
|
With --with-db, containers receive NINEPM_SQL_URL and NINEPM_DB_BINDINGS for the managed SQL bridge.
|
|
3966
4149
|
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
4150
|
With --with-email, a signed-in end user can send app invitation emails; it implies --with-auth and works on public apps only.
|