runline 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/core/engine.js +12 -8
  2. package/dist/credentials/registry.js +5 -0
  3. package/dist/credentials/types.d.ts +10 -0
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.js +1 -0
  6. package/dist/plugin/schema.d.ts +16 -4
  7. package/dist/plugin/schema.js +28 -5
  8. package/dist/plugin/types.d.ts +8 -0
  9. package/dist/plugins/_shared/base64.js +30 -0
  10. package/dist/plugins/_shared/credentialAdapter.js +23 -3
  11. package/dist/plugins/_shared/credentials.js +29 -2
  12. package/dist/plugins/_shared/googleAuth.js +62 -1
  13. package/dist/plugins/_shared/googleCredentials.js +40 -7
  14. package/dist/plugins/_shared/localFile.js +40 -0
  15. package/dist/plugins/_shared/mediaFile.js +12 -12
  16. package/dist/plugins/_shared/microsoftAuth.js +19 -4
  17. package/dist/plugins/_shared/microsoftCredentials.js +12 -7
  18. package/dist/plugins/_shared/shiftCloud.js +14 -18
  19. package/dist/plugins/_shared/shiftUpload.js +14 -10
  20. package/dist/plugins/agentMail/src/credentials.js +35 -0
  21. package/dist/plugins/agentMail/src/drafts.js +137 -0
  22. package/dist/plugins/agentMail/src/inboxes.js +95 -0
  23. package/dist/plugins/agentMail/src/index.js +35 -0
  24. package/dist/plugins/agentMail/src/messages.js +154 -0
  25. package/dist/plugins/agentMail/src/schemas.js +156 -0
  26. package/dist/plugins/agentMail/src/shared.js +203 -0
  27. package/dist/plugins/agentMail/src/threads.js +75 -0
  28. package/dist/plugins/elevenlabs/src/shared.js +29 -43
  29. package/dist/plugins/fal/src/index.js +1 -1
  30. package/dist/plugins/gmail/src/index.js +22 -85
  31. package/dist/plugins/googleAppsScript/src/index.js +2 -1
  32. package/dist/plugins/googleCalendar/src/index.js +6 -39
  33. package/dist/plugins/googleContacts/src/index.js +2 -32
  34. package/dist/plugins/googleDocs/src/index.js +2 -33
  35. package/dist/plugins/googleDrive/src/index.js +19 -45
  36. package/dist/plugins/googleImage/src/index.js +1 -1
  37. package/dist/plugins/googleSheets/src/index.js +2 -34
  38. package/dist/plugins/googleSlides/src/index.js +2 -32
  39. package/dist/plugins/googleTasks/src/index.js +2 -32
  40. package/dist/plugins/highlevel/src/index.js +8 -2
  41. package/dist/plugins/microsoftCalendar/src/index.js +2 -7
  42. package/dist/plugins/microsoftFiles/src/index.js +7 -8
  43. package/dist/plugins/microsoftMail/src/index.js +2 -7
  44. package/dist/plugins/openai/src/index.js +1 -1
  45. package/dist/plugins/recraft/src/index.js +1 -1
  46. package/dist/plugins/replicate/src/index.js +1 -1
  47. package/dist/plugins/shiftObjects/src/objects.js +1 -1
  48. package/dist/plugins/shiftOcr/src/index.js +8 -3
  49. package/dist/plugins/shiftTranscription/src/transcription.js +1 -1
  50. package/dist/plugins/together/src/index.js +1 -1
  51. package/dist/plugins/xai/src/index.js +1 -1
  52. package/package.json +2 -1
  53. package/dist/tests/helpers/engine-harness.d.ts +0 -1
  54. package/dist/tests/helpers/engine-harness.js +0 -236
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { createRequire } from "node:module";
3
3
  import { Worker } from "node:worker_threads";
4
4
  import { MemoryConnectionProvider } from "../connections/memory.js";
5
- import { formatValidationError, helpInputs, isTypedInputSchema, normalizeActionInput, validateTypedInput, } from "../plugin/schema.js";
5
+ import { dropUndefined, formatValidationError, helpInputs, isTypedInputSchema, normalizeActionInput, validateTypedInput, } from "../plugin/schema.js";
6
6
  import { notifyObserver } from "../utils/observer.js";
