runline 0.29.0 → 0.30.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 (69) hide show
  1. package/dist/auth/errors.d.ts +22 -0
  2. package/dist/auth/errors.js +25 -0
  3. package/dist/auth/oauth2.d.ts +21 -0
  4. package/dist/auth/oauth2.js +154 -0
  5. package/dist/auth/token.d.ts +11 -0
  6. package/dist/auth/token.js +231 -0
  7. package/dist/auth/types.d.ts +93 -0
  8. package/dist/auth/types.js +1 -0
  9. package/dist/commands/auth.js +26 -13
  10. package/dist/commands/connection.js +3 -6
  11. package/dist/commands/exec.js +7 -2
  12. package/dist/commands/init.js +9 -2
  13. package/dist/config/loader.d.ts +7 -13
  14. package/dist/config/loader.js +58 -88
  15. package/dist/config/store.d.ts +17 -0
  16. package/dist/config/store.js +85 -0
  17. package/dist/connections/file.d.ts +13 -0
  18. package/dist/connections/file.js +53 -0
  19. package/dist/connections/memory.d.ts +15 -0
  20. package/dist/connections/memory.js +70 -0
  21. package/dist/connections/types.d.ts +47 -0
  22. package/dist/connections/types.js +10 -0
  23. package/dist/core/engine.d.ts +5 -2
  24. package/dist/core/engine.js +48 -55
  25. package/dist/core/oauth.d.ts +20 -15
  26. package/dist/core/oauth.js +195 -98
  27. package/dist/credentials/http.d.ts +19 -0
  28. package/dist/credentials/http.js +109 -0
  29. package/dist/credentials/policy.d.ts +5 -0
  30. package/dist/credentials/policy.js +92 -0
  31. package/dist/credentials/registry.d.ts +31 -0
  32. package/dist/credentials/registry.js +171 -0
  33. package/dist/credentials/transport.d.ts +38 -0
  34. package/dist/credentials/transport.js +283 -0
  35. package/dist/credentials/types.d.ts +80 -0
  36. package/dist/credentials/types.js +1 -0
  37. package/dist/index.d.ts +12 -0
  38. package/dist/index.js +8 -0
  39. package/dist/plugin/types.d.ts +30 -17
  40. package/dist/plugins/_shared/credentialAdapter.js +88 -0
  41. package/dist/plugins/_shared/googleAuth.js +89 -103
  42. package/dist/plugins/_shared/googleCredentials.js +160 -0
  43. package/dist/plugins/_shared/microsoftAuth.js +81 -74
  44. package/dist/plugins/_shared/microsoftCredentials.js +90 -0
  45. package/dist/plugins/_shared/tokenRefresh.js +39 -0
  46. package/dist/plugins/bitwarden/src/index.js +13 -31
  47. package/dist/plugins/gmail/src/index.js +8 -44
  48. package/dist/plugins/googleAppsScript/src/index.js +3 -25
  49. package/dist/plugins/googleCalendar/src/index.js +3 -41
  50. package/dist/plugins/googleContacts/src/index.js +3 -36
  51. package/dist/plugins/googleDocs/src/index.js +5 -0
  52. package/dist/plugins/googleDocs/src/shared.js +2 -30
  53. package/dist/plugins/googleDrive/src/index.js +52 -98
  54. package/dist/plugins/googleSheets/src/index.js +3 -40
  55. package/dist/plugins/googleSlides/src/index.js +5 -39
  56. package/dist/plugins/googleTasks/src/index.js +3 -30
  57. package/dist/plugins/halopsa/src/index.js +16 -31
  58. package/dist/plugins/microsoftCalendar/src/index.js +2 -1
  59. package/dist/plugins/microsoftFiles/src/index.js +10 -24
  60. package/dist/plugins/microsoftMail/src/index.js +2 -1
  61. package/dist/plugins/paypal/src/index.js +14 -29
  62. package/dist/plugins/plaud/src/index.js +210 -0
  63. package/dist/plugins/plaud/src/shared.js +221 -0
  64. package/dist/plugins/salesforce/src/shared.js +17 -32
  65. package/dist/sdk.d.ts +13 -5
  66. package/dist/sdk.js +35 -29
  67. package/dist/utils/observer.d.ts +2 -0
  68. package/dist/utils/observer.js +11 -0
  69. package/package.json +1 -1
