@sendmux/cli 1.5.0 → 1.7.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.
Files changed (39) hide show
  1. package/README.md +26 -3
  2. package/dist/agent-auth.d.ts.map +1 -1
  3. package/dist/agent-auth.js +51 -7
  4. package/dist/base-command.d.ts +11 -4
  5. package/dist/base-command.d.ts.map +1 -1
  6. package/dist/base-command.js +24 -1
  7. package/dist/commands/auth/login.d.ts +14 -0
  8. package/dist/commands/auth/login.d.ts.map +1 -0
  9. package/dist/commands/auth/login.js +36 -0
  10. package/dist/commands/auth/logout.d.ts +9 -0
  11. package/dist/commands/auth/logout.d.ts.map +1 -0
  12. package/dist/commands/auth/logout.js +22 -0
  13. package/dist/commands/mailbox/stream-events.d.ts +6 -6
  14. package/dist/commands/profiles/list.d.ts.map +1 -1
  15. package/dist/commands/profiles/list.js +3 -1
  16. package/dist/commands/profiles/set.d.ts.map +1 -1
  17. package/dist/commands/profiles/set.js +4 -1
  18. package/dist/commands/profiles/show.d.ts.map +1 -1
  19. package/dist/commands/profiles/show.js +25 -23
  20. package/dist/generated/operations.d.ts +6 -6
  21. package/dist/generated/operations.js +7 -7
  22. package/dist/index.d.ts +1 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/oauth-http.d.ts +18 -0
  25. package/dist/oauth-http.d.ts.map +1 -0
  26. package/dist/oauth-http.js +113 -0
  27. package/dist/oauth-login.d.ts +13 -0
  28. package/dist/oauth-login.d.ts.map +1 -0
  29. package/dist/oauth-login.js +236 -0
  30. package/dist/oauth-profile.d.ts +6 -0
  31. package/dist/oauth-profile.d.ts.map +1 -0
  32. package/dist/oauth-profile.js +123 -0
  33. package/dist/operation-runner.d.ts.map +1 -1
  34. package/dist/operation-runner.js +72 -8
  35. package/dist/profiles.d.ts +23 -1
  36. package/dist/profiles.d.ts.map +1 -1
  37. package/dist/profiles.js +53 -7
  38. package/oclif.manifest.json +159 -68
  39. package/package.json +2 -2