7
7
  /**
8
8
  * How many bodies one worker may run before it is retired.
@@ -609,6 +609,14 @@ const { FerroSearch } = require(${JSON.stringify(ferrosearchPath)});
609
609
  // The host's own validator, for actions.check on typed schemas.
610
610
  const { Check: __schemaCheck, Errors: __schemaErrors } = require(${JSON.stringify(typeboxValuePath)});
611
611
 
612
+ // The host's own normalizeActionInput, by its source, so a check sees the
613
+ // arguments exactly as validation and execute do. The closure keeps the
614
+ // helper names it calls out of the scope plugin globals are declared in.
615
+ const __normalizeActionInput = (() => {
616
+ ${dropUndefined.toString()}
617
+ return ${normalizeActionInput.toString()};
618
+ })();
619
+
612
620
  const __help = ${JSON.stringify(helpData)};
613
621
 
614
622
  const __makeProxy = (path = []) => new Proxy(() => undefined, {
@@ -783,14 +791,10 @@ const __actionsApi = {
783
791
  return { ok: false, error: 'Unknown action: ' + path, suggestions: near };
784
792
  }
785
793
  const signature = __formatSignature(hit.plugin, hit.entry);
786
- if (hit.entry.schema) {
787
- // Mirror the host's normalizeActionInput: a check with no args is
788
- // a call with an empty argument set; anything else — null, a
789
- // scalar — is validated as given, exactly as execute would.
790
- return __checkTyped(hit.entry, args === undefined ? {} : args, signature);
791
- }
794
+ const input = __normalizeActionInput(args);
795
+ if (hit.entry.schema) return __checkTyped(hit.entry, input, signature);
792
796
  const inputs = hit.entry.inputs || {};
793
- const provided = args && typeof args === 'object' ? args : {};
797
+ const provided = input && typeof input === 'object' ? input : {};
794
798
  const missing = [];
795
799
  const unknown = [];
796
800
  const typeErrors = [];
@@ -165,6 +165,11 @@ function validateMethod(method) {
165
165
  if (auth.rejectionStatus !== undefined &&
166
166
  ![401, 403].includes(auth.rejectionStatus))
167
167
  throw new AuthError("invalid_definition");
168
+ if (auth.grant !== undefined) {
169
+ identifier(auth.grant);
170
+ if (auth.renewal !== "refresh")
171
+ throw new AuthError("invalid_definition");
172
+ }
168
173
  if (auth.scopes &&
169
174
  (!Array.isArray(auth.scopes) ||
170
175
  auth.scopes.some((scope) => typeof scope !== "string" || !scope)))
@@ -152,6 +152,16 @@ export type CredentialAuthentication = {
152
152
  /** Explicit renewal strategy, never inferred from available secrets. */
153
153
  renewal: "refresh" | "clientCredentials" | "jwtBearer" | "password";
154
154
  scopes?: string[];
155
+ /**
156
+ * The grant a refresh-renewed token belongs to, shared by every
157
+ * plugin declaring the same id. Its access tokens depend on the
158
+ * grant's client and token endpoint, not on the plugin that asked:
159
+ * the provider answers a refresh with every scope the grant holds
160
+ * (Google requests none; Microsoft returns every scope consented for
161
+ * the resource). One cached token then serves each of its plugins.
162
+ * Only with `renewal: "refresh"`.
163
+ */
164
+ grant?: string;
155
165
  /** Defaults to 401. A 403 must be explicitly documented by the provider. */
156
166
  rejectionStatus?: 401 | 403;
157
167
  };
package/dist/index.d.ts CHANGED
@@ -23,6 +23,7 @@ export type { InstalledPlugin, PluginSource } from "./plugin/installer.js";
23
23
  export { installPlugin, listInstalled, parsePluginSource, removePlugin, } from "./plugin/installer.js";
24
24
  export { discoverPlugins, loadAllPlugins, loadPluginFromPath, loadPluginsFromConfig, } from "./plugin/loader.js";
25
25
  export { PluginRegistry, registry } from "./plugin/registry.js";
26
+ export { connectionFields } from "./plugin/schema.js";
26
27
  export type { ActionAccess, ActionContext, ActionDef, ConnectionConfig, ConnectionSchema, ConnectionSchemaField, InputField, InputSchema, LegacyConnectionSchema, OAuthConfig, PluginDef, } from "./plugin/types.js";
27
28
  export type { RunlineExecuteOptions, RunlineOptions } from "./sdk.js";
28
29
  export { Runline } from "./sdk.js";
package/dist/index.js CHANGED
@@ -14,5 +14,6 @@ export { createPluginAPI, isPluginFunction, resolvePluginExport, } from "./plugi
14
14
  export { installPlugin, listInstalled, parsePluginSource, removePlugin, } from "./plugin/installer.js";
15
15
  export { discoverPlugins, loadAllPlugins, loadPluginFromPath, loadPluginsFromConfig, } from "./plugin/loader.js";
16
16
  export { PluginRegistry, registry } from "./plugin/registry.js";
17
+ export { connectionFields } from "./plugin/schema.js";
17
18
  export { Runline } from "./sdk.js";
18
19
  export { commandExists, syncExec } from "./utils/cli.js";
