@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,261 @@
1
+ /**
2
+ * Talking to a service that holds an OAuth client secret this machine cannot.
3
+ *
4
+ * Three requests, one shape each, and no knowledge of whose client is on the
5
+ * other end — the URL arrives as declared manifest data. Both callers live in
6
+ * different components (the CLI performs the first exchange, the dispatcher
7
+ * refreshes while serving), and putting the wire format here is what stops the
8
+ * two drifting into disagreeing about it.
9
+ *
10
+ * Everything here is a plain `fetch` against an operator-declared origin. There
11
+ * is deliberately no retry: a broker that is down should surface as a refusal
12
+ * the operator can read, not as a command that hangs.
13
+ */
14
+
15
+ /**
16
+ * Stamped on a credential this broker issued, and read back at refresh.
17
+ *
18
+ * The one fact the refresh path cannot derive: which client minted the token it
19
+ * is holding. Config cannot answer it, because config can change afterwards.
20
+ */
21
+ export const BROKERED = 'broker';
22
+
23
+ /** What the broker will authorise, and whether it is currently doing so. */
24
+ export interface BrokerConfig {
25
+ readonly clientId: string;
26
+ /** Every scope the client is registered for. Empty means "unstated". */
27
+ readonly scopesSupported: readonly string[];
28
+ /** Added to every request so the exchange returns an identity assertion. */
29
+ readonly identityScopes: readonly string[];
30
+ readonly open: boolean;
31
+ /** Why it is closed, or near capacity. The broker's words, printed verbatim. */
32
+ readonly notice: string | undefined;
33
+ readonly docsUrl: string | undefined;
34
+ /** How full the shared client is, when the broker says. Advisory. */
35
+ readonly capacity: { readonly accounts: number; readonly cap: number } | undefined;
36
+ }
37
+
38
+ /** A token response, in the vendor's own field names. */
39
+ export interface BrokerTokens {
40
+ readonly access_token?: string;
41
+ readonly refresh_token?: string;
42
+ readonly id_token?: string;
43
+ readonly expires_in?: number;
44
+ readonly scope?: string;
45
+ readonly token_type?: string;
46
+ }
47
+
48
+ /**
49
+ * A refusal, carrying what the caller needs to explain it.
50
+ *
51
+ * `ownClient` is the broker saying "registering a client of your own is the way
52
+ * past this". It matters because the two failures look identical otherwise: a
53
+ * shared client at capacity and a replayed authorization code are both a 4xx,
54
+ * and only one of them is solved by an hour in a cloud console.
55
+ */
56
+ export class BrokerError extends Error {
57
+ readonly status: number;
58
+ readonly code: string | undefined;
59
+ readonly notice: string | undefined;
60
+ readonly ownClient: boolean;
61
+ readonly docsUrl: string | undefined;
62
+ readonly retryAfterSeconds: number | undefined;
63
+
64
+ constructor(
65
+ message: string,
66
+ fields: {
67
+ status: number;
68
+ code?: string | undefined;
69
+ notice?: string | undefined;
70
+ ownClient?: boolean | undefined;
71
+ docsUrl?: string | undefined;
72
+ retryAfterSeconds?: number | undefined;
73
+ },
74
+ ) {
75
+ super(message);
76
+ this.name = 'BrokerError';
77
+ this.status = fields.status;
78
+ this.code = fields.code;
79
+ this.notice = fields.notice;
80
+ this.ownClient = fields.ownClient === true;
81
+ this.docsUrl = fields.docsUrl;
82
+ this.retryAfterSeconds = fields.retryAfterSeconds;
83
+ }
84
+ }
85
+
86
+ type Fetch = typeof globalThis.fetch;
87
+
88
+ export const BROKER_ORIGIN_ENV = 'LANES_LINK_BROKER_ORIGIN';
89
+
90
+ /** Loopback, in the spellings a `URL` will hand back for one. */
91
+ const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
92
+
93
+ /**
94
+ * Aiming the exchange somewhere other than the origin the manifest declares.
95
+ *
96
+ * A broker running on this machine and a staging deployment are the same
97
+ * problem: everything about the flow is unchanged except which host holds the
98
+ * secret. An origin is the whole of the difference, so a provider keeps its own
99
+ * path and only the origin in front of it moves.
100
+ *
101
+ * It refuses rather than falls back, because the failure it would otherwise
102
+ * cause is the expensive kind — believing you are exercising a local broker
103
+ * while a real authorization code goes to production. A variable that is
104
+ * ignored when malformed is worse than one that stops the command.
105
+ *
106
+ * `http` is confined to loopback for the same reason the redirect is: off this
107
+ * machine it puts an authorization code on the wire in the clear.
108
+ */
109
+ export function brokerOriginOverride(
110
+ env: Record<string, string | undefined> = process.env as Record<string, string | undefined>,
111
+ ): string | undefined {
112
+ const raw = env[BROKER_ORIGIN_ENV]?.trim();
113
+ if (!raw) return undefined;
114
+
115
+ let url: URL;
116
+ try {
117
+ url = new URL(raw);
118
+ } catch {
119
+ throw new Error(`${BROKER_ORIGIN_ENV} is not a URL: ${raw}`);
120
+ }
121
+
122
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
123
+ // `localhost:8080` is the mistake people make, and it *parses* — as a URL
124
+ // whose scheme is "localhost". Saying which scheme it read is what turns
125
+ // that from a baffling refusal into an obvious missing `http://`.
126
+ throw new Error(
127
+ `${BROKER_ORIGIN_ENV} must be an http or https URL. "${raw}" reads as scheme ` +
128
+ `"${url.protocol.replace(':', '')}" — a host and port with no scheme becomes one.`,
129
+ );
130
+ }
131
+ if (url.protocol === 'http:' && !LOOPBACK.has(url.hostname)) {
132
+ throw new Error(
133
+ `${BROKER_ORIGIN_ENV} may only be http for a loopback host. "${url.hostname}" over http ` +
134
+ `would put the authorization code on the wire in the clear — use https.`,
135
+ );
136
+ }
137
+ return url.origin;
138
+ }
139
+
140
+ /** The `{success, data}` envelope, unwrapped, or thrown as a `BrokerError`. */
141
+ async function unwrap(response: Response, what: string): Promise<Record<string, unknown>> {
142
+ const text = await response.text();
143
+ let body: Record<string, unknown> = {};
144
+ try {
145
+ body = text ? (JSON.parse(text) as Record<string, unknown>) : {};
146
+ } catch {
147
+ // A proxy or a captive portal answering in HTML is a real failure mode, and
148
+ // "Unexpected token <" tells the operator nothing about what went wrong.
149
+ if (response.ok) {
150
+ throw new BrokerError(`${what} returned a response that was not JSON.`, {
151
+ status: response.status,
152
+ });
153
+ }
154
+ }
155
+
156
+ if (!response.ok || body['success'] === false) {
157
+ const retry = Number(response.headers.get('retry-after'));
158
+ throw new BrokerError(str(body['error']) ?? `${what} failed (${response.status}).`, {
159
+ status: response.status,
160
+ code: str(body['code']),
161
+ notice: str(body['notice']),
162
+ ownClient: body['own_client'] === true,
163
+ docsUrl: str(body['docs_url']),
164
+ retryAfterSeconds: Number.isFinite(retry) && retry > 0 ? retry : undefined,
165
+ });
166
+ }
167
+
168
+ const data = body['data'];
169
+ return data !== null && typeof data === 'object' ? (data as Record<string, unknown>) : body;
170
+ }
171
+
172
+ const str = (value: unknown): string | undefined =>
173
+ typeof value === 'string' && value.length > 0 ? value : undefined;
174
+
175
+ const strings = (value: unknown): string[] =>
176
+ Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
177
+
178
+ async function post(
179
+ url: string,
180
+ body: unknown,
181
+ what: string,
182
+ fetchImpl: Fetch,
183
+ headers: Record<string, string> = {},
184
+ ): Promise<Record<string, unknown>> {
185
+ let response: Response;
186
+ try {
187
+ response = await fetchImpl(url, {
188
+ method: 'POST',
189
+ headers: { 'content-type': 'application/json', ...headers },
190
+ body: JSON.stringify(body),
191
+ });
192
+ } catch (cause) {
193
+ throw new BrokerError(`${what} could not be reached (${String(cause)}).`, { status: 0 });
194
+ }
195
+ return await unwrap(response, what);
196
+ }
197
+
198
+ export async function brokerConfig(
199
+ url: string,
200
+ fetchImpl: Fetch = globalThis.fetch,
201
+ ): Promise<BrokerConfig> {
202
+ let response: Response;
203
+ try {
204
+ response = await fetchImpl(`${url}/config`, { headers: { accept: 'application/json' } });
205
+ } catch (cause) {
206
+ throw new BrokerError(`${url} could not be reached (${String(cause)}).`, { status: 0 });
207
+ }
208
+ const data = await unwrap(response, url);
209
+
210
+ const clientId = str(data['client_id']);
211
+ if (!clientId) {
212
+ throw new BrokerError(`${url} did not return a client id.`, { status: response.status });
213
+ }
214
+
215
+ const capacity = data['capacity'];
216
+ return {
217
+ clientId,
218
+ scopesSupported: strings(data['scopes_supported']),
219
+ identityScopes: strings(data['identity_scopes']),
220
+ open: data['status'] !== 'closed',
221
+ notice: str(data['notice']),
222
+ docsUrl: str(data['docs_url']),
223
+ capacity:
224
+ capacity !== null && typeof capacity === 'object'
225
+ ? {
226
+ accounts: Number((capacity as Record<string, unknown>)['accounts']) || 0,
227
+ cap: Number((capacity as Record<string, unknown>)['cap']) || 0,
228
+ }
229
+ : undefined,
230
+ };
231
+ }
232
+
233
+ export async function brokerExchange(
234
+ url: string,
235
+ input: { code: string; codeVerifier: string; redirectUri: string },
236
+ fetchImpl: Fetch = globalThis.fetch,
237
+ ): Promise<BrokerTokens> {
238
+ return (await post(
239
+ `${url}/exchange`,
240
+ { code: input.code, code_verifier: input.codeVerifier, redirect_uri: input.redirectUri },
241
+ url,
242
+ fetchImpl,
243
+ )) as BrokerTokens;
244
+ }
245
+
246
+ export async function brokerRefresh(
247
+ url: string,
248
+ input: { refreshToken: string; idToken?: string | undefined },
249
+ fetchImpl: Fetch = globalThis.fetch,
250
+ ): Promise<BrokerTokens> {
251
+ // The assertion goes in a header rather than the body so the broker can
252
+ // attribute and rate-limit the call before it parses anything, and so it does
253
+ // not land in whatever logs request bodies.
254
+ return (await post(
255
+ `${url}/refresh`,
256
+ { refresh_token: input.refreshToken },
257
+ url,
258
+ fetchImpl,
259
+ input.idToken ? { authorization: `Bearer ${input.idToken}` } : {},
260
+ )) as BrokerTokens;
261
+ }
@@ -0,0 +1,64 @@
1
+ import { auth } from '@modelcontextprotocol/client';
2
+ import type { ProviderManifest } from '#connectivity';
3
+ import type { SecretStore } from '#secrets';
4
+ import { CredentialOAuthProvider, upstreamAccessToken } from './provider.ts';
5
+ import { refreshDirectly } from './refresh.ts';
6
+
7
+ /**
8
+ * OAuth 2.0 authorization code — the three-legged flow, with a refresh token
9
+ * held in the secret store.
10
+ *
11
+ * The only method here that needs more than a stored string: a browser round
12
+ * trip at connect time, a refresh on every use, and two different ways to run
13
+ * that refresh depending on whether the provider has a metadata document.
14
+ *
15
+ * The other flows the credential-type list names — JWT bearer, client
16
+ * credentials — are sibling folders that do not exist yet. See ../README.md.
17
+ */
18
+
19
+ export async function resolveUpstreamToken(
20
+ manifest: ProviderManifest,
21
+ connectionId: string,
22
+ credentials: SecretStore,
23
+ ): Promise<string | null> {
24
+ if (manifest.auth.kind !== 'oauth') return null;
25
+
26
+ const provider = new CredentialOAuthProvider({
27
+ manifest,
28
+ connectionId,
29
+ credentials,
30
+ scopes: manifest.auth.scopes,
31
+ });
32
+
33
+ // Where to re-run discovery from, if a refresh needs it. An `http` connector
34
+ // has no metadata document, so it names its authorization server in the
35
+ // manifest instead — and the refresh below only ever exchanges a stored
36
+ // refresh token, which needs the token endpoint and nothing else.
37
+ const endpoint =
38
+ manifest.connector.kind === 'mcp' ? manifest.connector.endpoint : manifest.auth.token_url;
39
+
40
+ return upstreamAccessToken({
41
+ connectionKey: `${manifest.id}.${connectionId}`,
42
+ provider,
43
+ async refresh(target) {
44
+ // No browser here on purpose: this path runs while serving requests, and
45
+ // a refresh that silently opened one would be both useless and alarming.
46
+ // If re-consent is genuinely needed the provider throws with the command
47
+ // that fixes it.
48
+ if (!endpoint) throw new Error(`Provider "${manifest.id}" declares no token endpoint.`);
49
+
50
+ // A non-MCP provider refreshes directly. The SDK's `auth()` wants to
51
+ // rediscover the authorization server first, which a REST API does not
52
+ // describe — it fails with "prepareTokenRequest() or authorizationCode is
53
+ // required", which says nothing about the real problem. Exchanging a
54
+ // stored refresh token needs the token endpoint and the client, and
55
+ // nothing else.
56
+ if (manifest.connector.kind !== 'mcp') {
57
+ return (await refreshDirectly(manifest, target, endpoint, credentials)) as never;
58
+ }
59
+
60
+ await auth(target as never, { serverUrl: endpoint });
61
+ return (await target.tokens()) as never;
62
+ },
63
+ });
64
+ }
@@ -0,0 +1,279 @@
1
+ import type { SecretStore } from '#secrets';
2
+ import type { ProviderManifest } from '#connectivity';
3
+
4
+ /**
5
+ * The SDK's `OAuthClientProvider`, backed by our `SecretStore`.
6
+ *
7
+ * This one class is the whole reason a provider is now a manifest. The SDK
8
+ * drives discovery, registration, authorization, and refresh; we only say where
9
+ * the resulting material lives. **DCR and bring-your-own-client differ by a
10
+ * single method**: whether `clientInformation()` returns something.
11
+ *
12
+ * - returns a stored client → the SDK uses it (Google)
13
+ * - returns `undefined` → the SDK registers via DCR (Notion, Linear)
14
+ *
15
+ * Everything persists through the credential store, so registered clients and
16
+ * refresh tokens are encrypted at rest like any other credential. That matters
17
+ * for DCR in particular: re-registering orphans the previous grant, so the
18
+ * client id has to survive restarts.
19
+ */
20
+
21
+ /** Loosely typed against the SDK's shapes; the SDK validates them itself. */
22
+ type ClientInformation = Record<string, unknown> & { client_id?: string };
23
+ type Tokens = Record<string, unknown> & { refresh_token?: string; access_token?: string };
24
+
25
+ export interface OAuthProviderOptions {
26
+ readonly manifest: ProviderManifest;
27
+ readonly connectionId: string;
28
+ readonly credentials: SecretStore;
29
+ /** Loopback callback the CLI is listening on. Absent for non-interactive refresh. */
30
+ readonly redirectUrl?: string | undefined;
31
+ /** Opens the browser. Absent when refreshing, where no interaction is possible. */
32
+ readonly openBrowser?: ((url: URL) => void) | undefined;
33
+ readonly scopes?: readonly string[];
34
+ /**
35
+ * The `state` the CLI's loopback listener will accept. Absent for a
36
+ * non-interactive refresh, which has no callback leg to bind.
37
+ */
38
+ readonly state?: string | undefined;
39
+ }
40
+
41
+ export class CredentialOAuthProvider {
42
+ readonly #options: OAuthProviderOptions;
43
+ #codeVerifier: string | undefined;
44
+ #discoveryState: unknown;
45
+
46
+ constructor(options: OAuthProviderOptions) {
47
+ this.#options = options;
48
+ }
49
+
50
+ // --- where things live -------------------------------------------------
51
+
52
+ /**
53
+ * The registered client.
54
+ *
55
+ * For `manual` registration this is keyed by the shared `oauth_apps` entry,
56
+ * because every Gmail and Drive connection authorises against the *same*
57
+ * Google app. For `dynamic` it is keyed per provider, since the registration
58
+ * belongs to us rather than to an operator-supplied app.
59
+ */
60
+ get #clientRef(): string {
61
+ const auth = this.#options.manifest.auth;
62
+ if (auth.kind === 'oauth' && auth.registration === 'manual' && auth.app) {
63
+ return `${auth.app}/client`;
64
+ }
65
+ return `${this.#options.manifest.id}/client`;
66
+ }
67
+
68
+ get #tokensRef(): string {
69
+ return `${this.#options.manifest.id}/${this.#options.connectionId}`;
70
+ }
71
+
72
+ // --- OAuthClientProvider ----------------------------------------------
73
+
74
+ get redirectUrl(): string | undefined {
75
+ return this.#options.redirectUrl;
76
+ }
77
+
78
+ get clientMetadata(): Record<string, unknown> {
79
+ return {
80
+ client_name: 'Lanes Link',
81
+ client_uri: 'https://github.com/lanes-sh/link',
82
+ redirect_uris: this.#options.redirectUrl ? [this.#options.redirectUrl] : [],
83
+ grant_types: ['authorization_code', 'refresh_token'],
84
+ response_types: ['code'],
85
+ token_endpoint_auth_method: 'client_secret_post',
86
+ ...(this.#options.scopes?.length ? { scope: this.#options.scopes.join(' ') } : {}),
87
+ };
88
+ }
89
+
90
+ async clientInformation(): Promise<ClientInformation | undefined> {
91
+ const auth = this.#options.manifest.auth;
92
+
93
+ // Manual registration: the operator supplied a client, so hand it over and
94
+ // the SDK will not attempt to register. Google requires this even for
95
+ // Google's own MCP servers.
96
+ if (auth.kind === 'oauth' && auth.registration === 'manual' && auth.app) {
97
+ const [clientId, clientSecret] = await Promise.all([
98
+ this.#options.credentials.get(`${auth.app}/client_id`),
99
+ this.#options.credentials.get(`${auth.app}/client_secret`),
100
+ ]);
101
+ if (!clientId) return undefined;
102
+ return { client_id: clientId, ...(clientSecret ? { client_secret: clientSecret } : {}) };
103
+ }
104
+
105
+ // Dynamic: a previous registration, if we have one. Returning `undefined`
106
+ // is what tells the SDK to register — and re-registering orphans the prior
107
+ // grant, so this must survive restarts.
108
+ return this.#read(this.#clientRef);
109
+ }
110
+
111
+ async saveClientInformation(information: ClientInformation): Promise<void> {
112
+ await this.#write(this.#clientRef, information);
113
+ }
114
+
115
+ async tokens(): Promise<Tokens | undefined> {
116
+ return this.#read(this.#tokensRef);
117
+ }
118
+
119
+ async saveTokens(tokens: Tokens): Promise<void> {
120
+ // Stamp an absolute expiry alongside the relative `expires_in` the server
121
+ // returns. Without it there is no way to tell a live access token from a
122
+ // stale one after a restart, so every cold start would force a refresh —
123
+ // and a refresh needs the network, which a serve path should not.
124
+ const lifetime = typeof tokens['expires_in'] === 'number' ? (tokens['expires_in'] as number) : 3600;
125
+
126
+ await this.#write(this.#tokensRef, {
127
+ ...tokens,
128
+ expires_at: Date.now() + lifetime * 1000,
129
+ });
130
+ }
131
+
132
+ async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
133
+ if (!this.#options.openBrowser) {
134
+ throw new Error(
135
+ `Connection ${this.#options.manifest.id}.${this.#options.connectionId} needs re-authorisation, ` +
136
+ `which requires a browser. Run: lanes link connect ${this.#options.manifest.id}.${this.#options.connectionId}`,
137
+ );
138
+ }
139
+ this.#options.openBrowser(authorizationUrl);
140
+ }
141
+
142
+ /**
143
+ * The CSRF binding between the authorization request and the callback.
144
+ *
145
+ * The SDK sends a `state` only if we return one here — `state?()` is optional
146
+ * on its provider interface, and an absent method means the parameter is left
147
+ * off the authorization URL entirely. The value belongs to the listener in
148
+ * `captureOAuthCallback`, which is what compares it on the way back; this
149
+ * method only carries it outbound.
150
+ *
151
+ * Undefined during refresh, which has no callback leg. The SDK omits the
152
+ * parameter, which is correct: there is nothing to bind.
153
+ */
154
+ state(): string | undefined {
155
+ return this.#options.state;
156
+ }
157
+
158
+ /**
159
+ * SEP-2352: bind the callback leg to the authorization server discovered on
160
+ * the first leg.
161
+ *
162
+ * Held in memory beside the code verifier, and for the same reason — both are
163
+ * valid for exactly one exchange. Without it the SDK cannot check that the
164
+ * callback came back from the server it started with, which is an
165
+ * authorization-server mix-up defence worth having rather than a warning
166
+ * worth silencing.
167
+ */
168
+ saveDiscoveryState(state: unknown): void {
169
+ this.#discoveryState = state;
170
+ }
171
+
172
+ discoveryState(): unknown {
173
+ return this.#discoveryState;
174
+ }
175
+
176
+ saveCodeVerifier(codeVerifier: string): void {
177
+ // In memory only: it is valid for one exchange and is worthless afterwards,
178
+ // so persisting it would add a stored secret for no benefit.
179
+ this.#codeVerifier = codeVerifier;
180
+ }
181
+
182
+ codeVerifier(): string {
183
+ if (!this.#codeVerifier) throw new Error('No PKCE code verifier for this flow');
184
+ return this.#codeVerifier;
185
+ }
186
+
187
+ async invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): Promise<void> {
188
+ if (scope === 'verifier') {
189
+ this.#codeVerifier = undefined;
190
+ return;
191
+ }
192
+ if (scope === 'discovery') {
193
+ this.#discoveryState = undefined;
194
+ return;
195
+ }
196
+ if (scope === 'tokens' || scope === 'all') {
197
+ await this.#options.credentials.delete(this.#tokensRef).catch(() => {});
198
+ }
199
+ if (scope === 'client' || scope === 'all') {
200
+ // Only ever a dynamically registered client. An operator-supplied one is
201
+ // theirs, and deleting it would silently break every other connection
202
+ // sharing that oauth_apps entry.
203
+ const auth = this.#options.manifest.auth;
204
+ const manual = auth.kind === 'oauth' && auth.registration === 'manual';
205
+ if (!manual) await this.#options.credentials.delete(this.#clientRef).catch(() => {});
206
+ }
207
+ }
208
+
209
+ // --- storage helpers ---------------------------------------------------
210
+
211
+ async #read<T>(ref: string): Promise<T | undefined> {
212
+ const raw = await this.#options.credentials.get(ref);
213
+ if (!raw) return undefined;
214
+ try {
215
+ return JSON.parse(raw) as T;
216
+ } catch {
217
+ // Unparseable stored credential: treat as absent so the flow can recover
218
+ // by re-registering rather than failing permanently.
219
+ return undefined;
220
+ }
221
+ }
222
+
223
+ async #write(ref: string, value: unknown): Promise<void> {
224
+ await this.#options.credentials.set(ref, JSON.stringify(value));
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Access tokens for upstream calls, cached in memory and never persisted.
230
+ *
231
+ * Short-lived by design, so storing one would add a credential to protect for
232
+ * no benefit. Keyed per connection, so two accounts never share a token, and a
233
+ * cold instance simply refreshes.
234
+ */
235
+ const accessTokens = new Map<string, { token: string; expiresAt: number }>();
236
+
237
+ export function clearUpstreamTokens(): void {
238
+ accessTokens.clear();
239
+ }
240
+
241
+ const EXPIRY_MARGIN_MS = 60_000;
242
+
243
+ export async function upstreamAccessToken(options: {
244
+ connectionKey: string;
245
+ provider: CredentialOAuthProvider;
246
+ refresh: (provider: CredentialOAuthProvider) => Promise<Tokens | undefined>;
247
+ now?: () => number;
248
+ }): Promise<string | null> {
249
+ const now = (options.now ?? Date.now)();
250
+ const cached = accessTokens.get(options.connectionKey);
251
+ if (cached && cached.expiresAt > now + EXPIRY_MARGIN_MS) return cached.token;
252
+
253
+ const stored = await options.provider.tokens();
254
+ const expiresAt = typeof stored?.['expires_at'] === 'number' ? (stored['expires_at'] as number) : 0;
255
+
256
+ if (stored?.access_token && expiresAt > now + EXPIRY_MARGIN_MS) {
257
+ accessTokens.set(options.connectionKey, { token: stored.access_token, expiresAt });
258
+ return stored.access_token;
259
+ }
260
+
261
+ // Only refresh when there is something to refresh with. Not every
262
+ // authorization server issues a refresh token — Notion's does not — and
263
+ // calling the SDK's `auth()` without one fails in a way that looks like a
264
+ // configuration bug rather than "this token simply cannot be renewed".
265
+ // Hand back what we have and let a 401 surface as "re-authorise", which is
266
+ // the truthful instruction.
267
+ if (!stored?.refresh_token) return stored?.access_token ?? null;
268
+
269
+ const refreshed = await options.refresh(options.provider);
270
+ if (!refreshed?.access_token) return stored?.access_token ?? null;
271
+
272
+ const lifetime = typeof refreshed['expires_in'] === 'number' ? (refreshed['expires_in'] as number) : 3600;
273
+ accessTokens.set(options.connectionKey, {
274
+ token: refreshed.access_token,
275
+ expiresAt: now + lifetime * 1000,
276
+ });
277
+
278
+ return refreshed.access_token;
279
+ }