@@ -0,0 +1,113 @@
1
+ export const OAUTH_RESOURCE = "https://sendmux.ai/api";
2
+ export const DEFAULT_OAUTH_ISSUER = "https://app.sendmux.ai";
3
+ export function oauthUrl(value, issuer) {
4
+ const url = new URL(value);
5
+ const loopback = url.hostname === "127.0.0.1" || url.hostname === "[::1]";
6
+ if ((url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) ||
7
+ url.username ||
8
+ url.password ||
9
+ url.hash ||
10
+ url.search ||
11
+ (issuer && url.origin !== new URL(issuer).origin))
12
+ throw new Error("OAuth endpoints must use HTTPS and the configured issuer origin.");
13
+ return url;
14
+ }
15
+ export async function oauthRequest(endpoint, init = {}) {
16
+ oauthUrl(endpoint);
17
+ try {
18
+ const response = await fetch(endpoint, {
19
+ ...init,
20
+ redirect: "error",
21
+ signal: init.signal ?? AbortSignal.timeout(15_000),
22
+ headers: { Accept: "application/json", ...init.headers },
23
+ });
24
+ const reader = response.body?.getReader();
25
+ const chunks = [];
26
+ let bytes = 0;
27
+ if (reader) {
28
+ try {
29
+ while (true) {
30
+ const chunk = await reader.read();
31
+ if (chunk.done)
32
+ break;
33
+ bytes += chunk.value.byteLength;
34
+ if (bytes > 256 * 1024)
35
+ throw new Error("Response exceeds the OAuth size limit.");
36
+ chunks.push(chunk.value);
37
+ }
38
+ }
39
+ finally {
40
+ await reader.cancel();
41
+ }
42
+ }
43
+ if (!response.ok)
44
+ throw new Error("OAuth request was rejected.");
45
+ if (bytes === 0)
46
+ return {};
47
+ if (!/^application\/json(?:;|$)/i.test(response.headers.get("content-type") ?? ""))
48
+ throw new Error("Invalid OAuth content type.");
49
+ const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
50
+ if (!value || typeof value !== "object" || Array.isArray(value))
51
+ throw new Error("Invalid OAuth response.");
52
+ return value;
53
+ }
54
+ catch {
55
+ throw new Error("OAuth request failed. Check the connection and try signing in again.");
56
+ }
57
+ }
58
+ export async function discoverOAuth(issuer, scopes) {
59
+ const url = oauthUrl(issuer);
60
+ const metadataUrl = `${url.origin}/.well-known/oauth-authorization-server${url.pathname === "/" ? "" : url.pathname}`;
61
+ const metadata = await oauthRequest(metadataUrl);
62
+ const supports = (field, value) => Array.isArray(metadata[field]) && metadata[field].includes(value);
63
+ if (metadata.issuer !== issuer ||
64
+ !supports("code_challenge_methods_supported", "S256") ||
65
+ !supports("token_endpoint_auth_methods_supported", "none") ||
66
+ metadata.authorization_response_iss_parameter_supported !== true ||
67
+ !supports("protected_resources", OAUTH_RESOURCE) ||
68
+ !scopes.every((scope) => supports("scopes_supported", scope)))
69
+ throw new Error("The issuer does not support the requested Sendmux OAuth connection.");
70
+ const endpoint = (name) => {
71
+ const value = metadata[name];
72
+ if (typeof value !== "string")
73
+ throw new Error("OAuth discovery is missing an endpoint.");
74
+ return oauthUrl(value, issuer).href;
75
+ };
76
+ return {
77
+ authorization: endpoint("authorization_endpoint"),
78
+ registration: endpoint("registration_endpoint"),
79
+ token: endpoint("token_endpoint"),
80
+ revocation: endpoint("revocation_endpoint"),
81
+ };
82
+ }
83
+ export function oauthTokenFields(value, allowedScopes) {
84
+ const validToken = (token) => typeof token === "string" && /^[A-Za-z0-9\-._~+/]+=*$/.test(token);
85
+ if (!validToken(value.access_token) ||
86
+ !validToken(value.refresh_token) ||
87
+ typeof value.token_type !== "string" ||
88
+ value.token_type.toLowerCase() !== "bearer" ||
89
+ typeof value.expires_in !== "number" ||
90
+ !Number.isSafeInteger(value.expires_in) ||
91
+ value.expires_in <= 0 ||
92
+ typeof value.scope !== "string")
93
+ throw new Error("The issuer returned invalid OAuth token metadata.");
94
+ const scopes = value.scope.split(" ").filter(Boolean);
95
+ if (!scopes.length || !scopes.every((scope) => allowedScopes.includes(scope)))
96
+ throw new Error("The issuer returned unexpected OAuth scopes.");
97
+ const expiresAt = Date.now() + value.expires_in * 1000;
98
+ if (!Number.isSafeInteger(expiresAt))
99
+ throw new Error("The issuer returned an invalid token lifetime.");
100
+ return {
101
+ accessToken: value.access_token,
102
+ refreshToken: value.refresh_token,
103
+ expiresAt,
104
+ scopes,
105
+ };
106
+ }
107
+ export function oauthForm(fields) {
108
+ return {
109
+ method: "POST",
110
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
111
+ body: new URLSearchParams(fields),
112
+ };
113
+ }
@@ -0,0 +1,13 @@
1
+ export declare function loginOAuth(input: {
2
+ configDir: string;
3
+ name: string;
4
+ issuer: string;
5
+ scopes: string[];
6
+ noBrowser: boolean;
7
+ report: (message: string) => void;
8
+ }): Promise<{
9
+ profile: string;
10
+ type: string;
11
+ scopes: string[];
12
+ }>;
13
+ //# sourceMappingURL=oauth-login.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oauth-login.d.ts","sourceRoot":"","sources":["../src/oauth-login.ts"],"names":[],"mappings":"AAmBA,wBAAsB,UAAU,CAAC,KAAK,EAAE;IACtC,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,OAAO,CAAC;IACnB,MAAM,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC;;;;GAsJA"}
@@ -0,0 +1,236 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
3
+ import { createServer } from "node:http";
4
+ import { promisify } from "node:util";
5
+ import { discoverOAuth, oauthForm, oauthRequest, oauthTokenFields, oauthUrl, OAUTH_RESOURCE, } from "./oauth-http.js";
6
+ import { isOAuthProfile, updateCliConfig, } from "./profiles.js";
7
+ export async function loginOAuth(input) {
8
+ const issuer = oauthUrl(input.issuer).href.replace(/\/$/, "");
9
+ const scopes = [
10
+ ...new Set(input.scopes.flatMap((scope) => scope.split(/\s+/)).filter(Boolean)),
11
+ ];
12
+ if (!scopes.length)
13
+ throw new Error("Choose at least one OAuth scope.");
14
+ const sessionId = randomUUID();
15
+ await updateCliConfig(input.configDir, (config) => {
16
+ if (config.profiles[input.name])
17
+ throw new Error("That profile already exists. Choose another name or log out first.");
18
+ config.profiles[input.name] = {
19
+ type: "oauth",
20
+ state: "authorizing",
21
+ sessionId,
22
+ issuer,
23
+ };
24
+ });
25
+ const abort = new AbortController();
26
+ const cancel = () => abort.abort();
27
+ process.once("SIGINT", cancel);
28
+ process.once("SIGTERM", cancel);
29
+ const timeout = setTimeout(cancel, 120_000);
30
+ let callback;
31
+ let activated;
32
+ try {
33
+ const endpoints = await discoverOAuth(issuer, scopes);
34
+ const state = randomBytes(32).toString("base64url");
35
+ const verifier = randomBytes(32).toString("base64url");
36
+ callback = await loopbackCallback({ state, issuer, signal: abort.signal });
37
+ const client = await oauthRequest(endpoints.registration, {
38
+ method: "POST",
39
+ headers: { "Content-Type": "application/json" },
40
+ body: JSON.stringify({
41
+ client_name: "Sendmux CLI",
42
+ application_type: "native",
43
+ redirect_uris: [callback.redirectUri],
44
+ grant_types: ["authorization_code", "refresh_token"],
45
+ response_types: ["code"],
46
+ token_endpoint_auth_method: "none",
47
+ resource: OAUTH_RESOURCE,
48
+ scope: scopes.join(" "),
49
+ }),
50
+ });
51
+ if (typeof client.client_id !== "string" ||
52
+ !client.client_id ||
53
+ client.token_endpoint_auth_method !== "none" ||
54
+ client.resource !== OAUTH_RESOURCE ||
55
+ !Array.isArray(client.redirect_uris) ||
56
+ client.redirect_uris.length !== 1 ||
57
+ client.redirect_uris[0] !== callback.redirectUri)
58
+ throw new Error("The issuer returned invalid client registration metadata.");
59
+ const url = new URL(endpoints.authorization);
60
+ url.search = new URLSearchParams({
61
+ client_id: client.client_id,
62
+ response_type: "code",
63
+ redirect_uri: callback.redirectUri,
64
+ scope: scopes.join(" "),
65
+ state,
66
+ resource: OAUTH_RESOURCE,
67
+ code_challenge_method: "S256",
68
+ code_challenge: createHash("sha256").update(verifier).digest("base64url"),
69
+ }).toString();
70
+ input.report(`Open this URL to sign in:\n${url.href}`);
71
+ if (!input.noBrowser)
72
+ await openBrowser(url.href).catch(() => input.report("Could not open the browser. Open the URL above to continue."));
73
+ const code = await callback.code;
74
+ await callback.close();
75
+ const fields = oauthTokenFields(await oauthRequest(endpoints.token, oauthForm({
76
+ grant_type: "authorization_code",
77
+ code,
78
+ code_verifier: verifier,
79
+ redirect_uri: callback.redirectUri,
80
+ client_id: client.client_id,
81
+ resource: OAUTH_RESOURCE,
82
+ })), scopes);
83
+ activated = {
84
+ type: "oauth",
85
+ state: "active",
86
+ sessionId,
87
+ issuer,
88
+ clientId: client.client_id,
89
+ tokenEndpoint: endpoints.token,
90
+ revocationEndpoint: endpoints.revocation,
91
+ ...fields,
92
+ };
93
+ const profile = activated;
94
+ await updateCliConfig(input.configDir, (config) => {
95
+ const existing = config.profiles[input.name];
96
+ if (!existing ||
97
+ !isOAuthProfile(existing) ||
98
+ existing.sessionId !== sessionId ||
99
+ existing.state !== "authorizing")
100
+ throw new Error("The login profile changed during authorization.");
101
+ config.profiles[input.name] = profile;
102
+ if (!config.defaultProfile)
103
+ config.defaultProfile = input.name;
104
+ });
105
+ return { profile: input.name, type: "oauth", scopes: profile.scopes };
106
+ }
107
+ catch (error) {
108
+ if (activated) {
109
+ await oauthRequest(activated.revocationEndpoint, oauthForm({
110
+ token: activated.refreshToken,
111
+ token_type_hint: "refresh_token",
112
+ client_id: activated.clientId,
113
+ })).catch(() => input.report("Could not revoke the interrupted login. Revoke the connection in Sendmux settings."));
114
+ }
115
+ await updateCliConfig(input.configDir, (config) => {
116
+ const existing = config.profiles[input.name];
117
+ if (existing &&
118
+ isOAuthProfile(existing) &&
119
+ existing.sessionId === sessionId &&
120
+ existing.state === "authorizing")
121
+ delete config.profiles[input.name];
122
+ });
123
+ throw error;
124
+ }
125
+ finally {
126
+ clearTimeout(timeout);
127
+ process.removeListener("SIGINT", cancel);
128
+ process.removeListener("SIGTERM", cancel);
129
+ await callback?.close();
130
+ }
131
+ }
132
+ async function openBrowser(url) {
133
+ const command = process.platform === "darwin"
134
+ ? "open"
135
+ : process.platform === "win32"
136
+ ? "rundll32.exe"
137
+ : "xdg-open";
138
+ await promisify(execFile)(command, process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url], { timeout: 5_000 });
139
+ }
140
+ async function loopbackCallback({ state, issuer, signal, }) {
141
+ let resolveCode;
142
+ let rejectCode;
143
+ const code = new Promise((resolve, reject) => {
144
+ resolveCode = resolve;
145
+ rejectCode = reject;
146
+ });
147
+ // Attach before registration/browser work so cancellation cannot become an unhandled rejection.
148
+ void code.catch(() => undefined);
149
+ let redirectUri = "";
150
+ let received = false;
151
+ const server = createServer((request, response) => {
152
+ response.setHeader("Cache-Control", "no-store");
153
+ response.setHeader("Content-Type", "text/plain; charset=utf-8");
154
+ let url;
155
+ try {
156
+ url = new URL(request.url ?? "/", redirectUri);
157
+ }
158
+ catch {
159
+ response.writeHead(400);
160
+ response.end("Invalid authorization response.");
161
+ return;
162
+ }
163
+ const one = (key) => url.searchParams.getAll(key).length === 1;
164
+ const valid = request.method === "GET" &&
165
+ url.pathname === "/callback" &&
166
+ request.headers.host === new URL(redirectUri).host &&
167
+ one("state") &&
168
+ url.searchParams.get("state") === state &&
169
+ one("iss") &&
170
+ url.searchParams.get("iss") === issuer &&
171
+ ((one("code") &&
172
+ !url.searchParams.has("error") &&
173
+ !!url.searchParams.get("code")) ||
174
+ (one("error") && !url.searchParams.has("code")));
175
+ if (!valid || received) {
176
+ response.writeHead(400);
177
+ response.end("Invalid authorization response.");
178
+ return;
179
+ }
180
+ received = true;
181
+ response.setHeader("Content-Type", "text/html; charset=utf-8");
182
+ response.end('<!doctype html><meta charset="utf-8"><link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27/%3E"><pre>Authorization received. You can return to the terminal.</pre>', () => {
183
+ if (url.searchParams.has("error"))
184
+ rejectCode(new Error("OAuth authorization was declined."));
185
+ else
186
+ resolveCode(url.searchParams.get("code"));
187
+ });
188
+ });
189
+ server.headersTimeout = 5_000;
190
+ server.requestTimeout = 10_000;
191
+ let host = "127.0.0.1";
192
+ for (const candidate of ["127.0.0.1", "::1"]) {
193
+ try {
194
+ await new Promise((resolve, reject) => {
195
+ const failed = (error) => {
196
+ server.removeListener("listening", bound);
197
+ reject(error);
198
+ };
199
+ const bound = () => {
200
+ server.removeListener("error", failed);
201
+ resolve();
202
+ };
203
+ server.once("error", failed);
204
+ server.once("listening", bound);
205
+ server.listen(0, candidate);
206
+ });
207
+ host = candidate;
208
+ break;
209
+ }
210
+ catch {
211
+ if (candidate === "::1")
212
+ throw new Error("Could not bind the OAuth callback listener.");
213
+ }
214
+ }
215
+ const address = server.address();
216
+ if (!address || typeof address === "string")
217
+ throw new Error("Could not bind the OAuth callback listener.");
218
+ redirectUri = `http://${host === "::1" ? "[::1]" : host}:${address.port}/callback`;
219
+ const cancel = () => rejectCode(new Error("OAuth login was cancelled or timed out."));
220
+ signal.addEventListener("abort", cancel, { once: true });
221
+ if (signal.aborted)
222
+ cancel();
223
+ let closing;
224
+ return {
225
+ redirectUri,
226
+ code,
227
+ close: () => {
228
+ if (closing)
229
+ return closing;
230
+ signal.removeEventListener("abort", cancel);
231
+ closing = new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
232
+ server.closeAllConnections();
233
+ return closing;
234
+ },
235
+ };
236
+ }
@@ -0,0 +1,6 @@
1
+ export declare function resolveOAuthToken(configDir: string, name: string): Promise<string>;
2
+ export declare function logoutOAuth(configDir: string, name: string): Promise<{
3
+ profile: string;
4
+ revoked: boolean;
5
+ }>;
6
+ //# sourceMappingURL=oauth-profile.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oauth-profile.d.ts","sourceRoot":"","sources":["../src/oauth-profile.ts"],"names":[],"mappings":"AAsBA,wBAAsB,iBAAiB,CACrC,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,MAAM,CAAC,CA4CjB;AA0DD,wBAAsB,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM;;;GA4ChE"}
@@ -0,0 +1,123 @@
1
+ import { oauthForm, oauthRequest, oauthTokenFields, oauthUrl, OAUTH_RESOURCE, } from "./oauth-http.js";
2
+ import { isOAuthProfile, readCliConfig, updateCliConfig, } from "./profiles.js";
3
+ function activeProfile(profile) {
4
+ if (!profile || !isOAuthProfile(profile) || profile.state === "authorizing") {
5
+ throw new Error("The OAuth profile is not ready. Complete login first.");
6
+ }
7
+ return profile;
8
+ }
9
+ export async function resolveOAuthToken(configDir, name) {
10
+ const deadline = Date.now() + 20_000;
11
+ while (true) {
12
+ const profile = activeProfile((await readCliConfig(configDir)).profiles[name]);
13
+ if (profile.state === "revoking" || profile.state === "reauthorize") {
14
+ throw new Error("This OAuth profile needs a new login. Run auth:logout, then auth:login.");
15
+ }
16
+ if (profile.state === "active" && profile.expiresAt > Date.now() + 30_000)
17
+ return profile.accessToken;
18
+ if (profile.state === "refreshing") {
19
+ if (Date.now() >= deadline ||
20
+ Date.now() - (profile.refreshStartedAt ?? 0) >= 20_000) {
21
+ throw new Error("OAuth refresh did not finish. Log out and sign in again; the old refresh token will not be replayed.");
22
+ }
23
+ await new Promise((resolve) => setTimeout(resolve, 50));
24
+ continue;
25
+ }
26
+ const reserved = await updateCliConfig(configDir, (config) => {
27
+ const current = activeProfile(config.profiles[name]);
28
+ if (current.state !== "active" ||
29
+ current.sessionId !== profile.sessionId ||
30
+ current.expiresAt > Date.now() + 30_000)
31
+ return null;
32
+ const next = {
33
+ ...current,
34
+ state: "refreshing",
35
+ refreshStartedAt: Date.now(),
36
+ };
37
+ config.profiles[name] = next;
38
+ return next;
39
+ });
40
+ if (!reserved)
41
+ continue;
42
+ return refreshReservedProfile(configDir, name, reserved);
43
+ }
44
+ }
45
+ async function refreshReservedProfile(configDir, name, profile) {
46
+ try {
47
+ const endpoint = oauthUrl(profile.tokenEndpoint, profile.issuer).href;
48
+ const fields = oauthTokenFields(await oauthRequest(endpoint, oauthForm({
49
+ grant_type: "refresh_token",
50
+ refresh_token: profile.refreshToken,
51
+ client_id: profile.clientId,
52
+ resource: OAUTH_RESOURCE,
53
+ })), profile.scopes);
54
+ await updateCliConfig(configDir, (config) => {
55
+ const current = activeProfile(config.profiles[name]);
56
+ if (current.sessionId !== profile.sessionId ||
57
+ current.state !== "refreshing" ||
58
+ current.refreshStartedAt !== profile.refreshStartedAt) {
59
+ throw new Error("The OAuth profile changed during refresh.");
60
+ }
61
+ const next = {
62
+ ...profile,
63
+ ...fields,
64
+ state: "active",
65
+ };
66
+ delete next.refreshStartedAt;
67
+ config.profiles[name] = next;
68
+ });
69
+ return fields.accessToken;
70
+ }
71
+ catch {
72
+ await updateCliConfig(configDir, (config) => {
73
+ const current = config.profiles[name];
74
+ if (current &&
75
+ isOAuthProfile(current) &&
76
+ current.state === "refreshing" &&
77
+ current.sessionId === profile.sessionId &&
78
+ current.refreshStartedAt === profile.refreshStartedAt) {
79
+ current.state = "reauthorize";
80
+ }
81
+ });
82
+ throw new Error("OAuth refresh failed. Log out and sign in again; the old refresh token will not be replayed.");
83
+ }
84
+ }
85
+ export async function logoutOAuth(configDir, name) {
86
+ const profile = await updateCliConfig(configDir, (config) => {
87
+ const stored = config.profiles[name];
88
+ if (stored && isOAuthProfile(stored) && stored.state === "authorizing") {
89
+ delete config.profiles[name];
90
+ if (config.defaultProfile === name)
91
+ delete config.defaultProfile;
92
+ return null;
93
+ }
94
+ const current = activeProfile(stored);
95
+ if (current.state === "refreshing" &&
96
+ Date.now() - (current.refreshStartedAt ?? 0) < 20_000) {
97
+ throw new Error("OAuth refresh is in progress. Retry logout when it finishes.");
98
+ }
99
+ const next = { ...current, state: "revoking" };
100
+ config.profiles[name] = next;
101
+ return next;
102
+ });
103
+ if (!profile)
104
+ return { profile: name, revoked: false };
105
+ const endpoint = oauthUrl(profile.revocationEndpoint, profile.issuer).href;
106
+ await oauthRequest(endpoint, oauthForm({
107
+ token: profile.refreshToken,
108
+ token_type_hint: "refresh_token",
109
+ client_id: profile.clientId,
110
+ }));
111
+ await updateCliConfig(configDir, (config) => {
112
+ const current = config.profiles[name];
113
+ if (current &&
114
+ isOAuthProfile(current) &&
115
+ current.sessionId === profile.sessionId &&
116
+ current.state === "revoking") {
117
+ delete config.profiles[name];
118
+ if (config.defaultProfile === name)
119
+ delete config.defaultProfile;
120
+ }
121
+ });
122
+ return { profile: name, revoked: true };
123
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"operation-runner.d.ts","sourceRoot":"","sources":["../src/operation-runner.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAGL,KAAK,cAAc,EACpB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAsBhE,wBAAsB,eAAe,CACnC,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,mBAAmB,EAC9B,KAAK,EAAE,cAAc,GACpB,OAAO,CAAC,OAAO,CAAC,CAwElB"}
1
+ {"version":3,"file":"operation-runner.d.ts","sourceRoot":"","sources":["../src/operation-runner.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAGL,KAAK,cAAc,EACpB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAuBhE,wBAAsB,eAAe,CACnC,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,mBAAmB,EAC9B,KAAK,EAAE,cAAc,GACpB,OAAO,CAAC,OAAO,CAAC,CAwElB"}
@@ -1,7 +1,12 @@
1
1
  import * as sdk from "@sendmux/sdk";
2
+ import { createHash } from "node:crypto";
3
+ import { closeSync, createReadStream, fsyncSync, openSync, writeSync } from "node:fs";
2
4
  import { readFile, stat } from "node:fs/promises";
3
5
  import { basename, extname } from "node:path";
4
6
  import { parseOperationOptions, } from "./operation-flags.js";
7
+ const MAX_SENDING_ATTACHMENTS = 10;
8
+ // Absolute Sending service ceiling; deployment policy may impose a lower limit.
9
+ const MAX_SENDING_ATTACHMENT_BYTES = 18 * 1024 * 1024;
5
10
  const surfaceModules = {
6
11
  mailbox: sdk.mailbox,
7
12
  management: sdk.management,
@@ -23,7 +28,7 @@ export async function runSdkOperation(command, operation, flags) {
23
28
  const auth = await command.resolveAuth(flags, operation.requiredKeyKind);
24
29
  baseUrl = auth.baseUrl;
25
30
  client = clientFactories[operation.surface]({
26
- apiKey: auth.apiKey,
31
+ ...(auth.apiKeyKind === "oauth" ? { accessToken: auth.accessToken } : { apiKey: auth.apiKey }),
27
32
  ...(baseUrl ? { baseUrl } : {}),
28
33
  });
29
34
  }
@@ -160,13 +165,18 @@ async function uploadMailboxAttachmentFromFile(command, client, operationOptions
160
165
  async function withAttachedFiles(command, operation, client, operationOptions, flags) {
161
166
  const body = jsonObjectBody(command, operationOptions.body);
162
167
  const existingAttachments = attachmentArray(command, body.attachments);
168
+ if (operation.operationId === "sendingSendEmail"
169
+ && existingAttachments.length + (flags.attach?.length ?? 0) > MAX_SENDING_ATTACHMENTS) {
170
+ command.error(`Sending email supports at most ${MAX_SENDING_ATTACHMENTS} attachments, including existing attachments and files.`, { exit: 2 });
171
+ }
163
172
  const files = [];
173
+ const maxBytes = operation.operationId === "sendingSendEmail" ? MAX_SENDING_ATTACHMENT_BYTES : undefined;
164
174
  for (const path of flags.attach ?? []) {
165
- files.push(await readAttachmentFile(command, path, flags["content-type"]));
175
+ files.push(await readAttachmentFile(command, path, flags["content-type"], maxBytes));
166
176
  }
167
177
  if (operation.operationId === "mailboxSendMessage") {
168
178
  const uploaded = [];
169
- for (const file of files) {
179
+ for (const [ordinal, file] of files.entries()) {
170
180
  const uploadResponse = await sdk.mailbox.mailboxUploadAttachment({
171
181
  client: client,
172
182
  body: blobFor(file),
@@ -179,8 +189,10 @@ async function withAttachedFiles(command, operation, client, operationOptions, f
179
189
  },
180
190
  });
181
191
  const result = envelopeData(uploadResponse, "mailboxUploadAttachment");
192
+ const blobId = stringField(result, "blob_id", "mailboxUploadAttachment");
193
+ journalNestedAttachment({ file, id: blobId, nestedOperationId: "mailboxUploadAttachment", operationId: operation.operationId, ordinal });
182
194
  uploaded.push({
183
- blob_id: stringField(result, "blob_id", "mailboxUploadAttachment"),
195
+ blob_id: blobId,
184
196
  content_type: stringField(result, "content_type", "mailboxUploadAttachment"),
185
197
  filename: stringField(result, "filename", "mailboxUploadAttachment"),
186
198
  });
@@ -195,11 +207,19 @@ async function withAttachedFiles(command, operation, client, operationOptions, f
195
207
  }
196
208
  if (operation.operationId === "sendingSendEmail") {
197
209
  const uploaded = [];
198
- for (const file of files) {
210
+ const outerKey = operationOptions.headers?.["Idempotency-Key"];
211
+ const idempotencyKey = typeof outerKey === "string" ? outerKey.trim() : undefined;
212
+ for (const [index, file] of files.entries()) {
199
213
  const uploadResponse = await sdk.sending.sendingUploadAttachment({
200
214
  client: client,
201
215
  body: blobFor(file),
202
216
  headers: {
217
+ ...(idempotencyKey ? {
218
+ // Match the TS/Python helpers; content must not change the key and evade conflict detection.
219
+ "Idempotency-Key": createHash("sha256")
220
+ .update(`sendmux:sending:attachment:${index}:${idempotencyKey}`)
221
+ .digest("hex"),
222
+ } : {}),
203
223
  "Content-Length": file.sizeBytes,
204
224
  "Content-Type": file.contentType,
205
225
  },
@@ -209,8 +229,10 @@ async function withAttachedFiles(command, operation, client, operationOptions, f
209
229
  },
210
230
  });
211
231
  const result = envelopeData(uploadResponse, "sendingUploadAttachment");
232
+ const attachmentId = stringField(result, "attachment_id", "sendingUploadAttachment");
233
+ journalNestedAttachment({ file, id: attachmentId, nestedOperationId: "sendingUploadAttachment", operationId: operation.operationId, ordinal: index });
212
234
  uploaded.push({
213
- attachment_id: stringField(result, "attachment_id", "sendingUploadAttachment"),
235
+ attachment_id: attachmentId,
214
236
  });
215
237
  }
216
238
  return {
@@ -237,7 +259,30 @@ async function withAttachedFiles(command, operation, client, operationOptions, f
237
259
  },
238
260
  };
239
261
  }
240
- async function readAttachmentFile(command, filePath, contentTypeOverride) {
262
+ function journalNestedAttachment({ file, id, nestedOperationId, operationId, ordinal }) {
263
+ const path = process.env.SENDMUX_LIVE_E2E_ATTACHMENT_JOURNAL;
264
+ if (!path)
265
+ return;
266
+ const record = {
267
+ adapter: "cli",
268
+ filename: file.filename,
269
+ id,
270
+ operationId,
271
+ nestedOperationId,
272
+ ordinal,
273
+ sha256: createHash("sha256").update(file.bytes).digest("hex"),
274
+ size_bytes: file.sizeBytes,
275
+ };
276
+ const descriptor = openSync(path, "a", 0o600);
277
+ try {
278
+ writeSync(descriptor, `${JSON.stringify(record)}\n`, undefined, "utf8");
279
+ fsyncSync(descriptor);
280
+ }
281
+ finally {
282
+ closeSync(descriptor);
283
+ }
284
+ }
285
+ async function readAttachmentFile(command, filePath, contentTypeOverride, maxBytes) {
241
286
  const info = await stat(filePath).catch((error) => {
242
287
  const message = error instanceof Error ? error.message : String(error);
243
288
  command.error(`Could not read attachment file ${filePath}: ${message}`, { exit: 2 });
@@ -248,7 +293,26 @@ async function readAttachmentFile(command, filePath, contentTypeOverride) {
248
293
  if (info.size === 0) {
249
294
  command.error(`Attachment file is empty: ${filePath}`, { exit: 2 });
250
295
  }
251
- const bytes = await readFile(filePath);
296
+ if (maxBytes !== undefined && info.size > maxBytes) {
297
+ command.error(`Attachment file exceeds ${maxBytes} bytes: ${filePath}`, { exit: 2 });
298
+ }
299
+ let bytes;
300
+ if (maxBytes === undefined) {
301
+ bytes = await readFile(filePath);
302
+ }
303
+ else {
304
+ const chunks = [];
305
+ // Include one excess byte so a growing file is rejected instead of truncated and uploaded.
306
+ for await (const chunk of createReadStream(filePath, { end: maxBytes }))
307
+ chunks.push(chunk);
308
+ bytes = Buffer.concat(chunks);
309
+ if (bytes.length > maxBytes) {
310
+ command.error(`Attachment file exceeds ${maxBytes} bytes: ${filePath}`, { exit: 2 });
311
+ }
312
+ }
313
+ if (bytes.length === 0) {
314
+ command.error(`Attachment file is empty: ${filePath}`, { exit: 2 });
315
+ }
252
316
  return {
253
317
  bytes,
254
318
  contentType: contentTypeOverride ?? inferContentType(filePath),