@@ -16,13 +16,25 @@ export declare function connectionFields(schema: ConnectionSchema | undefined):
16
16
  export declare function helpInputs(schema: InputSchema | undefined): Record<string, HelpInput>;
17
17
  export declare function validateLegacyInput(schema: LegacyInputSchema, input: unknown): ValidationResult;
18
18
  /**
19
- * A call written with no arguments is a call with an empty argument set.
19
+ * A value as JSON would carry it: plain-object keys whose value is
20
+ * `undefined` are dropped, at every depth. JSON has no `undefined`, and a
21
+ * schema's `required` counts a present key as given, so `{ name: undefined }`
22
+ * would otherwise satisfy a rule that needs a name. Arrays keep their
23
+ * length; other objects (a Buffer, a Date) pass through untouched.
20
24
  *
25
+ * Self-contained, because the worker's `actions.check` runs this same
26
+ * source: preflight and execution cannot disagree about what was passed.
27
+ */
28
+ export declare function dropUndefined(value: unknown): unknown;
29
+ /**
30
+ * An action's arguments as validation and `execute` see them.
31
+ *
32
+ * A call written with no arguments is a call with an empty argument set:
21
33
  * `linear.user.me()` supplies no argument, so object validation needs an
22
34
  * empty object to accept no-input actions and name missing required fields.
23
- *
24
- * Only `undefined` is normalized: an explicit `null` or `42` is an
25
- * argument, and a wrong one.
35
+ * An explicit `null` or `42` is an argument, and a wrong one. Keys holding
36
+ * `undefined` are absent, by `dropUndefined`. The worker's `actions.check`
37
+ * runs this source too, so it may call nothing but `dropUndefined`.
26
38
  */
27
39
  export declare function normalizeActionInput(input: unknown): unknown;
28
40
  export declare function validateTypedInput(schema: TypedInputSchema, rawInput: unknown): ValidationResult;
@@ -187,16 +187,39 @@ export function validateLegacyInput(schema, input) {
187
187
  return validationResult({ missing, unknown, typeErrors });
188
188
  }
189
189
  /**
190
- * A call written with no arguments is a call with an empty argument set.
190
+ * A value as JSON would carry it: plain-object keys whose value is
191
+ * `undefined` are dropped, at every depth. JSON has no `undefined`, and a
192
+ * schema's `required` counts a present key as given, so `{ name: undefined }`
193
+ * would otherwise satisfy a rule that needs a name. Arrays keep their
194
+ * length; other objects (a Buffer, a Date) pass through untouched.
191
195
  *
196
+ * Self-contained, because the worker's `actions.check` runs this same
197
+ * source: preflight and execution cannot disagree about what was passed.
198
+ */
199
+ export function dropUndefined(value) {
200
+ if (Array.isArray(value))
201
+ return value.map((entry) => dropUndefined(entry));
202
+ if (value === null || typeof value !== "object")
203
+ return value;
204
+ const prototype = Object.getPrototypeOf(value);
205
+ if (prototype !== Object.prototype && prototype !== null)
206
+ return value;
207
+ return Object.fromEntries(Object.entries(value)
208
+ .filter(([, entry]) => entry !== undefined)
209
+ .map(([key, entry]) => [key, dropUndefined(entry)]));
210
+ }
211
+ /**
212
+ * An action's arguments as validation and `execute` see them.
213
+ *
214
+ * A call written with no arguments is a call with an empty argument set:
192
215
  * `linear.user.me()` supplies no argument, so object validation needs an
193
216
  * empty object to accept no-input actions and name missing required fields.
194
- *
195
- * Only `undefined` is normalized: an explicit `null` or `42` is an
196
- * argument, and a wrong one.
217
+ * An explicit `null` or `42` is an argument, and a wrong one. Keys holding
218
+ * `undefined` are absent, by `dropUndefined`. The worker's `actions.check`
219
+ * runs this source too, so it may call nothing but `dropUndefined`.
197
220
  */
198
221
  export function normalizeActionInput(input) {
199
- return input === undefined ? {} : input;
222
+ return input === undefined ? {} : dropUndefined(input);
200
223
  }
