@lanes-sh/link 0.1.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 (276) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +144 -0
  3. package/bin/lanes +42 -0
  4. package/instructions/agents/lanes-link-scout.md +73 -0
  5. package/instructions/skills/lanes-link/SKILL.md +187 -0
  6. package/package.json +95 -0
  7. package/src/audit/chain.ts +278 -0
  8. package/src/audit/conformance.ts +374 -0
  9. package/src/audit/fanout.ts +97 -0
  10. package/src/audit/index.ts +218 -0
  11. package/src/audit/stdout.ts +60 -0
  12. package/src/auth/index.ts +220 -0
  13. package/src/auth/oauth/metadata.ts +75 -0
  14. package/src/auth/oauth/server.ts +385 -0
  15. package/src/auth/oauth/store.ts +215 -0
  16. package/src/auth/oidc.ts +206 -0
  17. package/src/auth/remote.ts +72 -0
  18. package/src/cli/argv.ts +94 -0
  19. package/src/cli/callback-page.ts +256 -0
  20. package/src/cli/commands/connect/accounts.ts +94 -0
  21. package/src/cli/commands/connect/authorise.ts +298 -0
  22. package/src/cli/commands/connect/client.ts +284 -0
  23. package/src/cli/commands/connect/index.ts +398 -0
  24. package/src/cli/commands/connect/outcome.ts +119 -0
  25. package/src/cli/commands/connect/requirements.ts +103 -0
  26. package/src/cli/commands/connect/scopes-gate.ts +146 -0
  27. package/src/cli/commands/connect/settle.ts +136 -0
  28. package/src/cli/commands/connect/setup.ts +276 -0
  29. package/src/cli/commands/mcp/assets.ts +189 -0
  30. package/src/cli/commands/mcp/harnesses.ts +143 -0
  31. package/src/cli/commands/mcp/list.ts +70 -0
  32. package/src/cli/commands/mcp/register.ts +199 -0
  33. package/src/cli/commands/mcp/stdio.ts +57 -0
  34. package/src/cli/commands/mcp.ts +22 -0
  35. package/src/cli/commands/operate/attach.ts +121 -0
  36. package/src/cli/commands/operate/audit.ts +119 -0
  37. package/src/cli/commands/operate/inspect.ts +355 -0
  38. package/src/cli/commands/operate/outputs.ts +206 -0
  39. package/src/cli/commands/operate/policy.ts +80 -0
  40. package/src/cli/commands/operate/serve.ts +55 -0
  41. package/src/cli/commands/operate/status.ts +133 -0
  42. package/src/cli/commands/operate/token.ts +70 -0
  43. package/src/cli/commands/operate.ts +27 -0
  44. package/src/cli/commands/owner/memory.ts +110 -0
  45. package/src/cli/commands/owner/shared.ts +125 -0
  46. package/src/cli/commands/owner/skills.ts +92 -0
  47. package/src/cli/commands/owner/vault.ts +140 -0
  48. package/src/cli/commands/owner.ts +46 -0
  49. package/src/cli/commands/profile/removal.ts +278 -0
  50. package/src/cli/commands/profile/remove.ts +315 -0
  51. package/src/cli/commands/profile.ts +156 -0
  52. package/src/cli/commands/secrets.ts +176 -0
  53. package/src/cli/commands/setup.ts +150 -0
  54. package/src/cli/commands/target.ts +310 -0
  55. package/src/cli/config-edit.ts +397 -0
  56. package/src/cli/endpoint-url.ts +77 -0
  57. package/src/cli/identity.ts +109 -0
  58. package/src/cli/lanes.ts +78 -0
  59. package/src/cli/main.ts +333 -0
  60. package/src/cli/oauth-error.ts +13 -0
  61. package/src/cli/oauth-exchange.ts +146 -0
  62. package/src/cli/oauth.ts +354 -0
  63. package/src/cli/output.ts +184 -0
  64. package/src/cli/prompt.ts +180 -0
  65. package/src/cli/publish.ts +185 -0
  66. package/src/cli/runtime/discovery.ts +123 -0
  67. package/src/cli/runtime/open.ts +342 -0
  68. package/src/cli/runtime/registry.ts +185 -0
  69. package/src/cli/runtime/select.ts +124 -0
  70. package/src/cli/runtime.ts +34 -0
  71. package/src/cli/scopes.ts +63 -0
  72. package/src/cli/usage.ts +111 -0
  73. package/src/cli/version.ts +25 -0
  74. package/src/connectivity/auth/README.md +36 -0
  75. package/src/connectivity/auth/api-key/index.ts +43 -0
  76. package/src/connectivity/auth/authorize.ts +49 -0
  77. package/src/connectivity/auth/basic/index.ts +68 -0
  78. package/src/connectivity/auth/bearer/index.ts +13 -0
  79. package/src/connectivity/auth/credential.ts +19 -0
  80. package/src/connectivity/auth/header/index.ts +18 -0
  81. package/src/connectivity/auth/index.ts +35 -0
  82. package/src/connectivity/auth/none/index.ts +12 -0
  83. package/src/connectivity/auth/oauth-authcode/broker.ts +261 -0
  84. package/src/connectivity/auth/oauth-authcode/index.ts +64 -0
  85. package/src/connectivity/auth/oauth-authcode/provider.ts +279 -0
  86. package/src/connectivity/auth/oauth-authcode/refresh.ts +118 -0
  87. package/src/connectivity/auth/resolve.ts +61 -0
  88. package/src/connectivity/auth/strategy/index.ts +14 -0
  89. package/src/connectivity/capability.ts +164 -0
  90. package/src/connectivity/connector.ts +175 -0
  91. package/src/connectivity/context.ts +77 -0
  92. package/src/connectivity/index.ts +94 -0
  93. package/src/connectivity/mail/attachments.ts +368 -0
  94. package/src/connectivity/mail/compose.ts +73 -0
  95. package/src/connectivity/mail/index.ts +40 -0
  96. package/src/connectivity/mail/message.ts +82 -0
  97. package/src/connectivity/mail/nodemailer.d.ts +83 -0
  98. package/src/connectivity/mail/staging.ts +143 -0
  99. package/src/connectivity/mail/url.ts +301 -0
  100. package/src/connectivity/manifest/auth.ts +135 -0
  101. package/src/connectivity/manifest/bundles.ts +23 -0
  102. package/src/connectivity/manifest/connector.ts +168 -0
  103. package/src/connectivity/manifest/credential-ref.ts +73 -0
  104. package/src/connectivity/manifest/identity.ts +43 -0
  105. package/src/connectivity/manifest/index.ts +46 -0
  106. package/src/connectivity/manifest/primitives.ts +20 -0
  107. package/src/connectivity/manifest/provider.ts +208 -0
  108. package/src/connectivity/manifest/requirements.ts +146 -0
  109. package/src/connectivity/manifest/setup.ts +55 -0
  110. package/src/connectivity/provider.ts +163 -0
  111. package/src/connectivity/transports/README.md +33 -0
  112. package/src/connectivity/transports/composite/index.ts +68 -0
  113. package/src/connectivity/transports/dav/calendar.ts +217 -0
  114. package/src/connectivity/transports/dav/capabilities.ts +151 -0
  115. package/src/connectivity/transports/dav/client.ts +226 -0
  116. package/src/connectivity/transports/dav/contacts.ts +75 -0
  117. package/src/connectivity/transports/dav/ical.ts +412 -0
  118. package/src/connectivity/transports/dav/index.ts +143 -0
  119. package/src/connectivity/transports/dav/operations.ts +43 -0
  120. package/src/connectivity/transports/dav/request.ts +161 -0
  121. package/src/connectivity/transports/dav/xml.ts +123 -0
  122. package/src/connectivity/transports/factory.ts +181 -0
  123. package/src/connectivity/transports/fs/capabilities.ts +88 -0
  124. package/src/connectivity/transports/fs/commands.ts +258 -0
  125. package/src/connectivity/transports/fs/index.ts +121 -0
  126. package/src/connectivity/transports/fs/operations.ts +45 -0
  127. package/src/connectivity/transports/fs/paths.ts +120 -0
  128. package/src/connectivity/transports/fs/result.ts +12 -0
  129. package/src/connectivity/transports/http/index.ts +255 -0
  130. package/src/connectivity/transports/imap/attachment.ts +166 -0
  131. package/src/connectivity/transports/imap/capabilities.ts +158 -0
  132. package/src/connectivity/transports/imap/client.ts +398 -0
  133. package/src/connectivity/transports/imap/commands.ts +385 -0
  134. package/src/connectivity/transports/imap/index.ts +152 -0
  135. package/src/connectivity/transports/imap/operations.ts +64 -0
  136. package/src/connectivity/transports/imap/parse.ts +130 -0
  137. package/src/connectivity/transports/imap/parser.ts +272 -0
  138. package/src/connectivity/transports/imap/result.ts +15 -0
  139. package/src/connectivity/transports/imap/send.ts +92 -0
  140. package/src/connectivity/transports/imap/socket.ts +111 -0
  141. package/src/connectivity/transports/imap/utf7.ts +136 -0
  142. package/src/connectivity/transports/index.ts +20 -0
  143. package/src/connectivity/transports/local/index.ts +173 -0
  144. package/src/connectivity/transports/mcp/index.ts +215 -0
  145. package/src/deployments/README.md +63 -0
  146. package/src/deployments/adapters/audit-blob.ts +203 -0
  147. package/src/deployments/adapters/filesystem.ts +184 -0
  148. package/src/deployments/adapters/gcp-secret-manager.ts +492 -0
  149. package/src/deployments/adapters/gcs.ts +191 -0
  150. package/src/deployments/adapters/otlp.ts +128 -0
  151. package/src/deployments/adapters/s3.ts +195 -0
  152. package/src/deployments/azure/README.md +21 -0
  153. package/src/deployments/bootstrap.ts +177 -0
  154. package/src/deployments/deploy.ts +290 -0
  155. package/src/deployments/driver.ts +157 -0
  156. package/src/deployments/drivers.ts +35 -0
  157. package/src/deployments/gcp/Dockerfile +70 -0
  158. package/src/deployments/gcp/cloudbuild.yaml +31 -0
  159. package/src/deployments/gcp/driver.ts +175 -0
  160. package/src/deployments/gcp/gcloud.ts +178 -0
  161. package/src/deployments/gcp/provision.ts +290 -0
  162. package/src/deployments/gcp/survey.ts +319 -0
  163. package/src/deployments/local/README.md +12 -0
  164. package/src/deployments/prepare.ts +257 -0
  165. package/src/deployments/steps.ts +137 -0
  166. package/src/deployments/target.ts +295 -0
  167. package/src/deployments/upload.ts +207 -0
  168. package/src/dispatch/context.ts +195 -0
  169. package/src/dispatch/dispatch.ts +350 -0
  170. package/src/dispatch/index.ts +32 -0
  171. package/src/dispatch/staging.ts +102 -0
  172. package/src/policy/index.ts +179 -0
  173. package/src/policy/limits.ts +77 -0
  174. package/src/profile/authorization.ts +81 -0
  175. package/src/profile/files.ts +71 -0
  176. package/src/profile/index.ts +76 -0
  177. package/src/profile/layout.ts +123 -0
  178. package/src/profile/load.ts +199 -0
  179. package/src/profile/primitives.ts +45 -0
  180. package/src/profile/schema.ts +347 -0
  181. package/src/profile/secret-detection.ts +162 -0
  182. package/src/profile/targets.ts +152 -0
  183. package/src/profile/workspace.ts +262 -0
  184. package/src/providers/custom/index.ts +21 -0
  185. package/src/providers/custom/load.ts +115 -0
  186. package/src/providers/custom/template.ts +156 -0
  187. package/src/providers/example/provider.ts +207 -0
  188. package/src/providers/google/calendar/index.ts +66 -0
  189. package/src/providers/google/calendar/redact.ts +40 -0
  190. package/src/providers/google/contacts/index.ts +50 -0
  191. package/src/providers/google/contacts/redact.ts +21 -0
  192. package/src/providers/google/docs/index.ts +45 -0
  193. package/src/providers/google/drive/hints.ts +28 -0
  194. package/src/providers/google/drive/index.ts +34 -0
  195. package/src/providers/google/drive/redact.ts +39 -0
  196. package/src/providers/google/drive-mcp/index.ts +21 -0
  197. package/src/providers/google/gmail/api.ts +42 -0
  198. package/src/providers/google/gmail/attachment.ts +142 -0
  199. package/src/providers/google/gmail/hints.ts +55 -0
  200. package/src/providers/google/gmail/index.ts +112 -0
  201. package/src/providers/google/gmail/redact.ts +56 -0
  202. package/src/providers/google/gmail/send.ts +365 -0
  203. package/src/providers/google/gmail-mcp/index.ts +35 -0
  204. package/src/providers/google/index.ts +10 -0
  205. package/src/providers/google/shared/oauth.ts +122 -0
  206. package/src/providers/google/shared/scopes.ts +99 -0
  207. package/src/providers/google/shared/setup.ts +80 -0
  208. package/src/providers/google/sheets/hints.ts +45 -0
  209. package/src/providers/google/sheets/index.ts +70 -0
  210. package/src/providers/google/sheets/redact.ts +45 -0
  211. package/src/providers/google/specs/calendar.v3.json +1829 -0
  212. package/src/providers/google/specs/docs.v1.json +381 -0
  213. package/src/providers/google/specs/drive.v3.json +2208 -0
  214. package/src/providers/google/specs/gmail.v1.json +2578 -0
  215. package/src/providers/google/specs/people.v1.json +506 -0
  216. package/src/providers/google/specs/sheets.v4.json +1269 -0
  217. package/src/providers/google/specs/tasks.v1.json +840 -0
  218. package/src/providers/google/specs/vendor.ts +661 -0
  219. package/src/providers/google/tasks/index.ts +53 -0
  220. package/src/providers/google/tasks/redact.ts +34 -0
  221. package/src/providers/harness.ts +95 -0
  222. package/src/providers/icloud/calendar/index.ts +27 -0
  223. package/src/providers/icloud/contacts/index.ts +17 -0
  224. package/src/providers/icloud/drive/index.ts +47 -0
  225. package/src/providers/icloud/index.ts +8 -0
  226. package/src/providers/icloud/mail/index.ts +37 -0
  227. package/src/providers/icloud/shared/setup.ts +66 -0
  228. package/src/providers/index.ts +93 -0
  229. package/src/providers/linear/index.ts +11 -0
  230. package/src/providers/linear/scopes.ts +7 -0
  231. package/src/providers/memory/provider.ts +429 -0
  232. package/src/providers/notion/index.ts +19 -0
  233. package/src/providers/owner.ts +49 -0
  234. package/src/providers/scopes.ts +26 -0
  235. package/src/providers/setup/plan.ts +141 -0
  236. package/src/providers/setup/provider.ts +323 -0
  237. package/src/providers/shared/frontmatter.ts +119 -0
  238. package/src/providers/skills/provider.ts +283 -0
  239. package/src/providers/skills/store.ts +252 -0
  240. package/src/providers/vault/provider.ts +194 -0
  241. package/src/registry/index.ts +36 -0
  242. package/src/registry/policy-bridge.ts +32 -0
  243. package/src/registry/reconcile.ts +313 -0
  244. package/src/registry/registry.ts +240 -0
  245. package/src/secrets/document.ts +293 -0
  246. package/src/secrets/index.ts +154 -0
  247. package/src/secrets/system.ts +173 -0
  248. package/src/secrets/vault.ts +336 -0
  249. package/src/server/attachments.ts +197 -0
  250. package/src/server/container.ts +96 -0
  251. package/src/server/edge.ts +53 -0
  252. package/src/server/endpoint.ts +352 -0
  253. package/src/server/generations.ts +362 -0
  254. package/src/server/harness.ts +400 -0
  255. package/src/server/index.ts +331 -0
  256. package/src/server/logging.ts +41 -0
  257. package/src/server/mcp/build.ts +68 -0
  258. package/src/server/mcp/icon.ts +145 -0
  259. package/src/server/mcp/index.ts +32 -0
  260. package/src/server/mcp/instructions.ts +245 -0
  261. package/src/server/mcp/naming.ts +39 -0
  262. package/src/server/mcp/prompts.ts +78 -0
  263. package/src/server/mcp/resources.ts +106 -0
  264. package/src/server/mcp/routing.ts +117 -0
  265. package/src/server/mcp/schema.ts +78 -0
  266. package/src/server/mcp/tools.ts +186 -0
  267. package/src/server/mcp/visibility.ts +132 -0
  268. package/src/server/oauth.ts +222 -0
  269. package/src/server/rebinding.ts +53 -0
  270. package/src/server/stdio.ts +192 -0
  271. package/src/stores/blobs/conformance.ts +223 -0
  272. package/src/stores/blobs/index.ts +125 -0
  273. package/src/stores/blobs/testing.ts +49 -0
  274. package/src/stores/state/index.ts +247 -0
  275. package/src/stores/state/keys.ts +68 -0
  276. package/src/stores/state/testing.ts +41 -0
