runline 0.37.0 → 0.39.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/dist/credentials/broker.js +32 -16
- package/dist/credentials/cookies.d.ts +33 -0
- package/dist/credentials/cookies.js +151 -0
- package/dist/credentials/http.d.ts +28 -3
- package/dist/credentials/http.js +34 -10
- package/dist/credentials/plugin.d.ts +21 -2
- package/dist/credentials/plugin.js +8 -3
- package/dist/credentials/policy.js +2 -0
- package/dist/credentials/registry.d.ts +25 -0
- package/dist/credentials/registry.js +84 -0
- package/dist/credentials/transport.d.ts +24 -0
- package/dist/credentials/transport.js +222 -7
- package/dist/credentials/types.d.ts +74 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- package/dist/plugins/_shared/attachments.js +97 -0
- package/dist/plugins/_shared/microsoftAuth.js +33 -4
- package/dist/plugins/_shared/schemas.js +8 -0
- package/dist/plugins/agentMail/src/drafts.js +2 -1
- package/dist/plugins/agentMail/src/inboxes.js +2 -1
- package/dist/plugins/agentMail/src/messages.js +2 -1
- package/dist/plugins/agentMail/src/schemas.js +4 -41
- package/dist/plugins/agentMail/src/shared.js +28 -50
- package/dist/plugins/agentMail/src/threads.js +2 -1
- package/dist/plugins/gmail/src/index.js +10 -22
- package/dist/plugins/microsoftCalendar/src/index.js +513 -17
- package/dist/plugins/microsoftFiles/src/index.js +3 -3
- package/dist/plugins/microsoftMail/src/index.js +305 -54
- package/dist/plugins/todoist/src/credentials.js +4 -11
- package/dist/plugins/todoist/src/index.js +390 -281
- package/dist/plugins/zohoMail/src/credentials.js +92 -0
- package/dist/plugins/zohoMail/src/index.js +241 -0
- package/package.json +1 -1
|
@@ -3,9 +3,10 @@ import { Check } from "typebox/value";
|
|
|
3
3
|
import { AuthError } from "../auth/errors.js";
|
|
4
4
|
import { acquireOAuth2ClientToken, acquireOAuth2JwtToken, acquireOAuth2PasswordToken, refreshOAuth2Token, } from "../auth/oauth2.js";
|
|
5
5
|
import { assumeAwsRole, awsAccessKey } from "./aws.js";
|
|
6
|
+
import { applySetCookies, cookieFor, cookieHeader, liveCookies, } from "./cookies.js";
|
|
6
7
|
import { sendResource } from "./http.js";
|
|
7
8
|
import { bodyFields, bodyFormat, bounded, headerName, injectedFields, injectedHeaders, injectedParams, injectedPointers, placementsFor, pointerSlot, refuseCredentialParams, resourceUrl, secret, TARGET_RESPONSE_LIMIT_BYTES, TARGET_TIMEOUT_LIMIT_MS, TRANSPORT_HEADERS, targetBase, targetOf, } from "./policy.js";
|
|
8
|
-
import { OAuthGrantSchema, validateCredential, } from "./registry.js";
|
|
9
|
+
import { CookieSessionSchema, OAuthGrantSchema, validateCredential, } from "./registry.js";
|
|
9
10
|
import { canonicalQuery, signSigV4 } from "./sigv4.js";
|
|
10
11
|
function boundSnapshot(binding, method, snapshot) {
|
|
11
12
|
if (snapshot.name !== binding.identity.name ||
|
|
@@ -29,6 +30,67 @@ function grantFrom(method, config) {
|
|
|
29
30
|
throw new AuthError("unsupported_operation");
|
|
30
31
|
return result;
|
|
31
32
|
}
|
|
33
|
+
function cookieSessionFrom(auth, config) {
|
|
34
|
+
const session = config[auth.field];
|
|
35
|
+
if (session === undefined)
|
|
36
|
+
return undefined;
|
|
37
|
+
if (!Check(CookieSessionSchema, session))
|
|
38
|
+
throw new AuthError("invalid_credentials");
|
|
39
|
+
return session;
|
|
40
|
+
}
|
|
41
|
+
/** Methods that change state, on which a CSRF header is sent. */
|
|
42
|
+
function unsafe(method) {
|
|
43
|
+
return method !== "GET" && method !== "HEAD";
|
|
44
|
+
}
|
|
45
|
+
/** Signs with the jar's cookies for the request's URL, and the CSRF header on an unsafe one. */
|
|
46
|
+
function cookieSigner(auth, cookies) {
|
|
47
|
+
return ({ method, headers, url }) => {
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
const cookie = cookieHeader(cookies, url, now);
|
|
50
|
+
if (cookie)
|
|
51
|
+
headers.set("cookie", cookie);
|
|
52
|
+
if (auth.csrf && unsafe(method)) {
|
|
53
|
+
const token = cookieFor(cookies, auth.csrf.cookie, url, now);
|
|
54
|
+
if (token)
|
|
55
|
+
headers.set(headerName(auth.csrf.header), token);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Whether a Location, read against the request's `url`, falls beneath a declared login path. */
|
|
60
|
+
function toLogin(auth, loginTarget, location, url) {
|
|
61
|
+
if (!location || !auth.rejection.loginPaths)
|
|
62
|
+
return false;
|
|
63
|
+
let next;
|
|
64
|
+
try {
|
|
65
|
+
next = new URL(location, url);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
return auth.rejection.loginPaths.some((path) => {
|
|
71
|
+
const login = resourceUrl(loginTarget, path);
|
|
72
|
+
return (next.origin === login.origin && next.pathname.startsWith(login.pathname));
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* How a response shows a cookie session was rejected, by the method's
|
|
77
|
+
* declared signals: its status, a redirect to a login path, or a body
|
|
78
|
+
* marker on an HTML answer. Null when it shows none.
|
|
79
|
+
*/
|
|
80
|
+
async function sessionRejection(auth, loginTarget, response, url) {
|
|
81
|
+
const { statuses, marker } = auth.rejection;
|
|
82
|
+
if (statuses?.includes(response.status))
|
|
83
|
+
return "status";
|
|
84
|
+
if (toLogin(auth, loginTarget, response.headers.get("location"), url))
|
|
85
|
+
return "redirect";
|
|
86
|
+
if (marker &&
|
|
87
|
+
response.status >= 200 &&
|
|
88
|
+
response.status < 300 &&
|
|
89
|
+
(response.headers.get("content-type") ?? "").includes("text/html") &&
|
|
90
|
+
(await response.clone().text()).includes(marker))
|
|
91
|
+
return "marker";
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
32
94
|
function pinBinding(selection) {
|
|
33
95
|
return {
|
|
34
96
|
...selection,
|
|
@@ -313,6 +375,118 @@ export class CredentialTransport {
|
|
|
313
375
|
},
|
|
314
376
|
});
|
|
315
377
|
}
|
|
378
|
+
/**
|
|
379
|
+
* A cookie session logged in under the connection's update ownership, so
|
|
380
|
+
* parallel calls on an expired session log in once: one another caller
|
|
381
|
+
* committed meanwhile is kept. With `rejected`, the jar sent at
|
|
382
|
+
* `observedRevision` was refused and only a newer login is kept.
|
|
383
|
+
*/
|
|
384
|
+
loginSession(binding, method, observedRevision, rejected = false) {
|
|
385
|
+
const auth = method.authentication;
|
|
386
|
+
if (auth.kind !== "cookie")
|
|
387
|
+
throw new AuthError("invalid_definition");
|
|
388
|
+
return this.commitIssued(binding, method, {
|
|
389
|
+
field: auth.field,
|
|
390
|
+
read: (config) => cookieSessionFrom(auth, config),
|
|
391
|
+
usable: (previous) => !!previous &&
|
|
392
|
+
liveCookies(previous.cookies, Date.now()).length > 0 &&
|
|
393
|
+
(!rejected || previous.revision !== observedRevision),
|
|
394
|
+
issue: (previous) => this.logIn(auth, method, binding, previous),
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* The declared login: a preflight GET when declared, then the owner's
|
|
399
|
+
* POST, both carrying the jar and absorbing what they set. The jar is
|
|
400
|
+
* merged into, never replaced, so a device-trust cookie survives. A
|
|
401
|
+
* login refused by status is `provider_rejected` with it; one the app
|
|
402
|
+
* answers with a rejection signal, or that leaves no cookie, is
|
|
403
|
+
* `reconnect_required`. The password goes in this one request only.
|
|
404
|
+
*/
|
|
405
|
+
async logIn(auth, method, binding, previous) {
|
|
406
|
+
const owner = binding.resourceOwner;
|
|
407
|
+
if (!owner?.username || !owner.password)
|
|
408
|
+
throw new AuthError("invalid_credentials");
|
|
409
|
+
const { login } = auth;
|
|
410
|
+
const target = targetOf(method, login.target);
|
|
411
|
+
if (!target)
|
|
412
|
+
throw new AuthError("invalid_definition");
|
|
413
|
+
let cookies = liveCookies(previous?.cookies ?? [], Date.now());
|
|
414
|
+
const exchange = async (path, verb, body) => {
|
|
415
|
+
const url = resourceUrl(target, path);
|
|
416
|
+
const headers = new Headers({ accept: "*/*" });
|
|
417
|
+
if (body)
|
|
418
|
+
headers.set("content-type", body.type);
|
|
419
|
+
const sign = cookieSigner(auth, cookies);
|
|
420
|
+
sign({ method: verb, headers, url, base: targetBase(target).pathname });
|
|
421
|
+
let set = [];
|
|
422
|
+
const response = await sendResource(url.toString(), verb, headers, body?.bytes, {
|
|
423
|
+
fetch: this.options.fetch,
|
|
424
|
+
timeoutMs: this.options.timeoutMs,
|
|
425
|
+
maxResponseBytes: this.options.maxResponseBytes,
|
|
426
|
+
redirect: () => true,
|
|
427
|
+
setCookie: (values) => {
|
|
428
|
+
set = values;
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
cookies = applySetCookies(cookies, set, url, Date.now());
|
|
432
|
+
return { response, url };
|
|
433
|
+
};
|
|
434
|
+
if (login.preflight !== undefined) {
|
|
435
|
+
const { response } = await exchange(login.preflight, "GET");
|
|
436
|
+
if (response.status >= 400)
|
|
437
|
+
throw new AuthError("provider_rejected", response.status);
|
|
438
|
+
}
|
|
439
|
+
const fields = {
|
|
440
|
+
...login.parameters,
|
|
441
|
+
[login.fields?.username ?? "username"]: owner.username,
|
|
442
|
+
[login.fields?.password ?? "password"]: owner.password,
|
|
443
|
+
};
|
|
444
|
+
const body = login.encoding === "json"
|
|
445
|
+
? {
|
|
446
|
+
type: "application/json",
|
|
447
|
+
bytes: Buffer.from(JSON.stringify(fields)),
|
|
448
|
+
}
|
|
449
|
+
: {
|
|
450
|
+
type: "application/x-www-form-urlencoded",
|
|
451
|
+
bytes: Buffer.from(new URLSearchParams(fields).toString()),
|
|
452
|
+
};
|
|
453
|
+
const { response, url } = await exchange(login.path, "POST", body);
|
|
454
|
+
if (response.status >= 400)
|
|
455
|
+
throw new AuthError("provider_rejected", response.status);
|
|
456
|
+
if ((await sessionRejection(auth, target, response, url)) ||
|
|
457
|
+
!liveCookies(cookies, Date.now()).length)
|
|
458
|
+
throw new AuthError("reconnect_required");
|
|
459
|
+
return { cookies, revision: randomUUID() };
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* What a response of the session sent at `revision` set, merged into the
|
|
463
|
+
* stored jar: a rotated, added or deleted cookie. Only while the jar is
|
|
464
|
+
* still that login's, so a stale response never undoes a newer login.
|
|
465
|
+
* Best effort: a store that fails leaves the response standing, and the
|
|
466
|
+
* session is renewed when it is next rejected.
|
|
467
|
+
*/
|
|
468
|
+
async absorb(binding, method, revision, values, url) {
|
|
469
|
+
const auth = method.authentication;
|
|
470
|
+
if (auth.kind !== "cookie" || !values.length)
|
|
471
|
+
return;
|
|
472
|
+
try {
|
|
473
|
+
await binding.connection.update(async (current) => {
|
|
474
|
+
validateCredential(method, current);
|
|
475
|
+
const stored = cookieSessionFrom(auth, current);
|
|
476
|
+
if (stored?.revision !== revision)
|
|
477
|
+
return undefined;
|
|
478
|
+
return {
|
|
479
|
+
[auth.field]: {
|
|
480
|
+
cookies: applySetCookies(stored.cookies, values, url, Date.now()),
|
|
481
|
+
revision,
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
// The response stands; the jar catches up at the next rejection.
|
|
488
|
+
}
|
|
489
|
+
}
|
|
316
490
|
/** A role session for the binding's `awsRole`, assumed with the stored key. */
|
|
317
491
|
renewAwsSession(binding, method, observedRevision) {
|
|
318
492
|
const auth = method.authentication;
|
|
@@ -442,9 +616,17 @@ export class CredentialTransport {
|
|
|
442
616
|
}
|
|
443
617
|
const replay = input.retry !== "never" &&
|
|
444
618
|
(verb === "GET" || verb === "HEAD" || input.idempotencyKey !== undefined);
|
|
619
|
+
// A rejected credential is dead whether or not this request may be sent
|
|
620
|
+
// again, so it is renewed either way and the next call signs with the
|
|
621
|
+
// renewal. Only a replayable request is sent again, exactly once; any
|
|
622
|
+
// other is answered with its rejection.
|
|
623
|
+
const afterRejection = async (rejection, renew, resend) => {
|
|
624
|
+
const renewed = await renew();
|
|
625
|
+
return replay ? resend(renewed) : rejection;
|
|
626
|
+
};
|
|
445
627
|
const base = targetBase(target).pathname;
|
|
446
|
-
|
|
447
|
-
const send = async (signer) => {
|
|
628
|
+
const { sign, grant, session } = await this.authorize(binding, method, input.target);
|
|
629
|
+
const send = async (signer, cookies) => {
|
|
448
630
|
const outgoing = {
|
|
449
631
|
method: verb,
|
|
450
632
|
headers: new Headers(headers),
|
|
@@ -458,17 +640,43 @@ export class CredentialTransport {
|
|
|
458
640
|
timeoutMs: Math.min(target.timeoutMs ?? this.options.timeoutMs, this.options.maxTargetTimeoutMs),
|
|
459
641
|
maxResponseBytes: Math.min(target.maxResponseBytes ?? this.options.maxResponseBytes, this.options.maxTargetResponseBytes),
|
|
460
642
|
resumableUpload: target.resumableUpload,
|
|
643
|
+
...cookies,
|
|
461
644
|
});
|
|
462
645
|
};
|
|
646
|
+
if (auth.kind === "cookie" && session) {
|
|
647
|
+
const loginTarget = targetOf(method, auth.login.target);
|
|
648
|
+
if (!loginTarget)
|
|
649
|
+
throw new AuthError("invalid_definition");
|
|
650
|
+
const attempt = async (current) => {
|
|
651
|
+
let set = [];
|
|
652
|
+
const response = await send(cookieSigner(auth, current.cookies), {
|
|
653
|
+
redirect: (location) => toLogin(auth, loginTarget, location, url),
|
|
654
|
+
setCookie: (values) => {
|
|
655
|
+
set = values;
|
|
656
|
+
},
|
|
657
|
+
});
|
|
658
|
+
await this.absorb(binding, method, current.revision, set, url);
|
|
659
|
+
const rejection = await sessionRejection(auth, loginTarget, response, url);
|
|
660
|
+
// A rejection by redirect or marker reads as the 401 it means; one
|
|
661
|
+
// by status keeps the app's own answer.
|
|
662
|
+
return {
|
|
663
|
+
rejection,
|
|
664
|
+
response: rejection === "redirect" || rejection === "marker"
|
|
665
|
+
? new Response(null, { status: 401 })
|
|
666
|
+
: response,
|
|
667
|
+
};
|
|
668
|
+
};
|
|
669
|
+
const first = await attempt(session);
|
|
670
|
+
if (!first.rejection)
|
|
671
|
+
return first.response;
|
|
672
|
+
return afterRejection(first.response, () => this.loginSession(binding, method, session.revision, true), async (renewed) => (await attempt(renewed)).response);
|
|
673
|
+
}
|
|
463
674
|
const first = await send(sign);
|
|
464
675
|
if (auth.kind !== "oauth2" ||
|
|
465
676
|
!grant ||
|
|
466
|
-
!replay ||
|
|
467
677
|
first.status !== (auth.rejectionStatus ?? 401))
|
|
468
678
|
return first;
|
|
469
|
-
|
|
470
|
-
grant = await this.renew(binding, method, grant.revision, true);
|
|
471
|
-
return send(bearer(secret(grant.tokens.accessToken)));
|
|
679
|
+
return afterRejection(first, () => this.renew(binding, method, grant.revision, true), (renewed) => send(bearer(secret(renewed.tokens.accessToken))));
|
|
472
680
|
}
|
|
473
681
|
/**
|
|
474
682
|
* A socket target's URL, signed by its query placements, for a host's
|
|
@@ -521,6 +729,13 @@ export class CredentialTransport {
|
|
|
521
729
|
const auth = method.authentication;
|
|
522
730
|
if (auth.kind === "none")
|
|
523
731
|
return { sign: () => { } };
|
|
732
|
+
if (auth.kind === "cookie") {
|
|
733
|
+
const stored = cookieSessionFrom(auth, config);
|
|
734
|
+
const session = stored && liveCookies(stored.cookies, Date.now()).length
|
|
735
|
+
? stored
|
|
736
|
+
: await this.loginSession(binding, method, stored?.revision);
|
|
737
|
+
return { sign: cookieSigner(auth, session.cookies), session };
|
|
738
|
+
}
|
|
524
739
|
if (auth.kind === "awsSigV4") {
|
|
525
740
|
const scope = targetOf(method, target)?.aws;
|
|
526
741
|
if (!scope)
|
|
@@ -139,7 +139,8 @@ export type SecretPlacement = ({
|
|
|
139
139
|
* How a request is signed. `field` names the one top-level config field
|
|
140
140
|
* holding the secret: for `static`, a record of the named `parts`
|
|
141
141
|
* (`staticSecretSchema(parts)`), each sent through its placements; for
|
|
142
|
-
* `oauth2`, a revisioned OAuthGrant; for `awsSigV4`, an AWS access key
|
|
142
|
+
* `oauth2`, a revisioned OAuthGrant; for `awsSigV4`, an AWS access key;
|
|
143
|
+
* for `cookie`, a revisioned `CookieSession` jar.
|
|
143
144
|
* `none` signs nothing: the method of a connection that holds no optional
|
|
144
145
|
* credential, still pinned to its targets.
|
|
145
146
|
*/
|
|
@@ -190,7 +191,79 @@ export type CredentialAuthentication = {
|
|
|
190
191
|
assumeRole?: {
|
|
191
192
|
sessionField: string;
|
|
192
193
|
};
|
|
194
|
+
} | {
|
|
195
|
+
/**
|
|
196
|
+
* A web app's cookie session. The transport logs in with the
|
|
197
|
+
* binding's `resourceOwner` as `login` declares, keeps every cookie
|
|
198
|
+
* the method's targets set in a jar in `field` (`CookieSessionSchema`),
|
|
199
|
+
* sends each only where RFC 6265 matches it, and logs in again when a
|
|
200
|
+
* response shows the session was rejected. The plugin never sees the
|
|
201
|
+
* password, the jar, or a `Set-Cookie`.
|
|
202
|
+
*/
|
|
203
|
+
kind: "cookie";
|
|
204
|
+
field: string;
|
|
205
|
+
login: CookieLogin;
|
|
206
|
+
rejection: CookieRejection;
|
|
207
|
+
/**
|
|
208
|
+
* Cookie-based CSRF (Django's `csrftoken`, `XSRF-TOKEN`): the named
|
|
209
|
+
* cookie's value is copied into `header` on every unsafe request, the
|
|
210
|
+
* login included. The header is reserved: callers never set it.
|
|
211
|
+
*/
|
|
212
|
+
csrf?: {
|
|
213
|
+
cookie: string;
|
|
214
|
+
header: string;
|
|
215
|
+
};
|
|
193
216
|
};
|
|
217
|
+
/**
|
|
218
|
+
* How the transport logs in: an optional preflight GET (a CSRF cookie, a
|
|
219
|
+
* session to log into), then one POST of the owner's username and password
|
|
220
|
+
* under the app's own field names, beside fixed `parameters`. Both go to
|
|
221
|
+
* `target` and carry the jar, so a device-trust cookie rides along.
|
|
222
|
+
*/
|
|
223
|
+
export interface CookieLogin {
|
|
224
|
+
target: string;
|
|
225
|
+
/** Relative to the target's base, as a request path is. */
|
|
226
|
+
path: string;
|
|
227
|
+
encoding: "form" | "json";
|
|
228
|
+
/** The app's names for the owner's fields; `username` and `password` when absent. */
|
|
229
|
+
fields?: {
|
|
230
|
+
username?: string;
|
|
231
|
+
password?: string;
|
|
232
|
+
};
|
|
233
|
+
/** Fixed extra fields, such as `remember: "1"`. Never a secret. */
|
|
234
|
+
parameters?: Record<string, string>;
|
|
235
|
+
/** A path on `target` to GET before the POST. */
|
|
236
|
+
preflight?: string;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* What a rejected session looks like, at least one of: a status; a 3xx
|
|
240
|
+
* whose Location falls under one of `loginPaths` (relative to the login
|
|
241
|
+
* target, as `login.path` is); or a 2xx HTML answer containing `marker`.
|
|
242
|
+
*/
|
|
243
|
+
export interface CookieRejection {
|
|
244
|
+
/** 4xx statuses, such as 401, 403, Laravel's 419 or IIS's 440. */
|
|
245
|
+
statuses?: number[];
|
|
246
|
+
loginPaths?: string[];
|
|
247
|
+
marker?: string;
|
|
248
|
+
}
|
|
249
|
+
/** One cookie in a session's jar, as RFC 6265 stores it. */
|
|
250
|
+
export interface StoredCookie {
|
|
251
|
+
name: string;
|
|
252
|
+
value: string;
|
|
253
|
+
/** Lowercased: the exact host when `hostOnly`, else the Domain attribute. */
|
|
254
|
+
domain: string;
|
|
255
|
+
hostOnly: boolean;
|
|
256
|
+
path: string;
|
|
257
|
+
secure: boolean;
|
|
258
|
+
httpOnly: boolean;
|
|
259
|
+
/** Milliseconds since epoch; absent for a session cookie, kept until replaced. */
|
|
260
|
+
expiresAt?: number;
|
|
261
|
+
}
|
|
262
|
+
/** A cookie jar as stored. `revision` changes on every login, never on a rotated cookie. */
|
|
263
|
+
export interface CookieSession {
|
|
264
|
+
cookies: StoredCookie[];
|
|
265
|
+
revision: string;
|
|
266
|
+
}
|
|
194
267
|
export interface CredentialProbe {
|
|
195
268
|
target: string;
|
|
196
269
|
/** Fixed relative path, including any fixed query. No expressions or callbacks. */
|
package/dist/index.d.ts
CHANGED
|
@@ -16,13 +16,13 @@ export { awsEndpoint, awsRegion, parseAwsXml } from "./credentials/aws.js";
|
|
|
16
16
|
export type { AwsCredentialSpec } from "./credentials/aws-plugin.js";
|
|
17
17
|
export { awsCredential, awsFailed, awsJson, awsList, awsQuery, awsTarget, } from "./credentials/aws-plugin.js";
|
|
18
18
|
export { brokerFor } from "./credentials/broker.js";
|
|
19
|
-
export { downloadResource } from "./credentials/http.js";
|
|
19
|
+
export { downloadResource, type PresignedRequestOptions, presignedRequest, } from "./credentials/http.js";
|
|
20
20
|
export type { CredentialCall, EmptyAnswer, ErrorEnvelope, StaticCredentialSpec, } from "./credentials/plugin.js";
|
|
21
|
-
export { answerFailed, configChoice, configFlag, credentialJson, credentialOk, credentialRequest, credentialSocketUrl, errorIdentifier, failureMessage, grantSchema, graphqlFailed, hostLabel, httpsBase, jsonAnswer, jsonOrAcknowledged, multipartBody, pathSegment, pathSegments, pathWithin, refuseUnderHost, requestFailed, responseFailed, slashEncodedSegment, staticCredential, } from "./credentials/plugin.js";
|
|
22
|
-
export { AwsAccessKeySchema, AwsSessionSchema, CredentialRegistry, OAuthGrantSchema, OAuthTokensSchema, staticSecretSchema, validateCredential, } from "./credentials/registry.js";
|
|
21
|
+
export { answerFailed, configChoice, configFlag, cookieSessionSchema, credentialJson, credentialOk, credentialRequest, credentialSocketUrl, errorIdentifier, failureMessage, grantSchema, graphqlFailed, hostLabel, httpsBase, jsonAnswer, jsonOrAcknowledged, multipartBody, pathSegment, pathSegments, pathWithin, refuseUnderHost, requestFailed, responseFailed, slashEncodedSegment, staticCredential, } from "./credentials/plugin.js";
|
|
22
|
+
export { AwsAccessKeySchema, AwsSessionSchema, CookieSessionSchema, CredentialRegistry, OAuthGrantSchema, OAuthTokensSchema, StoredCookieSchema, staticSecretSchema, validateCredential, } from "./credentials/registry.js";
|
|
23
23
|
export type { AuthenticatedRequest, CredentialBroker, CredentialBrokerCall, CredentialTransportOptions, SocketRequest, } from "./credentials/transport.js";
|
|
24
24
|
export { CredentialTransport } from "./credentials/transport.js";
|
|
25
|
-
export type { AwsAccessKey, AwsRole, AwsScope, AwsSession, CredentialAuthentication, CredentialBinding, CredentialDeclaration, CredentialMethod, CredentialProbe, CredentialProbeResult, CredentialSelection, CredentialTarget, CredentialType, HttpMethod, LocalSecretPart, LocalSecretSource, OAuthGrant, SecretPlacement, } from "./credentials/types.js";
|
|
25
|
+
export type { AwsAccessKey, AwsRole, AwsScope, AwsSession, CookieLogin, CookieRejection, CookieSession, CredentialAuthentication, CredentialBinding, CredentialDeclaration, CredentialMethod, CredentialProbe, CredentialProbeResult, CredentialSelection, CredentialTarget, CredentialType, HttpMethod, LocalSecretPart, LocalSecretSource, OAuthGrant, SecretPlacement, StoredCookie, } from "./credentials/types.js";
|
|
26
26
|
export type { ActionDefinition, PluginFunction, RunlinePluginAPI, } from "./plugin/api.js";
|
|
27
27
|
export { createPluginAPI, isPluginFunction, resolvePluginExport, } from "./plugin/api.js";
|
|
28
28
|
export type { InstalledPlugin, PluginSource } from "./plugin/installer.js";
|
package/dist/index.js
CHANGED
|
@@ -10,9 +10,9 @@ export { buildAuthUrl, exchangeAuthCode, generatePKCE, OAUTH_CALLBACK_PORT, OAUT
|
|
|
10
10
|
export { awsEndpoint, awsRegion, parseAwsXml } from "./credentials/aws.js";
|
|
11
11
|
export { awsCredential, awsFailed, awsJson, awsList, awsQuery, awsTarget, } from "./credentials/aws-plugin.js";
|
|
12
12
|
export { brokerFor } from "./credentials/broker.js";
|
|
13
|
-
export { downloadResource } from "./credentials/http.js";
|
|
14
|
-
export { answerFailed, configChoice, configFlag, credentialJson, credentialOk, credentialRequest, credentialSocketUrl, errorIdentifier, failureMessage, grantSchema, graphqlFailed, hostLabel, httpsBase, jsonAnswer, jsonOrAcknowledged, multipartBody, pathSegment, pathSegments, pathWithin, refuseUnderHost, requestFailed, responseFailed, slashEncodedSegment, staticCredential, } from "./credentials/plugin.js";
|
|
15
|
-
export { AwsAccessKeySchema, AwsSessionSchema, CredentialRegistry, OAuthGrantSchema, OAuthTokensSchema, staticSecretSchema, validateCredential, } from "./credentials/registry.js";
|
|
13
|
+
export { downloadResource, presignedRequest, } from "./credentials/http.js";
|
|
14
|
+
export { answerFailed, configChoice, configFlag, cookieSessionSchema, credentialJson, credentialOk, credentialRequest, credentialSocketUrl, errorIdentifier, failureMessage, grantSchema, graphqlFailed, hostLabel, httpsBase, jsonAnswer, jsonOrAcknowledged, multipartBody, pathSegment, pathSegments, pathWithin, refuseUnderHost, requestFailed, responseFailed, slashEncodedSegment, staticCredential, } from "./credentials/plugin.js";
|
|
15
|
+
export { AwsAccessKeySchema, AwsSessionSchema, CookieSessionSchema, CredentialRegistry, OAuthGrantSchema, OAuthTokensSchema, StoredCookieSchema, staticSecretSchema, validateCredential, } from "./credentials/registry.js";
|
|
16
16
|
export { CredentialTransport } from "./credentials/transport.js";
|
|
17
17
|
export { createPluginAPI, isPluginFunction, resolvePluginExport, } from "./plugin/api.js";
|
|
18
18
|
export { installPlugin, listInstalled, parsePluginSource, removePlugin, } from "./plugin/installer.js";
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import * as t from "typebox";
|
|
3
|
+
import { DownloadedFile, standardBase64 } from "./base64.js";
|
|
4
|
+
import { readLocalFile } from "./localFile.js";
|
|
5
|
+
const STRICT = { additionalProperties: false };
|
|
6
|
+
const id = t.String({ minLength: 1, pattern: "\\S" });
|
|
7
|
+
/** What any attachment may say about itself, whatever its bytes are. */
|
|
8
|
+
export const attachmentMetadata = {
|
|
9
|
+
name: t.Optional(t.String({ ...id, description: "File name; defaults to the path's name." })),
|
|
10
|
+
mimeType: t.Optional(t.String({ ...id, description: "MIME type, e.g. application/pdf." })),
|
|
11
|
+
inline: t.Optional(t.Boolean({
|
|
12
|
+
description: "Embed in the HTML body, referenced as cid:<contentId>, instead of attaching.",
|
|
13
|
+
})),
|
|
14
|
+
contentId: t.Optional(t.String({ ...id, description: "Content-ID for inline images." })),
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* An attachment's bytes, given one way each and named as gmail and
|
|
18
|
+
* googleDrive name them: `contentBase64` (base64, or a
|
|
19
|
+
* googleDrive.file.download result passed straight through),
|
|
20
|
+
* `contentPath` (a file on the runline host) or `content` (UTF-8 text).
|
|
21
|
+
* `sizeNote` states the plugin's own limit.
|
|
22
|
+
*/
|
|
23
|
+
export function attachmentSources(sizeNote) {
|
|
24
|
+
return [
|
|
25
|
+
t.Object({
|
|
26
|
+
...attachmentMetadata,
|
|
27
|
+
contentBase64: t.Union([t.String(), DownloadedFile], {
|
|
28
|
+
description: `Base64 bytes (standard or base64url; padding and whitespace normalized), or a googleDrive.file.download result, whose name and mimeType are used when not given here. ${sizeNote}`,
|
|
29
|
+
}),
|
|
30
|
+
}, STRICT),
|
|
31
|
+
t.Object({
|
|
32
|
+
...attachmentMetadata,
|
|
33
|
+
contentPath: t.String({
|
|
34
|
+
...id,
|
|
35
|
+
description: `A regular file on the runline host, read at call time. ${sizeNote}`,
|
|
36
|
+
}),
|
|
37
|
+
}, STRICT),
|
|
38
|
+
t.Object({
|
|
39
|
+
...attachmentMetadata,
|
|
40
|
+
content: t.String({
|
|
41
|
+
description: `UTF-8 text content, e.g. a generated CSV. ${sizeNote}`,
|
|
42
|
+
}),
|
|
43
|
+
}, STRICT),
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Bytes given as base64, or as a googleDrive.file.download result, which
|
|
48
|
+
* also supplies a name and type. `subject` names the input in errors
|
|
49
|
+
* (`gmail: attachment 0`).
|
|
50
|
+
*/
|
|
51
|
+
export function base64File(value, subject) {
|
|
52
|
+
const result = typeof value === "string" ? undefined : value;
|
|
53
|
+
const content = standardBase64(typeof value === "string" ? value : value.contentBase64);
|
|
54
|
+
if (content === undefined)
|
|
55
|
+
throw new Error(`${subject} contentBase64 is not valid base64`);
|
|
56
|
+
return {
|
|
57
|
+
bytes: Buffer.from(content, "base64"),
|
|
58
|
+
name: result?.name || undefined,
|
|
59
|
+
mimeType: result?.mimeType || undefined,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* An attachment input's bytes. A download result passed as contentBase64
|
|
64
|
+
* supplies the name and type the input leaves out; a local file is named
|
|
65
|
+
* for its path; text is text/plain. Oversized bytes, invalid base64 and
|
|
66
|
+
* unreadable files are refused here, before any request.
|
|
67
|
+
*/
|
|
68
|
+
export async function attachmentFile(input, index, limits) {
|
|
69
|
+
const subject = `${limits.plugin}: attachment ${index}`;
|
|
70
|
+
let file;
|
|
71
|
+
if ("contentBase64" in input) {
|
|
72
|
+
file = base64File(input.contentBase64, subject);
|
|
73
|
+
}
|
|
74
|
+
else if ("contentPath" in input) {
|
|
75
|
+
file = {
|
|
76
|
+
bytes: await readLocalFile(input.contentPath, {
|
|
77
|
+
subject: `${subject} contentPath`,
|
|
78
|
+
maxBytes: limits.maxBytes,
|
|
79
|
+
oversize: limits.oversize,
|
|
80
|
+
}),
|
|
81
|
+
name: basename(input.contentPath),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
file = {
|
|
86
|
+
bytes: Buffer.from(input.content, "utf8"),
|
|
87
|
+
mimeType: "text/plain",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (file.bytes.length > limits.maxBytes)
|
|
91
|
+
throw new Error(limits.oversize);
|
|
92
|
+
return {
|
|
93
|
+
bytes: file.bytes,
|
|
94
|
+
name: input.name ?? file.name,
|
|
95
|
+
mimeType: input.mimeType ?? file.mimeType,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AuthError, brokerFor, downloadResource, jsonAnswer,
|
|
1
|
+
import { AuthError, brokerFor, downloadResource, jsonAnswer, presignedRequest, responseFailed, } from "runline";
|
|
2
2
|
import { MICROSOFT_GRANT, microsoftCredential, microsoftEndpoint, microsoftUserBase, } from "./microsoftCredentials.js";
|
|
3
3
|
export { microsoftCredential, microsoftDriveBase, } from "./microsoftCredentials.js";
|
|
4
4
|
export function userBase(ctx) {
|
|
@@ -7,7 +7,11 @@ export function userBase(ctx) {
|
|
|
7
7
|
export async function microsoftProbe(ctx, plugin, scopes) {
|
|
8
8
|
return brokerFor(ctx, microsoftCredential(plugin, scopes)).probe();
|
|
9
9
|
}
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* One Graph request under the plugin's credential. `accept` is JSON
|
|
12
|
+
* unless the caller reads raw bytes, such as an attachment's `$value`.
|
|
13
|
+
*/
|
|
14
|
+
export async function graphResponse(ctx, plugin, scopes, method, path, body, contentType, accept = "application/json") {
|
|
11
15
|
if (!path.startsWith("/") || path.startsWith("//"))
|
|
12
16
|
throw new AuthError("request_not_allowed");
|
|
13
17
|
return brokerFor(ctx, microsoftCredential(plugin, scopes)).request({
|
|
@@ -15,12 +19,21 @@ export async function graphResponse(ctx, plugin, scopes, method, path, body, con
|
|
|
15
19
|
path: path.slice(1),
|
|
16
20
|
method: method,
|
|
17
21
|
headers: {
|
|
18
|
-
Accept:
|
|
22
|
+
Accept: accept,
|
|
19
23
|
...(contentType ? { "Content-Type": contentType } : {}),
|
|
20
24
|
},
|
|
21
25
|
body,
|
|
22
26
|
});
|
|
23
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* A failed Graph response, by its status and Graph's `error.code`
|
|
30
|
+
* (`ErrorItemNotFound`, `ErrorAccessDenied`); Graph's message is never read.
|
|
31
|
+
*/
|
|
32
|
+
export function graphFailed(plugin, response) {
|
|
33
|
+
return responseFailed(plugin, response, (body) => ({
|
|
34
|
+
code: body?.error?.code,
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
24
37
|
/**
|
|
25
38
|
* A Graph JSON request that must succeed; its answer is read by
|
|
26
39
|
* `jsonAnswer`. JSON and binary requests share the same destination,
|
|
@@ -29,7 +42,7 @@ export async function graphResponse(ctx, plugin, scopes, method, path, body, con
|
|
|
29
42
|
export async function graphRequest(ctx, plugin, scopes, method, path, body) {
|
|
30
43
|
const res = await graphResponse(ctx, plugin, scopes, method, path, body === undefined ? undefined : JSON.stringify(body), body === undefined ? undefined : "application/json");
|
|
31
44
|
if (!res.ok)
|
|
32
|
-
throw
|
|
45
|
+
throw await graphFailed(plugin, res);
|
|
33
46
|
return jsonAnswer(res);
|
|
34
47
|
}
|
|
35
48
|
/** Graph-issued signed download URLs carry their own authority, never a bearer header. */
|
|
@@ -57,6 +70,22 @@ export async function microsoftDownload(value) {
|
|
|
57
70
|
fetch: globalThis.fetch,
|
|
58
71
|
});
|
|
59
72
|
}
|
|
73
|
+
/** Where Graph's Outlook attachment upload sessions live, as Microsoft documents them. */
|
|
74
|
+
const OUTLOOK_UPLOAD_ORIGIN = "https://outlook.office.com";
|
|
75
|
+
/**
|
|
76
|
+
* One request to an Outlook attachment upload session's `uploadUrl`. The
|
|
77
|
+
* URL carries its own authority, so the request has no bearer token; a
|
|
78
|
+
* URL on any other origin is refused.
|
|
79
|
+
*/
|
|
80
|
+
export function outlookUpload(uploadUrl, init) {
|
|
81
|
+
if (typeof uploadUrl !== "string")
|
|
82
|
+
throw new AuthError("invalid_response");
|
|
83
|
+
return presignedRequest(uploadUrl, {
|
|
84
|
+
allowedOrigins: [OUTLOOK_UPLOAD_ORIGIN],
|
|
85
|
+
fetch: globalThis.fetch,
|
|
86
|
+
...init,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
60
89
|
/**
|
|
61
90
|
* A Microsoft Graph plugin's OAuth declaration: the multi-tenant endpoints,
|
|
62
91
|
* `offline_access` for a refresh token, the grant every Graph plugin
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* At least one of `fields` must be given: "provide a change", "name a
|
|
3
|
+
* recipient". Keyed off the field record itself, so the rule cannot drift
|
|
4
|
+
* from the fields it governs.
|
|
5
|
+
*/
|
|
6
|
+
export function atLeastOne(fields) {
|
|
7
|
+
return { anyOf: Object.keys(fields).map((key) => ({ required: [key] })) };
|
|
8
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as t from "typebox";
|
|
2
|
-
import { atLeastOne
|
|
2
|
+
import { atLeastOne } from "../../_shared/schemas.js";
|
|
3
|
+
import { attachments, clientId, contentFields, draftFields, id, idList, inboxFields, labelFields, listFields, STRICT, sendFields, strings, timestamp, } from "./schemas.js";
|
|
3
4
|
import { attachmentPath, draftPath, inboxPath, request, sendMessage, } from "./shared.js";
|
|
4
5
|
const list = t.Object({ ...inboxFields, ...listFields }, STRICT);
|
|
5
6
|
const draft = t.Object(draftFields, STRICT);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as t from "typebox";
|
|
2
|
-
import { atLeastOne
|
|
2
|
+
import { atLeastOne } from "../../_shared/schemas.js";
|
|
3
|
+
import { clientId, id, inboxFields, pageFields, STRICT } from "./schemas.js";
|
|
3
4
|
import { inboxPath, request } from "./shared.js";
|
|
4
5
|
const metadataValue = t.Union([
|
|
5
6
|
t.String({ maxLength: 256 }),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as t from "typebox";
|
|
2
|
-
import { atLeastOne
|
|
2
|
+
import { atLeastOne } from "../../_shared/schemas.js";
|
|
3
|
+
import { id, inboxFields, labelFields, mailListFields, messageContentFields, messageFields, recipientFields, replyContentFields, STRICT, searchFields, sendFields, strings, } from "./schemas.js";
|
|
3
4
|
import { attachmentPath, inboxPath, messagePath, request, sendMessage, } from "./shared.js";
|
|
4
5
|
const list = t.Object({
|
|
5
6
|
...inboxFields,
|