@@ -1,113 +1,99 @@
1
- import { createSign } from "node:crypto";
2
- const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
3
- const REFRESH_SKEW_MS = 60_000;
4
- export async function googleAccessToken(ctx, pluginName, scopes) {
5
- const cfg = ctx.connection.config;
6
- if (cfg.accessToken &&
7
- typeof cfg.accessTokenExpiresAt === "number" &&
8
- Date.now() < cfg.accessTokenExpiresAt - REFRESH_SKEW_MS) {
9
- return cfg.accessToken;
10
- }
11
- if (hasServiceAccountConfig(cfg)) {
12
- return refreshServiceAccountAccessToken(ctx, pluginName, cfg, scopes);
13
- }
14
- return refreshOAuthAccessToken(ctx, pluginName, cfg);
15
- }
16
- function hasServiceAccountConfig(cfg) {
17
- return !!cfg.serviceAccountJson || !!(cfg.serviceAccountEmail && cfg.serviceAccountPrivateKey);
18
- }
19
- async function refreshOAuthAccessToken(ctx, pluginName, cfg) {
20
- const { clientId, clientSecret, refreshToken } = cfg;
21
- if (!clientId || !clientSecret || !refreshToken) {
22
- throw new Error(`${pluginName}: missing OAuth clientId/clientSecret/refreshToken or service account credentials. Run the OAuth helper or set serviceAccountJson.`);
23
- }
24
- const body = new URLSearchParams({
25
- client_id: clientId,
26
- client_secret: clientSecret,
27
- refresh_token: refreshToken,
28
- grant_type: "refresh_token",
29
- });
30
- const res = await fetch(TOKEN_ENDPOINT, {
31
- method: "POST",
32
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
33
- body: body.toString(),
1
+ import { AuthError, downloadResource, } from "runline";
2
+ import { credentialRuntime } from "./credentialAdapter.js";
3
+ import { googleCredentialType, googleIdentity, googleMethod, googleResources, } from "./googleCredentials.js";
4
+ /** Explicit storage adapter: the registry owns token protocols and renewal. */
5
+ export function googleRuntime(ctx, pluginName, scopes) {
6
+ const config = ctx.connection.config;
7
+ const method = googleMethod(config);
8
+ const runtime = credentialRuntime(ctx, googleCredentialType(scopes, pluginName), method, (current) => {
9
+ const cfg = current;
10
+ return [
11
+ pluginName,
12
+ scopes,
13
+ googleMethod(cfg),
14
+ ...(method === "serviceAccount"
15
+ ? [googleIdentity(cfg)]
16
+ : [cfg.clientId, cfg.clientSecret]),
17
+ ];
34
18
  });
35
- if (!res.ok) {
36
- throw new Error(`${pluginName}: token refresh failed (${res.status}): ${await res.text()}`);
37
- }
38
- const data = (await res.json());
39
- const expiresAt = Date.now() + data.expires_in * 1000;
40
- await ctx.updateConnection({
41
- accessToken: data.access_token,
42
- accessTokenExpiresAt: expiresAt,
43
- });
44
- return data.access_token;
19
+ if (method === "serviceAccount")
20
+ runtime.binding.jwtIdentity = googleIdentity(config);
21
+ return runtime;
45
22
  }
46
- async function refreshServiceAccountAccessToken(ctx, pluginName, cfg, scopes) {
47
- const serviceAccount = parseServiceAccount(pluginName, cfg);
48
- const now = Math.floor(Date.now() / 1000);
49
- const assertion = signJwt({
50
- alg: "RS256",
51
- typ: "JWT",
52
- }, {
53
- iss: serviceAccount.client_email,
54
- scope: scopes.join(" "),
55
- aud: TOKEN_ENDPOINT,
56
- iat: now,
57
- exp: now + 3600,
58
- ...(cfg.serviceAccountSubject ? { sub: cfg.serviceAccountSubject } : {}),
59
- }, serviceAccount.private_key);
60
- const body = new URLSearchParams({
61
- grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
62
- assertion,
63
- });
64
- const res = await fetch(TOKEN_ENDPOINT, {
65
- method: "POST",
66
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
67
- body: body.toString(),
68
- });
69
- if (!res.ok) {
70
- throw new Error(`${pluginName}: service account token failed (${res.status}): ${await res.text()}`);
23
+ /** Trusted-runtime token compatibility only; resource consumers use googleResponse. */
24
+ export async function googleAccessToken(ctx, pluginName, scopes) {
25
+ const { binding, transport } = googleRuntime(ctx, pluginName, scopes);
26
+ return transport.accessToken(binding);
27
+ }
28
+ /** Translate builtin absolute URLs at one boundary; validate the raw path before URL normalization. */
29
+ export async function googleResponse(ctx, plugin, scopes, url, init = {}, query) {
30
+ const resources = googleResources(plugin);
31
+ const selected = Object.entries(resources.targets).find(([, target]) => url.startsWith(target.baseUrl));
32
+ if (!selected)
33
+ throw new AuthError("request_not_allowed");
34
+ const [target, policy] = selected;
35
+ let path = url.slice(policy.baseUrl.length);
36
+ if (query) {
37
+ const separator = path.indexOf("?");
38
+ const params = new URLSearchParams(separator < 0 ? "" : path.slice(separator + 1));
39
+ for (const [key, value] of Object.entries(query)) {
40
+ if (value === undefined || value === null)
41
+ continue;
42
+ params.delete(key);
43
+ for (const entry of Array.isArray(value) ? value : [value])
44
+ params.append(key, String(entry));
45
+ }
46
+ path = `${separator < 0 ? path : path.slice(0, separator)}?${params}`;
71
47
  }
72
- const data = (await res.json());
73
- const expiresAt = Date.now() + data.expires_in * 1000;
74
- await ctx.updateConnection({
75
- accessToken: data.access_token,
76
- accessTokenExpiresAt: expiresAt,
48
+ const { binding, transport } = googleRuntime(ctx, plugin, scopes);
49
+ return transport.request(binding, {
50
+ target,
51
+ path,
52
+ method: (init.method ?? "GET"),
53
+ headers: init.headers,
54
+ body: init.body,
77
55
  });
78
- return data.access_token;
79
56
  }
80
- function parseServiceAccount(pluginName, cfg) {
81
- if (cfg.serviceAccountJson) {
82
- try {
83
- const parsed = JSON.parse(cfg.serviceAccountJson);
84
- if (parsed.client_email && parsed.private_key) {
85
- return { client_email: parsed.client_email, private_key: parsed.private_key };
86
- }
87
- }
88
- catch (err) {
89
- throw new Error(`${pluginName}: invalid serviceAccountJson: ${err.message}`);
90
- }
57
+ export async function googleJsonRequest(ctx, plugin, scopes, method, url, body, query) {
58
+ const response = await googleResponse(ctx, plugin, scopes, url, {
59
+ method,
60
+ headers: {
61
+ Accept: "application/json",
62
+ ...(body === undefined ? {} : { "Content-Type": "application/json" }),
63
+ },
64
+ body: body === undefined ? undefined : JSON.stringify(body),
65
+ }, query);
66
+ if (!response.ok)
67
+ throw new Error(`${plugin}: request failed (HTTP ${response.status})`);
68
+ if (response.status === 204)
69
+ return { success: true };
70
+ const text = await response.text();
71
+ try {
72
+ return text ? JSON.parse(text) : { success: true };
91
73
  }
92
- if (cfg.serviceAccountEmail && cfg.serviceAccountPrivateKey) {
93
- return {
94
- client_email: cfg.serviceAccountEmail,
95
- private_key: cfg.serviceAccountPrivateKey.replace(/\\n/g, "\n"),
96
- };
74
+ catch {
75
+ throw new AuthError("invalid_response");
97
76
  }
98
- throw new Error(`${pluginName}: service account requires serviceAccountJson or serviceAccountEmail/serviceAccountPrivateKey`);
99
77
  }
100
- function signJwt(header, payload, privateKey) {
101
- const encodedHeader = base64url(JSON.stringify(header));
102
- const encodedPayload = base64url(JSON.stringify(payload));
103
- const signingInput = `${encodedHeader}.${encodedPayload}`;
104
- const signer = createSign("RSA-SHA256");
105
- signer.update(signingInput);
106
- signer.end();
107
- const signature = signer.sign(privateKey.replace(/\\n/g, "\n"));
108
- return `${signingInput}.${base64url(signature)}`;
78
+ export async function googleProbe(ctx, plugin, scopes) {
79
+ const { binding, transport } = googleRuntime(ctx, plugin, scopes);
80
+ return transport.probe(binding);
109
81
  }
110
- function base64url(input) {
111
- const buf = typeof input === "string" ? Buffer.from(input, "utf-8") : input;
112
- return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
82
+ /** Only Google-issued thumbnail hosts; never attach credentials to signed URLs. */
83
+ export async function googleDownload(value) {
84
+ let url;
85
+ try {
86
+ url = new URL(value);
87
+ if (url.port ||
88
+ !(url.hostname === "googleusercontent.com" ||
89
+ url.hostname.endsWith(".googleusercontent.com")))
90
+ throw new Error();
91
+ }
92
+ catch {
93
+ throw new AuthError("request_not_allowed");
94
+ }
95
+ return downloadResource(value, {
96
+ allowedOrigins: [url.origin],
97
+ fetch: globalThis.fetch,
98
+ });
113
99
  }
@@ -0,0 +1,160 @@
1
+ import { AuthError, OAuthGrantSchema, } from "runline";
2
+ import * as t from "typebox";
3
+ /** Only legacy storage infers a method; host bindings always select it explicitly. */
4
+ export function googleMethod(cfg) {
5
+ if (cfg.authMethod !== undefined) {
6
+ if (cfg.authMethod !== "delegated" && cfg.authMethod !== "serviceAccount")
7
+ throw new AuthError("invalid_credentials");
8
+ return cfg.authMethod;
9
+ }
10
+ return cfg.serviceAccountJson ||
11
+ cfg.serviceAccountEmail ||
12
+ cfg.serviceAccountPrivateKey
13
+ ? "serviceAccount"
14
+ : "delegated";
15
+ }
16
+ export function googleIdentity(cfg) {
17
+ try {
18
+ const parsed = cfg.serviceAccountJson
19
+ ? JSON.parse(cfg.serviceAccountJson)
20
+ : {
21
+ client_email: cfg.serviceAccountEmail,
22
+ private_key: cfg.serviceAccountPrivateKey,
23
+ };
24
+ if (!parsed ||
25
+ typeof parsed.client_email !== "string" ||
26
+ !parsed.client_email ||
27
+ typeof parsed.private_key !== "string" ||
28
+ !parsed.private_key)
29
+ throw new Error();
30
+ return {
31
+ issuer: parsed.client_email,
32
+ privateKey: parsed.private_key.replace(/\\n/g, "\n"),
33
+ ...(cfg.serviceAccountSubject === undefined
34
+ ? {}
35
+ : { subject: cfg.serviceAccountSubject }),
36
+ };
37
+ }
38
+ catch {
39
+ throw new AuthError("invalid_credentials");
40
+ }
41
+ }
42
+ const API_BASES = {
43
+ drive: "https://www.googleapis.com/drive/v3/",
44
+ upload: "https://www.googleapis.com/upload/drive/v3/",
45
+ docs: "https://docs.googleapis.com/v1/",
46
+ sheets: "https://sheets.googleapis.com/v4/",
47
+ slides: "https://slides.googleapis.com/v1/",
48
+ script: "https://script.googleapis.com/v1/",
49
+ tasks: "https://tasks.googleapis.com/tasks/v1/",
50
+ people: "https://people.googleapis.com/",
51
+ calendar: "https://www.googleapis.com/calendar/v3/",
52
+ gmail: "https://gmail.googleapis.com/gmail/v1/users/me/",
53
+ };
54
+ const PLUGIN_TARGETS = {
55
+ googleDrive: ["drive", "upload", "docs"],
56
+ googleDocs: ["docs", "drive"],
57
+ googleSheets: ["sheets", "drive"],
58
+ googleSlides: ["slides"],
59
+ googleAppsScript: ["script", "drive"],
60
+ googleTasks: ["tasks"],
61
+ googleContacts: ["people"],
62
+ googleCalendar: ["calendar"],
63
+ gmail: ["gmail"],
64
+ };
65
+ /** Builtin routing policy, never derived from action input or a returned URL. */
66
+ export function googleResources(plugin) {
67
+ if (!Object.hasOwn(PLUGIN_TARGETS, plugin))
68
+ throw new AuthError("request_not_allowed");
69
+ const names = PLUGIN_TARGETS[plugin];
70
+ const targets = Object.fromEntries(names.map((name) => [
71
+ name,
72
+ {
73
+ baseUrl: API_BASES[name],
74
+ methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
75
+ ...(name === "upload"
76
+ ? {
77
+ allowedHeaders: [
78
+ "X-Upload-Content-Type",
79
+ "X-Upload-Content-Length",
80
+ "Content-Range",
81
+ ],
82
+ resumableUpload: true,
83
+ }
84
+ : {}),
85
+ },
86
+ ]));
87
+ const probes = {
88
+ googleDrive: ["drive", "files?pageSize=1&fields=files(id)"],
89
+ googleTasks: ["tasks", "users/@me/lists?maxResults=1"],
90
+ googleContacts: [
91
+ "people",
92
+ "v1/people/me/connections?pageSize=1&personFields=names",
93
+ ],
94
+ googleCalendar: ["calendar", "users/me/calendarList?maxResults=1"],
95
+ gmail: ["gmail", "profile"],
96
+ googleAppsScript: ["drive", "files?pageSize=1&fields=files(id)"],
97
+ };
98
+ const probe = probes[plugin];
99
+ // Docs/Sheets/Slides have no fixed scoped document to probe. Do not guess an ID
100
+ // or require broader Drive permissions merely to manufacture a successful check.
101
+ return {
102
+ targets,
103
+ ...(probe
104
+ ? {
105
+ probe: {
106
+ target: probe[0],
107
+ path: probe[1],
108
+ method: "GET",
109
+ acceptedStatuses: [200],
110
+ },
111
+ }
112
+ : {}),
113
+ };
114
+ }
115
+ /** Fixed vendor endpoints; a JSON credential's token_uri can never select egress. */
116
+ export function googleCredentialType(scopes, plugin) {
117
+ const url = "https://oauth2.googleapis.com/token";
118
+ const schema = t.Object({ grant: t.Optional(OAuthGrantSchema) }, { additionalProperties: false });
119
+ const { targets, probe } = googleResources(plugin);
120
+ return {
121
+ id: "google.oauth",
122
+ methods: {
123
+ delegated: {
124
+ schema,
125
+ authentication: {
126
+ kind: "oauth2",
127
+ grantField: "grant",
128
+ renewal: "refresh",
129
+ definition: {
130
+ id: "google.oauth",
131
+ provider: "google",
132
+ refresh: { url, clientAuthentication: "client_secret_post" },
133
+ },
134
+ },
135
+ targets,
136
+ probe,
137
+ },
138
+ ...(scopes.length
139
+ ? {
140
+ serviceAccount: {
141
+ schema,
142
+ authentication: {
143
+ kind: "oauth2",
144
+ grantField: "grant",
145
+ renewal: "jwtBearer",
146
+ scopes,
147
+ definition: {
148
+ id: "google.serviceAccount",
149
+ provider: "google",
150
+ jwtBearer: { url, clientAuthentication: "none" },
151
+ },
152
+ },
153
+ targets,
154
+ probe,
155
+ },
156
+ }
157
+ : {}),
158
+ },
159
+ };
160
+ }
@@ -1,87 +1,94 @@
1
- const REFRESH_SKEW_MS = 60_000;
2
- function authority(cfg) {
3
- return `https://login.microsoftonline.com/${cfg.tenantId || "common"}/oauth2/v2.0/token`;
4
- }
1
+ import { AuthError, downloadResource, } from "runline";
2
+ import { credentialRuntime } from "./credentialAdapter.js";
3
+ import { microsoftCredentialType, microsoftMethod, microsoftUserBase, } from "./microsoftCredentials.js";
4
+ export { microsoftDriveBase } from "./microsoftCredentials.js";
5
5
  export function isAppOnly(cfg) {
6
- return !cfg.refreshToken && !!(cfg.tenantId && cfg.clientId && cfg.clientSecret);
6
+ return microsoftMethod(cfg) === "appOnly";
7
7
  }
8
- /** Graph path prefix for the acting principal: /me (delegated) or /users/{upn} (app-only). */
9
8
  export function userBase(ctx) {
10
- const cfg = ctx.connection.config;
11
- if (cfg.refreshToken)
12
- return "/me";
13
- if (cfg.userUpn)
14
- return `/users/${encodeURIComponent(cfg.userUpn)}`;
15
- throw new Error("microsoft: app-only mode requires userUpn (target mailbox/drive). Set MS_GRAPH_USER_UPN, or connect via OAuth.");
9
+ return microsoftUserBase(ctx.connection.config);
16
10
  }
17
- export async function microsoftAccessToken(ctx, pluginName, scopes) {
11
+ function runtime(ctx, plugin, scopes) {
18
12
  const cfg = ctx.connection.config;
19
- if (cfg.accessToken &&
20
- typeof cfg.accessTokenExpiresAt === "number" &&
21
- Date.now() < cfg.accessTokenExpiresAt - REFRESH_SKEW_MS) {
22
- return cfg.accessToken;
23
- }
24
- let body;
25
- if (cfg.refreshToken) {
26
- if (!cfg.clientId || !cfg.clientSecret) {
27
- throw new Error(`${pluginName}: missing clientId/clientSecret for OAuth refresh.`);
28
- }
29
- body = new URLSearchParams({
30
- client_id: cfg.clientId,
31
- client_secret: cfg.clientSecret,
32
- refresh_token: cfg.refreshToken,
33
- grant_type: "refresh_token",
34
- scope: [...scopes, "offline_access"].join(" "),
35
- });
36
- }
37
- else if (isAppOnly(cfg)) {
38
- body = new URLSearchParams({
39
- client_id: cfg.clientId,
40
- client_secret: cfg.clientSecret,
41
- grant_type: "client_credentials",
42
- scope: "https://graph.microsoft.com/.default",
43
- });
44
- }
45
- else {
46
- throw new Error(`${pluginName}: no credentials. Connect via OAuth, or set tenantId/clientId/clientSecret (app-only).`);
47
- }
48
- const res = await fetch(authority(cfg), {
49
- method: "POST",
50
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
51
- body: body.toString(),
13
+ const method = microsoftMethod(cfg);
14
+ if (method === "appOnly" &&
15
+ (!cfg.tenantId ||
16
+ ["common", "organizations", "consumers"].includes(cfg.tenantId)))
17
+ throw new AuthError("invalid_credentials");
18
+ return credentialRuntime(ctx, microsoftCredentialType(cfg, plugin, scopes), method, (current) => {
19
+ const c = current;
20
+ return [
21
+ microsoftMethod(c),
22
+ c.tenantId,
23
+ c.clientId,
24
+ c.clientSecret,
25
+ scopes,
26
+ ];
52
27
  });
53
- if (!res.ok) {
54
- throw new Error(`${pluginName}: token request failed (${res.status}): ${await res.text()}`);
55
- }
56
- const data = (await res.json());
57
- const patch = {
58
- accessToken: data.access_token,
59
- accessTokenExpiresAt: Date.now() + data.expires_in * 1000,
60
- };
61
- // Microsoft rotates refresh tokens — persist the new one when present.
62
- if (data.refresh_token)
63
- patch.refreshToken = data.refresh_token;
64
- await ctx.updateConnection(patch);
65
- return data.access_token;
66
28
  }
67
- /** Authenticated Graph v1.0 request. Returns parsed JSON ({success:true} for 204). */
68
- export async function graphRequest(ctx, pluginName, scopes, method, path, body) {
69
- const token = await microsoftAccessToken(ctx, pluginName, scopes);
70
- const init = {
71
- method,
72
- headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
73
- };
74
- if (body !== undefined) {
75
- init.headers["Content-Type"] = "application/json";
76
- init.body = JSON.stringify(body);
77
- }
78
- const res = await fetch(`https://graph.microsoft.com/v1.0${path}`, init);
29
+ /** Compatibility token access uses the same grant runtime as resource requests. */
30
+ export async function microsoftAccessToken(ctx, plugin, scopes) {
31
+ const { binding, transport } = runtime(ctx, plugin, scopes);
32
+ return transport.accessToken(binding);
33
+ }
34
+ export async function microsoftProbe(ctx, plugin, scopes) {
35
+ const { binding, transport } = runtime(ctx, plugin, scopes);
36
+ return transport.probe(binding);
37
+ }
38
+ export async function graphResponse(ctx, plugin, scopes, method, path, body, contentType) {
39
+ if (!path.startsWith("/") || path.startsWith("//"))
40
+ throw new AuthError("request_not_allowed");
41
+ const { binding, transport } = runtime(ctx, plugin, scopes);
42
+ return transport.request(binding, {
43
+ target: "graph",
44
+ path: path.slice(1),
45
+ method: method,
46
+ headers: {
47
+ Accept: "application/json",
48
+ ...(contentType ? { "Content-Type": contentType } : {}),
49
+ },
50
+ body,
51
+ });
52
+ }
53
+ /** JSON and binary requests share the same destination, renewal and replay policy. */
54
+ export async function graphRequest(ctx, plugin, scopes, method, path, body) {
55
+ const res = await graphResponse(ctx, plugin, scopes, method, path, body === undefined ? undefined : JSON.stringify(body), body === undefined ? undefined : "application/json");
56
+ if (!res.ok)
57
+ throw new Error(`${plugin}: Graph request failed (HTTP ${res.status})`);
79
58
  if (res.status === 204)
80
59
  return { success: true };
81
60
  const text = await res.text();
82
- if (!res.ok)
83
- throw new Error(`${pluginName}: ${method} ${path} → ${res.status} ${text}`);
84
- return text ? JSON.parse(text) : { success: true };
61
+ try {
62
+ return text ? JSON.parse(text) : { success: true };
63
+ }
64
+ catch {
65
+ throw new Error(`${plugin}: invalid Graph response`);
66
+ }
67
+ }
68
+ /** Graph-issued signed download URLs carry their own authority, never a bearer header. */
69
+ export async function microsoftDownload(value) {
70
+ if (typeof value !== "string")
71
+ throw new AuthError("invalid_response");
72
+ let url;
73
+ try {
74
+ url = new URL(value);
75
+ const domains = [
76
+ "sharepoint.com",
77
+ "1drv.com",
78
+ "storage.live.com",
79
+ "onedrive.com",
80
+ ];
81
+ if (url.port ||
82
+ !domains.some((domain) => url.hostname === domain || url.hostname.endsWith(`.${domain}`)))
83
+ throw new Error();
84
+ }
85
+ catch {
86
+ throw new AuthError("request_not_allowed");
87
+ }
88
+ return downloadResource(value, {
89
+ allowedOrigins: [url.origin],
90
+ fetch: globalThis.fetch,
91
+ });
85
92
  }
86
93
  /** Setup help shown by the OAuth wizard for all Microsoft plugins. */
87
94
  export function microsoftSetupHelp(apiName) {
@@ -0,0 +1,90 @@
1
+ import { AuthError, OAuthGrantSchema, } from "runline";
2
+ import * as t from "typebox";
3
+ /** Compatibility inference is confined to legacy configs without an explicit method. */
4
+ export function microsoftMethod(cfg) {
5
+ if (cfg.authMethod !== undefined) {
6
+ if (cfg.authMethod !== "delegated" && cfg.authMethod !== "appOnly")
7
+ throw new AuthError("invalid_credentials");
8
+ return cfg.authMethod;
9
+ }
10
+ return !cfg.refreshToken && cfg.tenantId && cfg.clientId && cfg.clientSecret
11
+ ? "appOnly"
12
+ : "delegated";
13
+ }
14
+ export function microsoftUserBase(cfg) {
15
+ if (microsoftMethod(cfg) === "delegated")
16
+ return "/me";
17
+ if (!cfg.userUpn)
18
+ throw new AuthError("invalid_credentials");
19
+ return `/users/${encodeURIComponent(cfg.userUpn)}`;
20
+ }
21
+ export function microsoftDriveBase(cfg) {
22
+ if (cfg.driveId)
23
+ return `/drives/${encodeURIComponent(cfg.driveId)}`;
24
+ if (cfg.siteId)
25
+ return `/sites/${encodeURIComponent(cfg.siteId)}/drive`;
26
+ return `${microsoftUserBase(cfg)}/drive`;
27
+ }
28
+ /** Scope-appropriate probes never require User.Read merely to check mail/calendar/files. */
29
+ export function microsoftCredentialType(cfg, plugin, scopes) {
30
+ const tenant = cfg.tenantId || "common";
31
+ if (!/^[a-zA-Z0-9.-]+$/.test(tenant))
32
+ throw new AuthError("invalid_credentials");
33
+ const endpoint = {
34
+ url: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
35
+ clientAuthentication: "client_secret_post",
36
+ };
37
+ const schema = t.Object({ grant: t.Optional(OAuthGrantSchema) }, { additionalProperties: false });
38
+ const methods = Object.fromEntries(["delegated", "appOnly"].map((method) => {
39
+ let probe;
40
+ const selected = { ...cfg, authMethod: method };
41
+ if (method === "delegated" ||
42
+ cfg.userUpn ||
43
+ (plugin === "microsoftFiles" && (cfg.driveId || cfg.siteId))) {
44
+ const path = plugin === "microsoftMail"
45
+ ? `${microsoftUserBase(selected)}/messages?$top=1&$select=id`
46
+ : plugin === "microsoftCalendar"
47
+ ? `${microsoftUserBase(selected)}/events?$top=1&$select=id`
48
+ : plugin === "microsoftFiles"
49
+ ? `${microsoftDriveBase(selected)}/root?$select=id`
50
+ : undefined;
51
+ if (path)
52
+ probe = {
53
+ target: "graph",
54
+ path: path.slice(1),
55
+ method: "GET",
56
+ acceptedStatuses: [200],
57
+ };
58
+ }
59
+ return [
60
+ method,
61
+ {
62
+ schema,
63
+ authentication: {
64
+ kind: "oauth2",
65
+ grantField: "grant",
66
+ renewal: method === "delegated"
67
+ ? "refresh"
68
+ : "clientCredentials",
69
+ definition: {
70
+ id: "microsoft.oauth",
71
+ provider: "microsoft",
72
+ refresh: endpoint,
73
+ clientCredentials: endpoint,
74
+ },
75
+ scopes: method === "delegated"
76
+ ? [...scopes, "offline_access"]
77
+ : ["https://graph.microsoft.com/.default"],
78
+ },
79
+ targets: {
80
+ graph: {
81
+ baseUrl: "https://graph.microsoft.com/v1.0/",
82
+ methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
83
+ },
84
+ },
85
+ ...(probe ? { probe } : {}),
86
+ },
87
+ ];
88
+ }));
89
+ return { id: "microsoft.graph", methods };
90
+ }
@@ -0,0 +1,39 @@
1
+ import { requestOAuth2Token, } from "runline";
2
+ const REFRESH_SKEW_MS = 60_000;
3
+ function usableToken(config) {
4
+ return typeof config.accessToken === "string" &&
5
+ config.accessToken &&
6
+ (config.accessTokenExpiresAt === undefined ||
7
+ (typeof config.accessTokenExpiresAt === "number" &&
8
+ Number.isFinite(config.accessTokenExpiresAt) &&
9
+ Date.now() < config.accessTokenExpiresAt - REFRESH_SKEW_MS))
10
+ ? config.accessToken
11
+ : undefined;
12
+ }
13
+ /** Refresh ownership includes the provider request and persistence of rotated tokens. */
14
+ export async function coordinatedAccessToken(ctx, refresh) {
15
+ const cached = usableToken(ctx.connection.config);
16
+ if (cached)
17
+ return cached;
18
+ await ctx.updateConnection(async (current) => {
19
+ if (usableToken(current))
20
+ return;
21
+ return refresh(current);
22
+ });
23
+ const token = ctx.connection.config.accessToken;
24
+ if (typeof token !== "string" || !token)
25
+ throw new Error("Token refresh committed no access token");
26
+ return token;
27
+ }
28
+ /** Token errors exclude response bodies, parser diagnostics, and request credentials. */
29
+ export async function requestToken(endpoint, body, application) {
30
+ const tokens = await requestOAuth2Token(endpoint, body, application);
31
+ return {
32
+ accessToken: tokens.accessToken,
33
+ // Unknown expiry replaces an old expiry; it never inherits a stale timestamp.
34
+ accessTokenExpiresAt: tokens.expiresAt,
35
+ ...(tokens.refreshToken === undefined
36
+ ? {}
37
+ : { refreshToken: tokens.refreshToken }),
38
+ };
39
+ }