@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.
- package/README.md +26 -3
- package/dist/agent-auth.d.ts.map +1 -1
- package/dist/agent-auth.js +51 -7
- package/dist/base-command.d.ts +11 -4
- package/dist/base-command.d.ts.map +1 -1
- package/dist/base-command.js +24 -1
- package/dist/commands/auth/login.d.ts +14 -0
- package/dist/commands/auth/login.d.ts.map +1 -0
- package/dist/commands/auth/login.js +36 -0
- package/dist/commands/auth/logout.d.ts +9 -0
- package/dist/commands/auth/logout.d.ts.map +1 -0
- package/dist/commands/auth/logout.js +22 -0
- package/dist/commands/mailbox/stream-events.d.ts +6 -6
- package/dist/commands/profiles/list.d.ts.map +1 -1
- package/dist/commands/profiles/list.js +3 -1
- package/dist/commands/profiles/set.d.ts.map +1 -1
- package/dist/commands/profiles/set.js +4 -1
- package/dist/commands/profiles/show.d.ts.map +1 -1
- package/dist/commands/profiles/show.js +25 -23
- package/dist/generated/operations.d.ts +6 -6
- package/dist/generated/operations.js +7 -7
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/oauth-http.d.ts +18 -0
- package/dist/oauth-http.d.ts.map +1 -0
- package/dist/oauth-http.js +113 -0
- package/dist/oauth-login.d.ts +13 -0
- package/dist/oauth-login.d.ts.map +1 -0
- package/dist/oauth-login.js +236 -0
- package/dist/oauth-profile.d.ts +6 -0
- package/dist/oauth-profile.d.ts.map +1 -0
- package/dist/oauth-profile.js +123 -0
- package/dist/operation-runner.d.ts.map +1 -1
- package/dist/operation-runner.js +72 -8
- package/dist/profiles.d.ts +23 -1
- package/dist/profiles.d.ts.map +1 -1
- package/dist/profiles.js +53 -7
- package/oclif.manifest.json +159 -68
- 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 @@
|
|
|
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":"
|
|
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"}
|
package/dist/operation-runner.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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),
|