201
224
  export function validateTypedInput(schema, rawInput) {
202
225
  const input = normalizeActionInput(rawInput);
@@ -108,6 +108,14 @@ export type OAuthConfig = OAuthSetupOptions & ({
108
108
  interface OAuthSetupOptions {
109
109
  /** Scopes to request on the consent screen. */
110
110
  scopes: string[];
111
+ /**
112
+ * The grant this plugin's consent belongs to, shared by every plugin
113
+ * declaring the same id (`google`, `microsoft`): one provider, one OAuth
114
+ * client, one person's consent. A host may hold one connection for the
115
+ * whole grant and ask for the union of its plugins' scopes. The plugin's
116
+ * credential declares the same id, so its tokens are the grant's.
117
+ */
118
+ grant?: string;
111
119
  /**
112
120
  * Provider-fixed loopback callback (`http://localhost:<port>/<path>`),
113
121
  * replacing Runline's default `http://127.0.0.1:47823/callback`. Use only
@@ -0,0 +1,30 @@
1
+ import * as t from "typebox";
2
+ /**
3
+ * Caller-supplied base64 in the standard padded alphabet: whitespace
4
+ * dropped, base64url's `-` and `_` mapped to `+` and `/`, padding
5
+ * restored. So a gmail.message.getAttachment result (base64url, unpadded)
6
+ * and a googleDrive download (standard) are equally accepted. Undefined
7
+ * when the value is not base64: a character outside both alphabets,
8
+ * padding anywhere but the end, or a length no encoding produces.
9
+ *
10
+ * Flat character-class patterns only: values run to many megabytes.
11
+ */
12
+ export function standardBase64(value) {
13
+ const compact = value.replace(/\s+/g, "").replace(/={1,2}$/, "");
14
+ if (!/^[A-Za-z0-9+/_-]*$/.test(compact) || compact.length % 4 === 1)
15
+ return undefined;
16
+ const standard = compact.replace(/-/g, "+").replace(/_/g, "/");
17
+ return standard.padEnd(Math.ceil(standard.length / 4) * 4, "=");
18
+ }
19
+ /**
20
+ * A googleDrive.file.download result, accepted wherever attachment bytes
21
+ * are: exactly its fields, so the result passes through unedited while
22
+ * anything else is still refused. Its `name` and `mimeType` stand in for
23
+ * an attachment's own when those are left out.
24
+ */
25
+ export const DownloadedFile = t.Object({
26
+ contentBase64: t.String(),
27
+ name: t.Optional(t.String()),
28
+ mimeType: t.Optional(t.String()),
29
+ size: t.Optional(t.Integer({ minimum: 0 })),
30
+ }, { additionalProperties: false });
@@ -16,10 +16,30 @@ export function credentialBroker(ctx, declaration) {
16
16
  socketUrl: (input) => transport.socketUrl(binding, input),
17
17
  };
18
18
  }
19
- /** Everything a stored token was issued under. A config whose selection
20
- * hashes differently can no longer use tokens issued for this one. */
19
+ /**
20
+ * Everything a stored token was issued under. A config whose selection
21
+ * hashes differently can no longer use tokens issued for this one. A
22
+ * token of a declared grant was issued under the grant — its method,
23
+ * client and token endpoint (Microsoft's names the tenant) — not the
24
+ * plugin that asked, so every plugin of the grant hashes alike and shares
25
+ * it; any other token is the whole selection's, scopes and targets included.
26
+ */
21
27
  function authorityOf(selection) {
22
- return createHash("sha256").update(JSON.stringify(selection)).digest("hex");
28
+ const { type, method, application } = selection;
29
+ const auth = Object.hasOwn(type.methods, method)
30
+ ? type.methods[method].authentication
31
+ : undefined;
32
+ const issuedUnder = auth?.kind === "oauth2" && auth.grant !== undefined
33
+ ? {
34
+ grant: auth.grant,
35
+ method,
36
+ application,
37
+ endpoint: auth.definition.refresh,
38
+ }
39
+ : selection;
40
+ return createHash("sha256")
41
+ .update(JSON.stringify(issuedUnder))
42
+ .digest("hex");
23
43
  }
24
44
  /**
25
45
  * A static secret's structured shape, from the flat fields and fixed values
@@ -226,6 +226,9 @@ export function credentialRequest(ctx, declaration, call) {
226
226
  ...(call.destination !== undefined
227
227
  ? { destination: call.destination }
228
228
  : {}),
229
+ ...(call.idempotencyKey !== undefined
230
+ ? { idempotencyKey: call.idempotencyKey }
231
+ : {}),
229
232
  });
230
233
  }
231
234
  /**
@@ -265,8 +268,8 @@ export function errorIdentifier(value) {
265
268
  /**
266
269
  * How a failed request reads: its HTTP status when the status is the
267
270
  * failure, and the identifiers a provider returns for correcting the call
268
- * — its error `code` and the offending `param`. A failed response's body
269
- * is never read into it: that is where a provider may echo the credential.
271
+ * — its error `code` and the offending `param`. No free text from a failed
272
+ * response reaches it: that is where a provider may echo the credential.
270
273
  */
271
274
  export function failureMessage(plugin, status, detail = {}) {
272
275
  const identifiers = [detail.code, detail.param && `param: ${detail.param}`]
@@ -312,6 +315,30 @@ export function graphqlFailed(plugin, errors) {
312
315
  export function requestFailed(plugin, status) {
313
316
  return new Error(failureMessage(plugin, status));
314
317
  }
318
+ /**
319
+ * A failed response by its status and the identifiers its error envelope
320
+ * names. `read` picks the code and param out of the parsed JSON body, in
321
+ * the provider's own shape; only what `errorIdentifier` accepts is kept,
322
+ * so a provider's messages never reach the error. A body that is not JSON
323
+ * reports the status alone. The status rides on the error for callers
324
+ * that branch on it.
325
+ */
326
+ export async function responseFailed(plugin, response, read) {
327
+ let envelope = {};
328
+ try {
329
+ envelope = read(JSON.parse(await response.text()));
330
+ }
331
+ catch {
332
+ envelope = {};
333
+ }
334
+ const params = (Array.isArray(envelope.param) ? envelope.param : [envelope.param])
335
+ .map(identifierOf)
336
+ .filter((param) => param !== undefined);
337
+ return Object.assign(new Error(failureMessage(plugin, response.status, {
338
+ code: identifierOf(envelope.code),
339
+ param: params.length ? params.join(", ") : undefined,
340
+ })), { status: response.status });
341
+ }
315
342
  /** A request that must succeed, for callers that read the Response itself (headers, text, bytes). */
316
343
  export async function credentialOk(ctx, declaration, plugin, call) {
317
344
  const response = await credentialRequest(ctx, declaration, call);
@@ -1,8 +1,69 @@
1
1
  import { AuthError, downloadResource, } from "runline";
2
2
  import { credentialBroker } from "./credentialAdapter.js";
3
3
  import { jsonAnswer, requestFailed } from "./credentials.js";
4
- import { googleCredential, googleResources } from "./googleCredentials.js";
4
+ import { GOOGLE_GRANT, GOOGLE_TOKEN_URL, googleCredential, googleResources, googleServices, } from "./googleCredentials.js";
5
5
  export { googleCredential } from "./googleCredentials.js";
6
+ /**
7
+ * A Google plugin's OAuth declaration: Google's endpoints, the offline
8
+ * consent that returns a refresh token (without `access_type=offline` and
9
+ * `prompt=consent` a repeat login omits it), the grant every Google plugin
10
+ * shares, and setup steps that enable the APIs its targets call and name
11
+ * its client variables (`env`: `GMAIL` → `GMAIL_CLIENT_ID`).
12
+ */
13
+ export function googleOAuth(plugin, scopes, env) {
14
+ return {
15
+ grant: GOOGLE_GRANT,
16
+ authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
17
+ tokenUrl: GOOGLE_TOKEN_URL,
18
+ scopes,
19
+ authParams: { access_type: "offline", prompt: "consent" },
20
+ setupHelp: [
21
+ "You need a Google Cloud OAuth client. Takes ~5 minutes, one time;",
22
+ "the same client serves every Google plugin.",
23
+ "Do the steps in order — skipping step 4 causes a 403 when you log in.",
24
+ "",
25
+ "1. Create a Google Cloud project (or pick an existing one):",
26
+ " https://console.cloud.google.com/projectcreate",
27
+ " Make sure the project is selected in the top navigation dropdown.",
28
+ "",
29
+ "2. Enable the APIs this plugin calls (click 'Enable' on each):",
30
+ ...googleServices(plugin).map((service) => ` https://console.cloud.google.com/apis/library/${service}`),
31
+ "",
32
+ "3. Configure the OAuth consent screen (first time only):",
33
+ " https://console.cloud.google.com/apis/credentials/consent",
34
+ " Click 'Get started' and fill in:",
35
+ " • App name: runline (or whatever)",
36
+ " • User support email: your email",
37
+ " • Audience: External",
38
+ " • Developer contact: your email",
39
+ " You can skip the Scopes screen — runline declares scopes at auth time.",
40
+ "",
41
+ "4. Add yourself as a test user (required — External apps in Testing mode",
42
+ " only allow authentication from addresses on this list):",
43
+ " https://console.cloud.google.com/auth/audience",
44
+ " Under 'Test users', click '+ Add users' and enter the Google address",
45
+ " you'll use to log in. Consent and refresh tokens expire every 7 days",
46
+ " while the app is in Testing mode.",
47
+ "",
48
+ "5. Create the OAuth client:",
49
+ " https://console.cloud.google.com/apis/credentials",
50
+ " • + Create credentials → OAuth client ID",
51
+ " • Application type: Web application",
52
+ " • Name: runline (or whatever)",
53
+ " • Authorized redirect URIs → + Add URI:",
54
+ " {{redirectUri}}",
55
+ " • Click Create, then copy the Client ID and Client Secret from the",
56
+ " dialog — the secret is only shown once.",
57
+ "",
58
+ "6. Paste them below (or re-run with --client-id / --client-secret,",
59
+ ` or export ${env}_CLIENT_ID and ${env}_CLIENT_SECRET).`,
60
+ "",
61
+ "During login you'll see a 'Google hasn't verified this app' warning.",
62
+ "Click 'Advanced → Go to runline (unsafe)' to continue — this is",
63
+ "expected for apps in Testing mode.",
64
+ ],
65
+ };
66
+ }
6
67
  /** Translate builtin absolute URLs at one boundary; validate the raw path before URL normalization. */
7
68
  export async function googleResponse(ctx, plugin, scopes, url, init = {}, query) {
8
69
  const resources = googleResources(plugin);
@@ -1,5 +1,12 @@
1
1
  import { AuthError, } from "runline";
2
2
  import { grantSchema } from "./credentials.js";
3
+ /** The grant every Google plugin's consent belongs to. */
4
+ export const GOOGLE_GRANT = "google";
5
+ /**
6
+ * Google's fixed token endpoint: a JSON credential's token_uri never
7
+ * selects egress.
8
+ */
9
+ export const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
3
10
  /** Only legacy storage infers a method; host bindings always select it explicitly. */
4
11
  export function googleMethod(cfg) {
5
12
  if (cfg.authMethod !== undefined) {
@@ -51,6 +58,19 @@ const API_BASES = {
51
58
  calendar: "https://www.googleapis.com/calendar/v3/",
52
59
  gmail: "https://gmail.googleapis.com/gmail/v1/users/me/",
53
60
  };
61
+ /** The Cloud Console service each API base belongs to, to enable it by. */
62
+ const API_SERVICES = {
63
+ drive: "drive.googleapis.com",
64
+ upload: "drive.googleapis.com",
65
+ docs: "docs.googleapis.com",
66
+ sheets: "sheets.googleapis.com",
67
+ slides: "slides.googleapis.com",
68
+ script: "script.googleapis.com",
69
+ tasks: "tasks.googleapis.com",
70
+ people: "people.googleapis.com",
71
+ calendar: "calendar-json.googleapis.com",
72
+ gmail: "gmail.googleapis.com",
73
+ };
54
74
  const PLUGIN_TARGETS = {
55
75
  googleDrive: ["drive", "upload", "docs"],
56
76
  googleDocs: ["docs", "drive"],
@@ -62,11 +82,19 @@ const PLUGIN_TARGETS = {
62
82
  googleCalendar: ["calendar"],
63
83
  gmail: ["gmail"],
64
84
  };
65
- /** Builtin routing policy, never derived from action input or a returned URL. */
66
- export function googleResources(plugin) {
85
+ /** The API bases a builtin Google plugin calls; any other name is refused. */
86
+ function targetNames(plugin) {
67
87
  if (!Object.hasOwn(PLUGIN_TARGETS, plugin))
68
88
  throw new AuthError("request_not_allowed");
69
- const names = PLUGIN_TARGETS[plugin];
89
+ return PLUGIN_TARGETS[plugin];
90
+ }
91
+ /** The Cloud Console services a plugin calls, each to be enabled once. */
92
+ export function googleServices(plugin) {
93
+ return [...new Set(targetNames(plugin).map((name) => API_SERVICES[name]))];
94
+ }
95
+ /** Builtin routing policy, never derived from action input or a returned URL. */
96
+ export function googleResources(plugin) {
97
+ const names = targetNames(plugin);
70
98
  const targets = Object.fromEntries(names.map((name) => [
71
99
  name,
72
100
  {
@@ -134,9 +162,7 @@ export function googleCredential(plugin, scopes) {
134
162
  };
135
163
  };
136
164
  }
137
- /** Fixed vendor endpoints; a JSON credential's token_uri can never select egress. */
138
165
  export function googleCredentialType(scopes, plugin) {
139
- const url = "https://oauth2.googleapis.com/token";
140
166
  const { targets, probe } = googleResources(plugin);
141
167
  return {
142
168
  id: "google.oauth",
@@ -147,10 +173,14 @@ export function googleCredentialType(scopes, plugin) {
147
173
  kind: "oauth2",
148
174
  field: "grant",
149
175
  renewal: "refresh",
176
+ grant: GOOGLE_GRANT,
150
177
  definition: {
151
178
  id: "google.oauth",
152
179
  provider: "google",
153
- refresh: { url, clientAuthentication: "client_secret_post" },
180
+ refresh: {
181
+ url: GOOGLE_TOKEN_URL,
182
+ clientAuthentication: "client_secret_post",
183
+ },
154
184
  },
155
185
  },
156
186
  targets,
@@ -168,7 +198,10 @@ export function googleCredentialType(scopes, plugin) {
168
198
  definition: {
169
199
  id: "google.serviceAccount",
170
200
  provider: "google",
171
- jwtBearer: { url, clientAuthentication: "none" },
201
+ jwtBearer: {
202
+ url: GOOGLE_TOKEN_URL,
203
+ clientAuthentication: "none",
204
+ },
172
205
  },
173
206
  },
174
207
  targets,
@@ -0,0 +1,40 @@
1
+ import { constants } from "node:fs";
2
+ import { open } from "node:fs/promises";
3
+ /**
4
+ * A file on the runline host that an action input names — an attachment,
5
+ * an upload, an image to edit — read as one bounded buffer. It must be a
6
+ * regular file: the nonblocking open lets fstat refuse a FIFO or device
7
+ * without waiting on a writer. The size is checked before allocation and
8
+ * the bytes are read through the same handle, so a file swapped or grown
9
+ * between the check and the read cannot slip past the bound.
10
+ */
11
+ export async function readLocalFile(path, options) {
12
+ const { subject } = options;
13
+ let file;
14
+ try {
15
+ file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
16
+ }
17
+ catch (error) {
18
+ const code = error.code;
19
+ throw new Error(`${subject} is unreadable${typeof code === "string" ? ` (${code})` : ""}`);
20
+ }
21
+ try {
22
+ const stat = await file.stat();
23
+ if (!stat.isFile())
24
+ throw new Error(`${subject} must be a regular file`);
25
+ if (stat.size > options.maxBytes)
26
+ throw new Error(options.oversize);
27
+ const bytes = Buffer.alloc(stat.size);
28
+ let offset = 0;
29
+ while (offset < bytes.length) {
30
+ const { bytesRead } = await file.read(bytes, offset, bytes.length - offset, offset);
31
+ if (!bytesRead)
32
+ throw new Error(`${subject} changed while reading`);
33
+ offset += bytesRead;
34
+ }
35
+ return bytes;
36
+ }
37
+ finally {
38
+ await file.close();
39
+ }
40
+ }
@@ -9,9 +9,10 @@
9
9
  * available (same pattern as googleDrive/googleSlides).
10
10
  */
11
11
  import { randomUUID } from "node:crypto";
12
- import { readFileSync, writeFileSync } from "node:fs";
12
+ import { writeFileSync } from "node:fs";
13
13
  import { tmpdir } from "node:os";
14
14
  import { basename, extname, join } from "node:path";
15
+ import { readLocalFile } from "./localFile.js";
15
16
  const MIME_EXT = {
16
17
  "image/png": "png",
17
18
  "image/jpeg": "jpg",
@@ -66,23 +67,22 @@ const EXT_MIME = {
66
67
  webp: "image/webp",
67
68
  gif: "image/gif",
68
69
  };
70
+ /** Larger than any provider accepts inline; a data URI past it only fails remotely. */
71
+ const MAX_IMAGE_BYTES = 50 * 1024 * 1024;
69
72
  /**
70
73
  * Read a local image for an `image.edit` call: bytes + base64 + mime
71
- * (from the extension) + a `data:` URI for JSON APIs. Throws with the
72
- * plugin's name when the file can't be read so errors point at the
73
- * right tool.
74
+ * (from the extension) + a `data:` URI for JSON APIs. Errors carry the
75
+ * plugin's name so they point at the right tool.
74
76
  */
75
- export function readImageInput(imagePath, pluginName) {
77
+ export async function readImageInput(imagePath, pluginName) {
76
78
  if (typeof imagePath !== "string" || imagePath.trim().length === 0) {
77
79
  throw new Error(`${pluginName}: imagePath is required`);
78
80
  }
79
- let bytes;
80
- try {
81
- bytes = readFileSync(imagePath);
82
- }
83
- catch (err) {
84
- throw new Error(`${pluginName}: cannot read image at ${imagePath} — ${err instanceof Error ? err.message : String(err)}`);
85
- }
81
+ const bytes = await readLocalFile(imagePath, {
82
+ subject: `${pluginName}: imagePath`,
83
+ maxBytes: MAX_IMAGE_BYTES,
84
+ oversize: `${pluginName}: imagePath exceeds ${MAX_IMAGE_BYTES / (1024 * 1024)} MiB`,
85
+ });
86
86
  const ext = extname(imagePath).slice(1).toLowerCase();
87
87
  const mimeType = EXT_MIME[ext] ?? "image/png";
88
88
  const base64 = bytes.toString("base64");
@@ -1,7 +1,7 @@
1
1
  import { AuthError, downloadResource, } from "runline";
2
2
  import { credentialBroker } from "./credentialAdapter.js";
3
3
  import { jsonAnswer, requestFailed } from "./credentials.js";
4
- import { microsoftCredential, microsoftUserBase, } from "./microsoftCredentials.js";
4
+ import { MICROSOFT_GRANT, microsoftCredential, microsoftEndpoint, microsoftUserBase, } from "./microsoftCredentials.js";
5
5
  export { microsoftCredential, microsoftDriveBase, } from "./microsoftCredentials.js";
6
6
  export function userBase(ctx) {
7
7
  return microsoftUserBase(ctx.connection.config);
@@ -59,8 +59,23 @@ export async function microsoftDownload(value) {
59
59
  fetch: globalThis.fetch,
60
60
  });
61
61
  }
62
- /** Setup help shown by the OAuth wizard for all Microsoft plugins. */
63
- export function microsoftSetupHelp(apiName) {
62
+ /**
63
+ * A Microsoft Graph plugin's OAuth declaration: the multi-tenant endpoints,
64
+ * `offline_access` for a refresh token, the grant every Graph plugin
65
+ * shares, and setup steps naming the Graph permissions it needs
66
+ * (`permissions`, e.g. "Calendars.Read").
67
+ */
68
+ export function microsoftOAuth(scopes, permissions) {
69
+ return {
70
+ grant: MICROSOFT_GRANT,
71
+ authUrl: microsoftEndpoint("common", "authorize"),
72
+ tokenUrl: microsoftEndpoint("common", "token"),
73
+ scopes: [...scopes, "offline_access"],
74
+ setupHelp: setupHelp(permissions),
75
+ };
76
+ }
77
+ /** The OAuth wizard's steps for a Microsoft plugin. */
78
+ function setupHelp(permissions) {
64
79
  return [
65
80
  `You need a Microsoft Entra (Azure AD) app registration. One-time, ~5 minutes.`,
66
81
  "",
@@ -69,7 +84,7 @@ export function microsoftSetupHelp(apiName) {
69
84
  "2. Add a Web redirect URI (Authentication → Add platform → Web):",
70
85
  " {{redirectUri}}",
71
86
  "3. Certificates & secrets → New client secret → copy the VALUE (not the Secret ID).",
72
- `4. API permissions → Add → Microsoft Graph → Delegated → add the ${apiName} scopes,`,
87
+ `4. API permissions → Add → Microsoft Graph → Delegated → add the ${permissions} scopes,`,
73
88
  " then 'Grant admin consent'.",
74
89
  "5. Paste the Application (client) ID and the client secret VALUE below.",
75
90
  ];
@@ -1,5 +1,13 @@
1
1
  import { AuthError, } from "runline";
2
2
  import { grantSchema, pathSegment } from "./credentials.js";
3
+ /** The grant every Microsoft Graph plugin's delegated consent belongs to. */
4
+ export const MICROSOFT_GRANT = "microsoft";
5
+ /** A tenant's OAuth endpoint on the Microsoft identity platform. */
6
+ export function microsoftEndpoint(tenant, leg) {
7
+ if (!/^[a-zA-Z0-9.-]+$/.test(tenant))
8
+ throw new AuthError("invalid_credentials");
9
+ return `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/${leg}`;
10
+ }
3
11
  /** Compatibility inference is confined to legacy configs without an explicit method. */
4
12
  export function microsoftMethod(cfg) {
5
13
  if (cfg.authMethod !== undefined) {
@@ -50,11 +58,8 @@ export function microsoftCredential(plugin, scopes) {
50
58
  }
51
59
  /** Scope-appropriate probes never require User.Read merely to check mail/calendar/files. */
52
60
  export function microsoftCredentialType(cfg, plugin, scopes) {
53
- const tenant = cfg.tenantId || "common";
54
- if (!/^[a-zA-Z0-9.-]+$/.test(tenant))
55
- throw new AuthError("invalid_credentials");
56
61
  const endpoint = {
57
- url: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
62
+ url: microsoftEndpoint(cfg.tenantId || "common", "token"),
58
63
  clientAuthentication: "client_secret_post",
59
64
  };
60
65
  const methods = Object.fromEntries(["delegated", "appOnly"].map((method) => {
@@ -85,9 +90,9 @@ export function microsoftCredentialType(cfg, plugin, scopes) {
85
90
  authentication: {
86
91
  kind: "oauth2",
87
92
  field: "grant",
88
- renewal: method === "delegated"
89
- ? "refresh"
90
- : "clientCredentials",
93
+ ...(method === "delegated"
94
+ ? { renewal: "refresh", grant: MICROSOFT_GRANT }
95
+ : { renewal: "clientCredentials" }),
91
96
  definition: {
92
97
  id: "microsoft.oauth",
93
98
  provider: "microsoft",