@@ -0,0 +1,94 @@
1
+ import type { SecretStore } from '#secrets';
2
+ import type { Config, ConnectionConfig } from '#profile';
3
+ import type { ProviderManifest } from '#connectivity';
4
+
5
+ /**
6
+ * Which connections belong to the same vendor account, and moving a credential
7
+ * when a provisional id turns out to be the wrong one.
8
+ *
9
+ * All of it is about one problem: a vendor is not a provider. iCloud is three
10
+ * providers on one Apple Account, so "the connections like this one" cannot be
11
+ * answered by comparing provider ids.
12
+ */
13
+
14
+ /** The credential group a provider belongs to, when it shares one with siblings. */
15
+ export function credentialApp(manifest: ProviderManifest): string | undefined {
16
+ const auth = manifest.auth;
17
+ return auth.kind === 'bearer' ||
18
+ auth.kind === 'api_key' ||
19
+ auth.kind === 'header' ||
20
+ auth.kind === 'basic'
21
+ ? auth.app
22
+ : undefined;
23
+ }
24
+
25
+ /**
26
+ * Connections belonging to the same vendor account as this provider would.
27
+ *
28
+ * `icloud_calendar` is a different provider from `icloud_mail` but the same
29
+ * Apple Account, so its connection id has to match — that is what makes both
30
+ * derive `icloud/<id>` and share the one app-specific password. Filtering on
31
+ * provider id alone would miss it and ask for the password three times.
32
+ */
33
+ export function accountSiblings(
34
+ manifest: ProviderManifest,
35
+ config: Config,
36
+ registry: { manifest(id: string): ProviderManifest | undefined },
37
+ ): ConnectionConfig[] {
38
+ const app = credentialApp(manifest);
39
+
40
+ return config.connections.filter((connection) => {
41
+ if (connection.provider === manifest.id) return true;
42
+ if (!app) return false;
43
+ const sibling = registry.manifest(connection.provider);
44
+ return sibling ? credentialApp(sibling) === app : false;
45
+ });
46
+ }
47
+
48
+ /**
49
+ * The one account a sibling provider already holds, if there is exactly one.
50
+ *
51
+ * Adopting its id is what turns the second and third `connect` of a family into
52
+ * "already stored" rather than a third identical password prompt. With two
53
+ * accounts in play there is nothing to infer, so it declines rather than
54
+ * guessing — the operator is asked, or names one with `--id`.
55
+ */
56
+ export function siblingAccountId(
57
+ manifest: ProviderManifest,
58
+ config: Config,
59
+ registry: { manifest(id: string): ProviderManifest | undefined },
60
+ ): string | undefined {
61
+ if (!credentialApp(manifest)) return undefined;
62
+
63
+ // Asked of the registry, not inferred from the id: `app` is a manifest field,
64
+ // and a provider is free to declare `app: icloud` under any name it likes.
65
+ const ids = new Set(
66
+ accountSiblings(manifest, config, registry)
67
+ .filter((connection) => connection.provider !== manifest.id)
68
+ .map((connection) => connection.id),
69
+ );
70
+
71
+ return ids.size === 1 ? [...ids][0] : undefined;
72
+ }
73
+
74
+ /** Whether an existing allow rule already covers the one we were about to add. */
75
+ export function matchesRule(existing: string, wanted: string): boolean {
76
+ return existing === '*' || existing === wanted;
77
+ }
78
+
79
+ /**
80
+ * Move a credential when a provisional id turns out to be the wrong one.
81
+ *
82
+ * Copy before delete: a crash between the two leaves the credential readable
83
+ * under the old name, where `doctor` will flag it, rather than gone.
84
+ */
85
+ export async function moveCredential(
86
+ credentials: SecretStore,
87
+ from: string,
88
+ to: string,
89
+ ): Promise<void> {
90
+ const value = await credentials.get(from);
91
+ if (value === null) return;
92
+ await credentials.set(to, value);
93
+ await credentials.delete(from);
94
+ }
@@ -0,0 +1,298 @@
1
+ import { auth, discoverOAuthProtectedResourceMetadata } from '@modelcontextprotocol/client';
2
+ import { CredentialOAuthProvider } from '#connectivity/auth/index.ts';
3
+ import type { SecretStore } from '#secrets';
4
+ import type { ProviderManifest } from '#connectivity';
5
+ import { ConfigDocument } from '../../config-edit.ts';
6
+ import { captureOAuthCallback, defaultOpenBrowser, runOAuthFlow } from '../../oauth.ts';
7
+ import { ok, progress, style, warn } from '../../output.ts';
8
+ import { terminalPrompter, type Prompter } from '../../prompt.ts';
9
+ import { describeScopes, shortScope } from '../../scopes.ts';
10
+ import { BROKERED, BrokerError } from '#connectivity/auth/index.ts';
11
+ import { brokerExchangeVia } from '../../oauth-exchange.ts';
12
+ import { brokeredScopes, hostedClientRefusal, resolveOAuthClient } from './client.ts';
13
+ import { confirmScopes } from './scopes-gate.ts';
14
+ import { ensureOAuthApp } from './setup.ts';
15
+
16
+ /**
17
+ * Getting a token, and saying what it will be able to do first.
18
+ *
19
+ * Two paths, because two kinds of upstream: an MCP server publishes metadata
20
+ * worth discovering and the SDK drives it, while a plain REST API announces
21
+ * nothing and the manifest has to name its endpoints.
22
+ */
23
+
24
+ export function oauthProviderFor(
25
+ manifest: ProviderManifest,
26
+ connectionId: string,
27
+ credentials: SecretStore,
28
+ redirectUrl?: string,
29
+ openBrowser?: (url: URL) => void,
30
+ state?: string,
31
+ ): CredentialOAuthProvider {
32
+ return new CredentialOAuthProvider({
33
+ manifest,
34
+ connectionId,
35
+ credentials,
36
+ scopes: manifest.auth.kind === 'oauth' ? manifest.auth.scopes : [],
37
+ ...(redirectUrl ? { redirectUrl } : {}),
38
+ ...(openBrowser ? { openBrowser } : {}),
39
+ ...(state ? { state } : {}),
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Drive the SDK's OAuth flow over a loopback callback.
45
+ *
46
+ * The SDK does discovery, registration, PKCE, and the exchange. We supply
47
+ * storage and a browser — and, for a manual registration, the client the vendor
48
+ * insists the operator supplies.
49
+ */
50
+ export async function authorise(input: {
51
+ manifest: ProviderManifest;
52
+ connectionId: string;
53
+ credentials: SecretStore;
54
+ document: ConfigDocument;
55
+ changes: string[];
56
+ /** No connection of this provider exists yet, so its console setup is undone. */
57
+ firstForProvider: boolean;
58
+ /** How the operator spelled the target, so a refusal names a command they typed. */
59
+ target?: string;
60
+ profile: string;
61
+ /** `--own-client`: register a client rather than using the one a broker runs. */
62
+ ownClient?: boolean;
63
+ prompter?: Prompter;
64
+ /** The operator has already said yes to scopes broader than the provider needs. */
65
+ acceptBroadScopes?: boolean;
66
+ /** Injected for tests. The broker is the only thing `connect` fetches. */
67
+ fetch?: typeof globalThis.fetch;
68
+ }): Promise<void> {
69
+ const { manifest, connectionId, credentials, document, changes } = input;
70
+ const prompter = input.prompter ?? terminalPrompter;
71
+ const acceptBroadScopes = input.acceptBroadScopes === true;
72
+ if (manifest.auth.kind !== 'oauth') return;
73
+
74
+ // `authoriseDirect` owns the client decision now: a brokered provider has
75
+ // nothing to prompt for, and asking first would ask for what it does not need.
76
+ //
77
+ // A non-MCP connector has nothing to discover auth from: a REST API is a base
78
+ // URL, and where its authorization server lives is not something it
79
+ // announces. So the manifest names the endpoints and we run the flow
80
+ // directly — the same loopback listener, PKCE, and exchange, minus the
81
+ // discovery the SDK would otherwise do for us.
82
+ if (manifest.connector.kind !== 'mcp') {
83
+ await authoriseDirect(input);
84
+ return;
85
+ }
86
+
87
+ // An MCP provider is always bring-your-own: `defineProvider` refuses a broker
88
+ // on one, because the SDK owns the exchange and there is no seam to route it.
89
+ if (manifest.auth.registration === 'manual') {
90
+ await ensureOAuthApp(input);
91
+ }
92
+
93
+ const serverUrl = manifest.connector.endpoint;
94
+
95
+ const callback = captureOAuthCallback({ label: manifest.name });
96
+
97
+ try {
98
+ const provider = oauthProviderFor(
99
+ manifest,
100
+ connectionId,
101
+ credentials,
102
+ callback.redirectUri,
103
+ (url) => {
104
+ progress(style.dim('Opening your browser to authorise…'));
105
+ progress(style.dim(`If it did not open: ${url.href}`));
106
+ defaultOpenBrowser(url.href);
107
+ },
108
+ callback.state,
109
+ );
110
+
111
+ // Request exactly the scopes the manifest declares, and say what they are.
112
+ //
113
+ // Pinning the set matters: left alone the SDK asks for the union of
114
+ // everything the resource advertises, so a vendor could widen a grant by
115
+ // editing its own metadata. But pinning is not the same as narrowing —
116
+ // Google's servers reject a subset of what they advertise, which is why
117
+ // the Gmail and Drive manifests carry the full set rather than the two
118
+ // scopes their docs name. Where we cannot reduce the grant we can at least
119
+ // refuse to make it quietly.
120
+ if (!(await confirmScopes(manifest, serverUrl, prompter, acceptBroadScopes))) {
121
+ throw new Error('Cancelled — nothing was authorised.');
122
+ }
123
+
124
+ const scope = manifest.auth.scopes.length > 0 ? manifest.auth.scopes.join(' ') : undefined;
125
+
126
+ const first = await auth(provider as never, {
127
+ serverUrl,
128
+ ...(scope ? { scope } : {}),
129
+ });
130
+
131
+ if (first === 'AUTHORIZED') {
132
+ progress(ok('already authorised'));
133
+ return;
134
+ }
135
+
136
+ const { code, iss } = await callback.wait();
137
+
138
+ // `iss` is forwarded for RFC 9207 issuer validation: it is what lets the
139
+ // SDK detect an authorization-server mix-up before redeeming the code.
140
+ // Capturing it and dropping it would silently give up that defence.
141
+ await auth(provider as never, {
142
+ serverUrl,
143
+ authorizationCode: code,
144
+ ...(scope ? { scope } : {}),
145
+ ...(iss ? { iss } : {}),
146
+ });
147
+
148
+ if (manifest.auth.registration === 'dynamic') {
149
+ // Worth saying out loud: this is the setup that did not happen.
150
+ changes.push(`registered with ${new URL(serverUrl).host} automatically`);
151
+ }
152
+
153
+ progress(ok('authorised'));
154
+ } finally {
155
+ await callback.close();
156
+ }
157
+
158
+ void document;
159
+ }
160
+
161
+ /**
162
+ * OAuth against a plain REST API, where there is nothing to discover.
163
+ *
164
+ * `runOAuthFlow` is the M1 path, kept because it turns out to be exactly what a
165
+ * non-MCP provider needs: give it endpoints, a client, and scopes, and it does
166
+ * the loopback listener, PKCE, and the exchange. The MCP branch above uses the
167
+ * SDK instead only because MCP servers publish metadata worth discovering.
168
+ *
169
+ * The token blob is written in the same shape `CredentialOAuthProvider` reads,
170
+ * so refresh, expiry, and staleness reporting all work the same afterwards —
171
+ * a connection should not behave differently at runtime because of how it was
172
+ * authorised.
173
+ */
174
+ async function authoriseDirect(input: {
175
+ manifest: ProviderManifest;
176
+ connectionId: string;
177
+ credentials: SecretStore;
178
+ document: ConfigDocument;
179
+ changes: string[];
180
+ firstForProvider: boolean;
181
+ target?: string;
182
+ profile: string;
183
+ ownClient?: boolean;
184
+ prompter?: Prompter;
185
+ acceptBroadScopes?: boolean;
186
+ fetch?: typeof globalThis.fetch;
187
+ }): Promise<void> {
188
+ const { manifest, connectionId, credentials } = input;
189
+ const prompter = input.prompter ?? terminalPrompter;
190
+ if (manifest.auth.kind !== 'oauth') return;
191
+
192
+ const { authorize_url: authorizeUrl, token_url: tokenUrl, app } = manifest.auth;
193
+ if (!authorizeUrl || !tokenUrl) {
194
+ throw new Error(
195
+ `Provider "${manifest.id}" uses a ${manifest.connector.kind} connector, which cannot discover ` +
196
+ 'its authorization server. Its manifest must set auth.authorize_url and auth.token_url.',
197
+ );
198
+ }
199
+
200
+ const client = await resolveOAuthClient({
201
+ manifest,
202
+ credentials,
203
+ document: input.document,
204
+ changes: input.changes,
205
+ firstForProvider: input.firstForProvider,
206
+ ownClient: input.ownClient === true,
207
+ target: input.target ?? manifest.id,
208
+ profile: input.profile,
209
+ ...(input.prompter ? { prompter: input.prompter } : {}),
210
+ ...(input.fetch ? { fetch: input.fetch } : {}),
211
+ });
212
+
213
+ const scopes =
214
+ client.kind === 'brokered'
215
+ ? brokeredScopes(manifest.auth.scopes, client.config).scopes
216
+ : manifest.auth.scopes;
217
+
218
+ if (
219
+ !(await confirmScopes(
220
+ manifest,
221
+ authorizeUrl,
222
+ prompter,
223
+ input.acceptBroadScopes === true,
224
+ scopes,
225
+ ))
226
+ ) {
227
+ throw new Error('Cancelled — nothing was authorised.');
228
+ }
229
+
230
+ let tokens;
231
+ try {
232
+ tokens = await runOAuthFlow({
233
+ authorizeUrl,
234
+ clientId: client.kind === 'own' ? client.clientId : client.config.clientId,
235
+ ...(client.kind === 'own'
236
+ ? { tokenUrl, clientSecret: client.clientSecret }
237
+ : {
238
+ exchange: brokerExchangeVia({
239
+ url: client.url,
240
+ ...(input.fetch ? { fetch: input.fetch } : {}),
241
+ }),
242
+ }),
243
+ scopes,
244
+ connectionLabel: manifest.name,
245
+ ...(manifest.auth.authorize_params
246
+ ? { authorizeParams: manifest.auth.authorize_params }
247
+ : {}),
248
+ onPrompt: (url) => {
249
+ progress(style.dim('Opening your browser to authorise…'));
250
+ progress(style.dim(`If it did not open: ${url}`));
251
+ },
252
+ });
253
+ } catch (cause) {
254
+ // A broker refusal after consent is worth its own sentence: the operator
255
+ // has already approved the screen, and "nothing was stored" is the fact
256
+ // they need before they decide whether to try again.
257
+ if (cause instanceof BrokerError && client.kind === 'brokered') {
258
+ throw hostedClientRefusal({
259
+ manifest,
260
+ target: input.target ?? manifest.id,
261
+ profile: input.profile,
262
+ operator: manifest.auth.broker?.operator ?? 'this project',
263
+ cause: cause.message,
264
+ ...(cause.notice ? { notice: cause.notice } : {}),
265
+ ...(cause.docsUrl ? { docsUrl: cause.docsUrl } : {}),
266
+ afterConsent: true,
267
+ // A replayed code is not solved by an hour in a console, and the broker
268
+ // is the one that knows which of its refusals are.
269
+ ownClient: cause.ownClient,
270
+ });
271
+ }
272
+ throw cause;
273
+ }
274
+
275
+ await credentials.set(
276
+ `${manifest.id}/${connectionId}`,
277
+ JSON.stringify({
278
+ access_token: tokens.accessToken,
279
+ refresh_token: tokens.refreshToken,
280
+ token_type: 'Bearer',
281
+ expires_in: tokens.expiresIn,
282
+ expires_at: Date.now() + tokens.expiresIn * 1000,
283
+ scope: tokens.scope,
284
+ issuer: new URL(authorizeUrl).origin,
285
+ ...(tokens.idToken ? { id_token: tokens.idToken } : {}),
286
+ // Which client issued this, stamped once at connect.
287
+ //
288
+ // A property of the token, not of the profile: a refresh token minted by
289
+ // one client is refused by another, so an operator who registers a client
290
+ // of their own six months from now must not drag existing connections
291
+ // onto it. Absent means "the profile's own", which every credential
292
+ // written before this feature already is.
293
+ ...(client.kind === 'brokered' ? { authorized_via: BROKERED } : {}),
294
+ }),
295
+ );
296
+
297
+ progress(ok('authorised'));
298
+ }
@@ -0,0 +1,284 @@
1
+ import {
2
+ BROKER_ORIGIN_ENV,
3
+ BrokerError,
4
+ brokerConfig,
5
+ brokerOriginOverride,
6
+ type BrokerConfig,
7
+ } from '#connectivity/auth/index.ts';
8
+ import type { ProviderManifest } from '#connectivity';
9
+ import type { SecretStore } from '#secrets';
10
+ import { ConfigDocument } from '../../config-edit.ts';
11
+ import { progress, style, warn } from '../../output.ts';
12
+ import type { Prompter } from '../../prompt.ts';
13
+ import { shortScope } from '../../scopes.ts';
14
+ import { declareOwnClient, ensureOAuthApp } from './setup.ts';
15
+
16
+ /**
17
+ * Which OAuth client this authorisation uses, and whether it can be used.
18
+ *
19
+ * Two arrangements, and the manifest does not pick between them — the profile
20
+ * does. Declaring an `oauth_apps` entry means "I registered a client, use it";
21
+ * leaving it out means "use the one the broker operates". So a profile that has
22
+ * already taken the trouble to register one is never moved off it, and everyone
23
+ * else gets the path with no console in it.
24
+ *
25
+ * Everything here happens *before the browser opens*. A refusal after consent
26
+ * has been given is a refusal the operator has already paid for, so the
27
+ * checks that can be made early are made early.
28
+ */
29
+
30
+ export type OAuthClient =
31
+ | { readonly kind: 'own'; readonly clientId: string; readonly clientSecret: string }
32
+ | { readonly kind: 'brokered'; readonly url: string; readonly config: BrokerConfig };
33
+
34
+ export interface ClientChoice {
35
+ readonly manifest: ProviderManifest;
36
+ readonly credentials: SecretStore;
37
+ readonly document: ConfigDocument;
38
+ readonly changes: string[];
39
+ /** No connection of this provider exists yet, so its console setup is undone. */
40
+ readonly firstForProvider: boolean;
41
+ /** `--own-client`: register one rather than using the client the broker runs. */
42
+ readonly ownClient: boolean;
43
+ /** How the operator spelled the target, so a refusal names a command they typed. */
44
+ readonly target: string;
45
+ readonly profile: string;
46
+ readonly prompter?: Prompter | undefined;
47
+ readonly fetch?: typeof globalThis.fetch | undefined;
48
+ }
49
+
50
+ export async function resolveOAuthClient(input: ClientChoice): Promise<OAuthClient> {
51
+ const { manifest, credentials, document } = input;
52
+ if (manifest.auth.kind !== 'oauth') {
53
+ throw new Error(`Provider "${manifest.id}" does not use OAuth.`);
54
+ }
55
+
56
+ const { app, broker } = manifest.auth;
57
+
58
+ if (!broker || (await profileHasOwnClient(app, document, credentials)) || input.ownClient) {
59
+ if (input.ownClient && broker && !hasClientPrompts(manifest)) {
60
+ // `defineProvider` permits a broker with no prompts — a provider with no
61
+ // bring-your-own path is a legal thing to be. This is where that absence
62
+ // becomes a sentence rather than a prompt for a value nothing collects.
63
+ throw new Error(
64
+ `${manifest.name} has no bring-your-own client path: it does not describe what to ` +
65
+ `register. Drop --own-client to authorise against the client ${broker.operator} operates.`,
66
+ );
67
+ }
68
+ await ensureOAuthApp({
69
+ manifest,
70
+ credentials,
71
+ document,
72
+ changes: input.changes,
73
+ firstForProvider: input.firstForProvider,
74
+ ...(input.prompter ? { prompter: input.prompter } : {}),
75
+ });
76
+ if (input.ownClient) declareOwnClient(document, manifest, input.changes);
77
+
78
+ const [clientId, clientSecret] = app
79
+ ? await Promise.all([
80
+ credentials.get(`${app}/client_id`),
81
+ credentials.get(`${app}/client_secret`),
82
+ ])
83
+ : [null, null];
84
+
85
+ if (!clientId || !clientSecret) {
86
+ throw new Error(
87
+ `No OAuth client stored for "${app}". Run: lanes link connect ${manifest.id}`,
88
+ );
89
+ }
90
+ return { kind: 'own', clientId, clientSecret };
91
+ }
92
+
93
+ // Before the fetch, so it is said even when the broker cannot be reached.
94
+ // The danger is not someone who set this deliberately; it is the variable
95
+ // still exported in a shell three days later, quietly sending a real
96
+ // authorization code somewhere other than where the operator believes.
97
+ const overridden = brokerOriginOverride();
98
+ if (overridden) {
99
+ progress(
100
+ warn(
101
+ `${BROKER_ORIGIN_ENV} is set — the authorization code will be exchanged at ${overridden}, ` +
102
+ `not by ${broker.operator}.`,
103
+ ),
104
+ );
105
+ }
106
+
107
+ let config: BrokerConfig;
108
+ try {
109
+ config = await brokerConfig(broker.url, input.fetch ?? globalThis.fetch);
110
+ } catch (cause) {
111
+ throw hostedClientRefusal({
112
+ manifest,
113
+ target: input.target,
114
+ profile: input.profile,
115
+ operator: broker.operator,
116
+ cause: cause instanceof Error ? cause.message : String(cause),
117
+ ...(cause instanceof BrokerError && cause.notice ? { notice: cause.notice } : {}),
118
+ docsUrl: broker.docs_url,
119
+ });
120
+ }
121
+
122
+ if (!config.open) {
123
+ throw hostedClientRefusal({
124
+ manifest,
125
+ target: input.target,
126
+ profile: input.profile,
127
+ operator: broker.operator,
128
+ cause: 'it is not accepting new connections.',
129
+ ...(config.notice ? { notice: config.notice } : {}),
130
+ docsUrl: config.docsUrl ?? broker.docs_url,
131
+ });
132
+ }
133
+
134
+ const { unsupported } = brokeredScopes(manifest.auth.scopes, config);
135
+ if (unsupported.length > 0) {
136
+ throw hostedClientRefusal({
137
+ manifest,
138
+ target: input.target,
139
+ profile: input.profile,
140
+ operator: broker.operator,
141
+ cause:
142
+ `it is not registered for ${unsupported.length === 1 ? 'a scope' : `${unsupported.length} scopes`} ` +
143
+ `${manifest.name} needs:\n ${unsupported.map(shortScope).join('\n ')}`,
144
+ docsUrl: config.docsUrl ?? broker.docs_url,
145
+ });
146
+ }
147
+
148
+ if (config.capacity && config.capacity.cap > 0) {
149
+ const left = config.capacity.cap - config.capacity.accounts;
150
+ // Advisory, never a refusal. The vendor is the authority on its own cap and
151
+ // still admits accounts that have already granted once, so refusing here
152
+ // would lock out exactly the people it would still let through.
153
+ if (left <= 10) {
154
+ // `warn` formats; `progress` is what puts it on the stream. Called bare it
155
+ // builds the sentence and drops it, which is how this one went unsaid.
156
+ progress(
157
+ warn(
158
+ `The ${broker.operator} client is near capacity (${config.capacity.accounts} of ${config.capacity.cap} accounts).`,
159
+ ),
160
+ );
161
+ }
162
+ }
163
+
164
+ progress(
165
+ style.dim(
166
+ `Authorising against the OAuth client ${broker.operator} operates — nothing to register.`,
167
+ ),
168
+ );
169
+
170
+ return { kind: 'brokered', url: broker.url, config };
171
+ }
172
+
173
+ /**
174
+ * Whether this profile holds a client of its own.
175
+ *
176
+ * The config entry is the declaration, but the store is consulted too: someone
177
+ * who placed the two values by hand and whose config lost the block keeps their
178
+ * client rather than being quietly moved onto a different one, where every
179
+ * existing refresh token would stop working.
180
+ */
181
+ async function profileHasOwnClient(
182
+ app: string | undefined,
183
+ document: ConfigDocument,
184
+ credentials: SecretStore,
185
+ ): Promise<boolean> {
186
+ if (!app) return false;
187
+ if (document.getIn(['oauth_apps', app]) !== undefined) return true;
188
+
189
+ const [id, secret] = await Promise.all([
190
+ credentials.get(`${app}/client_id`),
191
+ credentials.get(`${app}/client_secret`),
192
+ ]);
193
+ return Boolean(id && secret);
194
+ }
195
+
196
+ function hasClientPrompts(manifest: ProviderManifest): boolean {
197
+ return (manifest.setup?.prompts ?? []).some((prompt) => prompt.scope === 'shared');
198
+ }
199
+
200
+ /**
201
+ * What is actually asked for, and what the broker cannot grant.
202
+ *
203
+ * The identity scopes are appended rather than assumed: they are what lets the
204
+ * broker tell one caller's refresh from another's, and the broker says which
205
+ * ones it wants so it can change them without a new CLI. They are added *only*
206
+ * on this path — for a client the operator registered there is nobody to
207
+ * identify to, and asking would cost them a re-consent and two extra lines in
208
+ * their own console for no benefit at all.
209
+ */
210
+ export function brokeredScopes(
211
+ wanted: readonly string[],
212
+ config: BrokerConfig,
213
+ ): { readonly scopes: readonly string[]; readonly unsupported: readonly string[] } {
214
+ // An empty `scopes_supported` means the broker did not say, not that it
215
+ // supports nothing. Treating silence as refusal would break every connection
216
+ // against a broker that simply does not advertise.
217
+ const unsupported =
218
+ config.scopesSupported.length > 0
219
+ ? wanted.filter((scope) => !config.scopesSupported.includes(scope))
220
+ : [];
221
+
222
+ const scopes = [...wanted];
223
+ for (const scope of config.identityScopes) if (!scopes.includes(scope)) scopes.push(scope);
224
+
225
+ return { scopes, unsupported };
226
+ }
227
+
228
+ /** The refusal that names the way out, with the command already filled in. */
229
+ export function hostedClientRefusal(input: {
230
+ manifest: ProviderManifest;
231
+ target: string;
232
+ profile: string;
233
+ operator: string;
234
+ cause: string;
235
+ notice?: string | undefined;
236
+ docsUrl?: string | undefined;
237
+ /** Consent has already been given, so say so before saying nothing was kept. */
238
+ afterConsent?: boolean | undefined;
239
+ /**
240
+ * Whether registering a client of your own is the way past this.
241
+ *
242
+ * The broker knows and this does not: a spent capacity and a replayed
243
+ * authorization code are both a 4xx, and offering an hour in a cloud console
244
+ * as the fix for the second is worse than offering nothing. Defaults to true
245
+ * because the refusals raised before the browser opens are all of the first
246
+ * kind — a broker that is closed, unreachable, or short a scope.
247
+ */
248
+ ownClient?: boolean | undefined;
249
+ }): Error {
250
+ const lines = [
251
+ `${input.manifest.name} could not be authorised against the OAuth client ${input.operator} operates.`,
252
+ '',
253
+ ];
254
+
255
+ if (input.afterConsent) {
256
+ lines.push(
257
+ ' You approved the consent screen, but the token exchange was refused, so nothing',
258
+ ' was stored.',
259
+ '',
260
+ );
261
+ }
262
+
263
+ // The reason is the broker's to word, so a spent capacity, a suspension and a
264
+ // maintenance window can read differently without shipping a new CLI.
265
+ lines.push(` ${input.notice ?? input.cause}`, '');
266
+ if (input.notice) lines.push(` (${input.cause})`, '');
267
+
268
+ lines.push(' Nothing was written and no account was connected.');
269
+
270
+ if (input.ownClient !== false) {
271
+ lines.push(
272
+ '',
273
+ ' Register your own OAuth client instead:',
274
+ ` lanes link connect ${input.target} --profile ${input.profile} --own-client`,
275
+ '',
276
+ ' That walks through the vendor’s console once and then covers every account on',
277
+ ' this profile.',
278
+ );
279
+ }
280
+
281
+ if (input.docsUrl) lines.push(` ${input.docsUrl}`);
282
+
283
+ return new Error(lines.join('\n'));
284
+ }