runline 0.36.0 → 0.38.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.
@@ -35,6 +35,9 @@ export interface AwsCredentialSpec {
35
35
  * `roleDurationSeconds` shape each session request.
36
36
  * - `stsRegion`, else `region`, else `us-east-1`, is where sessions are
37
37
  * requested.
38
+ * - `awsProfile` names an AWS CLI profile (an SSO login) whose key this
39
+ * process signs with in place of a stored one; a host that stores the
40
+ * key ignores it.
38
41
  */
39
42
  export declare function awsCredential(spec: AwsCredentialSpec): CredentialDeclaration;
40
43
  /**
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import * as t from "typebox";
7
7
  import { AuthError } from "../auth/errors.js";
8
- import { awsEndpoint, awsErrorCode, awsRole, parseAwsXml } from "./aws.js";
8
+ import { awsEndpoint, awsErrorCode, awsProfileName, awsRole, parseAwsXml, } from "./aws.js";
9
9
  import { credentialRequest, errorIdentifier, failureMessage, jsonAnswer, } from "./plugin.js";
10
10
  import { AwsAccessKeySchema, AwsSessionSchema } from "./registry.js";
11
11
  const STRICT = { additionalProperties: false };
@@ -57,6 +57,9 @@ function configString(value) {
57
57
  * `roleDurationSeconds` shape each session request.
58
58
  * - `stsRegion`, else `region`, else `us-east-1`, is where sessions are
59
59
  * requested.
60
+ * - `awsProfile` names an AWS CLI profile (an SSO login) whose key this
61
+ * process signs with in place of a stored one; a host that stores the
62
+ * key ignores it.
60
63
  */
61
64
  export function awsCredential(spec) {
62
65
  const key = { credential: AwsAccessKeySchema };
@@ -68,6 +71,8 @@ export function awsCredential(spec) {
68
71
  return (config) => {
69
72
  const targets = spec.targets(config);
70
73
  const probe = spec.probe ? { probe: spec.probe } : {};
74
+ const profile = configString(config.awsProfile);
75
+ const awsProfile = profile === undefined ? undefined : awsProfileName(profile);
71
76
  const roleArn = configString(config.roleArn);
72
77
  const externalId = configString(config.roleExternalId);
73
78
  const durationSeconds = configInteger(config.roleDurationSeconds);
@@ -93,7 +98,7 @@ export function awsCredential(spec) {
93
98
  },
94
99
  },
95
100
  },
96
- localSecret,
101
+ ...(awsProfile === undefined ? { localSecret } : { awsProfile }),
97
102
  ...(roleArn === undefined
98
103
  ? { method: "accessKey" }
99
104
  : {
@@ -14,6 +14,17 @@ export declare function awsEndpoint(service: string, region: string): string;
14
14
  * signature. Anything else is refused before any request.
15
15
  */
16
16
  export declare function awsAccessKey(value: unknown): AwsAccessKey;
17
+ /** A profile name from config; anything else is refused before the CLI runs. */
18
+ export declare function awsProfileName(value: unknown): string;
19
+ /**
20
+ * The access key of an AWS CLI profile, as the CLI resolves it: an SSO
21
+ * login, an assumed role, or a key in `~/.aws/credentials`, through
22
+ * `aws configure export-credentials` (the `credential_process` contract).
23
+ * Held until five minutes before it expires; however many callers find
24
+ * it missing or stale, one CLI run serves them all, and a failed run is
25
+ * not held.
26
+ */
27
+ export declare function awsProfileKey(name: string): Promise<AwsAccessKey>;
17
28
  /** A role as STS accepts it; anything else is refused before any request. */
18
29
  export declare function awsRole(role: AwsRole): AwsRole;
19
30
  /**
@@ -1,3 +1,4 @@
1
+ import { execFile } from "node:child_process";
1
2
  import { XMLParser } from "fast-xml-parser";
2
3
  import { AuthError } from "../auth/errors.js";
3
4
  import { sendResource } from "./http.js";
@@ -55,6 +56,110 @@ export function awsAccessKey(value) {
55
56
  : { sessionToken: secret(key.sessionToken) }),
56
57
  };
57
58
  }
59
+ /** An AWS CLI profile name, as `~/.aws/config` section names allow it. */
60
+ const PROFILE = /^[\w+=,.@-]{1,64}$/;
61
+ /** A profile name from config; anything else is refused before the CLI runs. */
62
+ export function awsProfileName(value) {
63
+ if (typeof value !== "string" || !PROFILE.test(value))
64
+ throw new AuthError("invalid_credentials");
65
+ return value;
66
+ }
67
+ /** A profile's key is used until this long before it expires. */
68
+ const PROFILE_KEY_MARGIN_MS = 5 * 60_000;
69
+ /**
70
+ * Each profile's key as this process holds it: settled while it lasts,
71
+ * or still in flight, so concurrent callers share one CLI run.
72
+ */
73
+ const profileKeys = new Map();
74
+ /** A key a provider issued: one that cannot sign is a malformed answer. */
75
+ function issuedKey(value) {
76
+ try {
77
+ return awsAccessKey(value);
78
+ }
79
+ catch {
80
+ throw new AuthError("invalid_response");
81
+ }
82
+ }
83
+ /**
84
+ * Why the CLI produced no key: it is not installed, it ran out of time,
85
+ * it answered more than a key, or it refused (no login, or an expired
86
+ * SSO session, which `aws sso login` mends).
87
+ */
88
+ function cliFailure(error) {
89
+ if (error.code === "ENOENT")
90
+ return new AuthError("invalid_credentials");
91
+ if (error.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER")
92
+ return new AuthError("invalid_response");
93
+ if (error.killed)
94
+ return new AuthError("request_failed");
95
+ return new AuthError("reconnect_required");
96
+ }
97
+ /** One CLI run: the profile's key, as `credential_process` defines it. */
98
+ async function exportProfileKey(profile) {
99
+ const stdout = await new Promise((resolve, reject) => {
100
+ execFile("aws", [
101
+ "configure",
102
+ "export-credentials",
103
+ "--profile",
104
+ profile,
105
+ "--format",
106
+ "process",
107
+ ], { timeout: 30_000, maxBuffer: 64 * 1024 }, (error, out) => (error ? reject(cliFailure(error)) : resolve(out)));
108
+ });
109
+ let exported;
110
+ try {
111
+ exported = JSON.parse(stdout);
112
+ }
113
+ catch {
114
+ throw new AuthError("invalid_response");
115
+ }
116
+ const expiresAt = exported.Expiration === undefined
117
+ ? undefined
118
+ : Date.parse(String(exported.Expiration));
119
+ if (exported.Version !== 1 ||
120
+ (expiresAt !== undefined && !Number.isSafeInteger(expiresAt)))
121
+ throw new AuthError("invalid_response");
122
+ return {
123
+ key: issuedKey({
124
+ accessKeyId: exported.AccessKeyId,
125
+ secretAccessKey: exported.SecretAccessKey,
126
+ ...(exported.SessionToken === undefined
127
+ ? {}
128
+ : { sessionToken: exported.SessionToken }),
129
+ }),
130
+ expiresAt,
131
+ };
132
+ }
133
+ /**
134
+ * The access key of an AWS CLI profile, as the CLI resolves it: an SSO
135
+ * login, an assumed role, or a key in `~/.aws/credentials`, through
136
+ * `aws configure export-credentials` (the `credential_process` contract).
137
+ * Held until five minutes before it expires; however many callers find
138
+ * it missing or stale, one CLI run serves them all, and a failed run is
139
+ * not held.
140
+ */
141
+ export async function awsProfileKey(name) {
142
+ const profile = awsProfileName(name);
143
+ for (;;) {
144
+ const held = profileKeys.get(profile);
145
+ if (!held)
146
+ break;
147
+ const { key, expiresAt } = await held;
148
+ if (expiresAt === undefined ||
149
+ expiresAt > Date.now() + PROFILE_KEY_MARGIN_MS)
150
+ return key;
151
+ // Stale: refresh it, unless another caller already started to.
152
+ if (profileKeys.get(profile) === held)
153
+ break;
154
+ }
155
+ const exporting = exportProfileKey(profile);
156
+ profileKeys.set(profile, exporting);
157
+ exporting.catch(() => {
158
+ if (profileKeys.get(profile) === exporting)
159
+ profileKeys.delete(profile);
160
+ });
161
+ return (await exporting).key;
162
+ }
58
163
  /** `arn:<partition>:iam::<account>:role/<path/name>`, as IAM allows it. */
59
164
  const ROLE_ARN = /^arn:aws(?:-[a-z]+)*:iam::\d{12}:role\/[\w+=,.@/-]{1,512}$/;
60
165
  const SESSION_NAME = /^[\w+=,.@-]{2,64}$/;
@@ -193,17 +298,11 @@ export async function assumeAwsRole(key, role, options) {
193
298
  !Number.isSafeInteger(expiresAt) ||
194
299
  expiresAt <= now)
195
300
  throw new AuthError("invalid_response");
196
- let session;
197
- try {
198
- session = awsAccessKey({
199
- accessKeyId: result.AccessKeyId,
200
- secretAccessKey: result.SecretAccessKey,
201
- sessionToken: result.SessionToken,
202
- });
203
- }
204
- catch {
205
- throw new AuthError("invalid_response");
206
- }
301
+ const session = issuedKey({
302
+ accessKeyId: result.AccessKeyId,
303
+ secretAccessKey: result.SecretAccessKey,
304
+ sessionToken: result.SessionToken,
305
+ });
207
306
  return {
208
307
  accessKeyId: session.accessKeyId,
209
308
  secretAccessKey: session.secretAccessKey,
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { AuthError } from "../auth/errors.js";
3
+ import { awsProfileKey } from "./aws.js";
3
4
  import { CredentialRegistry } from "./registry.js";
4
5
  import { CredentialTransport } from "./transport.js";
5
6
  /**
@@ -122,7 +123,7 @@ function localSigner(ctx, declaration) {
122
123
  const auth = definition.methods[method].authentication;
123
124
  const identity = { name: ctx.connection.name, plugin: ctx.connection.plugin };
124
125
  const expected = authorityOf(selection);
125
- const project = (current) => {
126
+ const project = async (current) => {
126
127
  if (authorityOf(declaration(current)) !== expected)
127
128
  throw new AuthError("binding_changed");
128
129
  if (auth.kind === "none")
@@ -132,9 +133,9 @@ function localSigner(ctx, declaration) {
132
133
  return secret ? { [auth.field]: secret } : {};
133
134
  }
134
135
  if (auth.kind === "awsSigV4") {
135
- const key = staticSecret(current, selection.localSecret, [
136
- "sessionToken",
137
- ]);
136
+ const key = selection.awsProfile
137
+ ? await awsProfileKey(selection.awsProfile)
138
+ : staticSecret(current, selection.localSecret, ["sessionToken"]);
138
139
  const cached = current.awsSession;
139
140
  const sessionField = auth.assumeRole?.sessionField;
140
141
  return {
@@ -178,9 +179,9 @@ function localSigner(ctx, declaration) {
178
179
  : createHash("sha256").update(JSON.stringify(tokens)).digest("hex");
179
180
  return { [auth.field]: { tokens, revision } };
180
181
  };
181
- const snapshot = () => ({
182
+ const snapshot = async () => ({
182
183
  ...identity,
183
- config: project(ctx.connection.config),
184
+ config: await project(ctx.connection.config),
184
185
  });
185
186
  const binding = {
186
187
  type: definition.id,
@@ -199,7 +200,7 @@ function localSigner(ctx, declaration) {
199
200
  async update(change) {
200
201
  const flatten = writeBack(auth, expected);
201
202
  await ctx.updateConnection(async (current) => {
202
- const projected = project(current);
203
+ const projected = await project(current);
203
204
  const patch = typeof change === "function" ? await change(projected) : change;
204
205
  return patch ? flatten(patch, current) : undefined;
205
206
  });
@@ -7,11 +7,25 @@ export interface ResourceOptions {
7
7
  }
8
8
  /** Internal send primitive. Callers validate destination policy before reaching it. */
9
9
  export declare function sendResource(url: string, method: HttpMethod, headers: Headers, body: Buffer | undefined, options: ResourceOptions): Promise<Response>;
10
+ export interface PresignedRequestOptions {
11
+ /** Exact origins the URL may be on; anything else is refused. */
12
+ allowedOrigins: string[];
13
+ fetch: typeof globalThis.fetch;
14
+ method?: "GET" | "PUT" | "DELETE";
15
+ /** Request headers; credentials and cookies are never among them. */
16
+ headers?: Record<string, string>;
17
+ body?: Uint8Array;
18
+ maxResponseBytes?: number;
19
+ }
10
20
  /**
11
- * Fetch a provider-issued preauthenticated URL without bearer tokens or cookies.
12
- * Hosts approve exact origins and enforce DNS/IP egress through their fetch hook.
13
- * The signed URL is itself secret: errors never include it. No redirects or retries.
21
+ * One request to a provider-issued preauthenticated URL — a signed
22
+ * download, an upload session — without bearer tokens or cookies.
23
+ * Hosts approve exact origins and enforce DNS/IP egress through their
24
+ * fetch hook. The signed URL is itself secret: errors never include it.
25
+ * No redirects or retries.
14
26
  */
27
+ export declare function presignedRequest(value: string, options: PresignedRequestOptions): Promise<Response>;
28
+ /** A GET of a provider-issued preauthenticated URL; see presignedRequest. */
15
29
  export declare function downloadResource(value: string, options: {
16
30
  allowedOrigins: string[];
17
31
  fetch: typeof globalThis.fetch;
@@ -78,11 +78,19 @@ export async function sendResource(url, method, headers, body, options) {
78
78
  }
79
79
  }
80
80
  /**
81
- * Fetch a provider-issued preauthenticated URL without bearer tokens or cookies.
82
- * Hosts approve exact origins and enforce DNS/IP egress through their fetch hook.
83
- * The signed URL is itself secret: errors never include it. No redirects or retries.
81
+ * Headers a presigned request never sends: the URL is its only authority,
82
+ * so no credential or cookie rides along, and Destination belongs to the
83
+ * credential transport's COPY and MOVE.
84
84
  */
85
- export async function downloadResource(value, options) {
85
+ const PRESIGNED_REFUSED_HEADERS = ["authorization", "cookie", "destination"];
86
+ /**
87
+ * One request to a provider-issued preauthenticated URL — a signed
88
+ * download, an upload session — without bearer tokens or cookies.
89
+ * Hosts approve exact origins and enforce DNS/IP egress through their
90
+ * fetch hook. The signed URL is itself secret: errors never include it.
91
+ * No redirects or retries.
92
+ */
93
+ export async function presignedRequest(value, options) {
86
94
  let url;
87
95
  try {
88
96
  url = new URL(value);
@@ -101,9 +109,23 @@ export async function downloadResource(value, options) {
101
109
  maxResponseBytes <= 0 ||
102
110
  maxResponseBytes > 64 * 1024 * 1024)
103
111
  throw new AuthError("invalid_definition");
104
- return sendResource(url.toString(), "GET", new Headers(), undefined, {
105
- fetch: options.fetch,
106
- timeoutMs: 20_000,
107
- maxResponseBytes,
108
- });
112
+ const method = options.method ?? "GET";
113
+ const headers = new Headers();
114
+ try {
115
+ for (const [name, value] of Object.entries(options.headers ?? {})) {
116
+ if (PRESIGNED_REFUSED_HEADERS.includes(name.toLowerCase()))
117
+ throw new Error();
118
+ headers.set(name, value);
119
+ }
120
+ if (options.body !== undefined && method !== "PUT")
121
+ throw new Error();
122
+ }
123
+ catch {
124
+ throw new AuthError("request_not_allowed");
125
+ }
126
+ return sendResource(url.toString(), method, headers, options.body === undefined ? undefined : Buffer.from(options.body), { fetch: options.fetch, timeoutMs: 20_000, maxResponseBytes });
127
+ }
128
+ /** A GET of a provider-issued preauthenticated URL; see presignedRequest. */
129
+ export function downloadResource(value, options) {
130
+ return presignedRequest(value, options);
109
131
  }
@@ -244,7 +244,11 @@ export class CredentialTransport {
244
244
  try {
245
245
  snapshot = await binding.connection.read();
246
246
  }
247
- catch {
247
+ catch (error) {
248
+ // A store's own error can carry anything; an AuthError is already
249
+ // sanitized and says what is wrong with the credential itself.
250
+ if (error instanceof AuthError)
251
+ throw error;
248
252
  throw new AuthError("credential_store_failed");
249
253
  }
250
254
  return boundSnapshot(binding, method, snapshot);
@@ -225,6 +225,13 @@ export interface CredentialSelection {
225
225
  resourceOwner?: OAuthResourceOwner;
226
226
  /** The role an `assumeRole` method's sessions are issued for. */
227
227
  awsRole?: AwsRole;
228
+ /**
229
+ * The AWS CLI profile whose credentials an `awsSigV4` method's key
230
+ * comes from, read through the CLI when this process signs with its
231
+ * own connection: an SSO login, with no key stored anywhere. Hosts that
232
+ * store the key ignore it, as they ignore `localSecret`.
233
+ */
234
+ awsProfile?: string;
228
235
  /**
229
236
  * How a process signing with its own flat connection config (the CLI)
230
237
  * assembles a static secret: each of its parts, from a named config
package/dist/index.d.ts CHANGED
@@ -16,7 +16,7 @@ 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
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
22
  export { AwsAccessKeySchema, AwsSessionSchema, CredentialRegistry, OAuthGrantSchema, OAuthTokensSchema, staticSecretSchema, validateCredential, } from "./credentials/registry.js";
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ 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";
13
+ export { downloadResource, presignedRequest, } from "./credentials/http.js";
14
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
15
  export { AwsAccessKeySchema, AwsSessionSchema, CredentialRegistry, OAuthGrantSchema, OAuthTokensSchema, staticSecretSchema, validateCredential, } from "./credentials/registry.js";
16
16
  export { CredentialTransport } from "./credentials/transport.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, requestFailed, } from "runline";
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
- export async function graphResponse(ctx, plugin, scopes, method, path, body, contentType) {
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: "application/json",
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 requestFailed(plugin, res.status);
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, attachments, clientId, contentFields, draftFields, id, idList, inboxFields, labelFields, listFields, STRICT, sendFields, strings, timestamp, } from "./schemas.js";
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, clientId, id, inboxFields, pageFields, STRICT, } from "./schemas.js";
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, id, inboxFields, labelFields, mailListFields, messageContentFields, messageFields, recipientFields, replyContentFields, STRICT, searchFields, sendFields, strings, } from "./schemas.js";
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,
@@ -1,16 +1,8 @@
1
1
  import * as t from "typebox";
2
- import { DownloadedFile } from "../../_shared/base64.js";
2
+ import { attachmentMetadata, attachmentSources, } from "../../_shared/attachments.js";
3
3
  export const STRICT = { additionalProperties: false };
4
4
  export const MAX_REQUEST_BYTES = 6_000_000;
5
5
  export const id = t.String({ minLength: 1, pattern: "\\S" });
6
- /**
7
- * At least one of `fields` must be given: "provide a change", "name a
8
- * recipient". Keyed off the field record itself, so the rule cannot drift
9
- * from the fields it governs.
10
- */
11
- export function atLeastOne(fields) {
12
- return { anyOf: Object.keys(fields).map((key) => ({ required: [key] })) };
13
- }
14
6
  export const inboxFields = {
15
7
  inboxId: t.String({
16
8
  ...id,
@@ -82,40 +74,11 @@ export const sendFields = {
82
74
  })),
83
75
  };
84
76
  /**
85
- * Attachment bytes come one way each, named as gmail and googleDrive name
86
- * them: `contentBase64`, `contentPath`, `content` (UTF-8 text), plus
87
- * AgentMail's own `url`. A googleDrive download result passes straight
88
- * through as `contentBase64`.
77
+ * Attachment bytes come one way each, as every mail plugin takes them,
78
+ * plus AgentMail's own `url`, which AgentMail fetches itself.
89
79
  */
90
- const attachmentMetadata = {
91
- name: t.Optional(t.String({ ...id, description: "File name; defaults to the path's name." })),
92
- mimeType: t.Optional(t.String({ ...id, description: "MIME type, e.g. application/pdf." })),
93
- inline: t.Optional(t.Boolean({
94
- description: "Embed in the HTML body, referenced as cid:<contentId>, instead of attaching.",
95
- })),
96
- contentId: t.Optional(t.String({ ...id, description: "Content-ID for inline images." })),
97
- };
98
- const SIZE_NOTE = "The entire serialized request is limited to 6 MB; use url for larger files.";
99
80
  export const attachment = t.Union([
100
- t.Object({
101
- ...attachmentMetadata,
102
- contentBase64: t.Union([t.String(), DownloadedFile], {
103
- 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. ${SIZE_NOTE}`,
104
- }),
105
- }, STRICT),
106
- t.Object({
107
- ...attachmentMetadata,
108
- contentPath: t.String({
109
- ...id,
110
- description: `A regular file on the runline host, read at call time. ${SIZE_NOTE}`,
111
- }),
112
- }, STRICT),
113
- t.Object({
114
- ...attachmentMetadata,
115
- content: t.String({
116
- description: `UTF-8 text content, e.g. a generated CSV. ${SIZE_NOTE}`,
117
- }),
118
- }, STRICT),
81
+ ...attachmentSources("The entire serialized request is limited to 6 MB; use url for larger files."),
119
82
  t.Object({
120
83
  ...attachmentMetadata,
121
84
  url: t.String({