@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,83 @@
1
+ /**
2
+ * Types for the sliver of nodemailer this codebase uses.
3
+ *
4
+ * Declared here rather than by adding `@types/nodemailer`, which is a large
5
+ * surface tracking its own version and would be a second dependency to keep
6
+ * honest — in a repository that has six, holds live credentials, and enforces a
7
+ * release-age floor for exactly that reason. What we call is a few fields wide
8
+ * and stable; if that stops being true, this file fails to compile, which is the
9
+ * behaviour we want.
10
+ *
11
+ * Moved here from `transports/imap/` when a second caller appeared: an HTTP mail
12
+ * API needs the same composed bytes SMTP does, so composition outgrew the
13
+ * transport that first needed it.
14
+ */
15
+ declare module 'nodemailer' {
16
+ interface Envelope {
17
+ from: string;
18
+ to: string[];
19
+ }
20
+
21
+ /**
22
+ * One attachment. `content` takes the raw bytes — nodemailer picks the
23
+ * transfer encoding and wraps base64 at 76 columns per RFC 2045 §6.8, which is
24
+ * the kind of detail that corrupts a PDF quietly when hand-rolled.
25
+ */
26
+ interface Attachment {
27
+ filename?: string;
28
+ content?: Buffer | Uint8Array | string;
29
+ contentType?: string;
30
+ encoding?: string;
31
+ cid?: string;
32
+ }
33
+
34
+ /** A display name plus an address. Nodemailer encodes the name if it needs it. */
35
+ interface Address {
36
+ name: string;
37
+ address: string;
38
+ }
39
+
40
+ interface Message {
41
+ from?: string | Address;
42
+ to?: string[];
43
+ cc?: string[];
44
+ bcc?: string[];
45
+ subject?: string;
46
+ text?: string;
47
+ html?: string;
48
+ inReplyTo?: string;
49
+ references?: string[];
50
+ attachments?: Attachment[];
51
+ /** A pre-composed RFC 822 message, sent verbatim. */
52
+ raw?: Buffer | Uint8Array;
53
+ envelope?: Envelope;
54
+ }
55
+
56
+ interface SentInfo {
57
+ messageId: string;
58
+ /** Present with `streamTransport` + `buffer`: the composed message. */
59
+ message: Buffer;
60
+ }
61
+
62
+ interface Transport {
63
+ sendMail(message: Message): Promise<SentInfo>;
64
+ close(): void;
65
+ }
66
+
67
+ interface TransportOptions {
68
+ host?: string;
69
+ port?: number;
70
+ secure?: boolean;
71
+ requireTLS?: boolean;
72
+ auth?: { user: string; pass: string };
73
+ /** Compose without sending, which is how the Sent copy stays identical. */
74
+ streamTransport?: boolean;
75
+ buffer?: boolean;
76
+ }
77
+
78
+ const nodemailer: {
79
+ createTransport(options: TransportOptions): Transport;
80
+ };
81
+
82
+ export default nodemailer;
83
+ }
@@ -0,0 +1,143 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import type { BlobStore } from '#stores/blobs';
3
+
4
+ /**
5
+ * Staged attachments — bytes handed to the endpoint out of band, then named by
6
+ * an opaque handle.
7
+ *
8
+ * This is the source that survives the endpoint not being on the caller's
9
+ * machine. A `path` means nothing to a container, so on a hosted deployment the
10
+ * bytes have to arrive some other way, and MCP offers none: there is no
11
+ * client-to-server binary channel in any released version of the protocol, and
12
+ * `roots` never carried bytes even before it was deprecated. Every serious
13
+ * treatment of the problem lands on the same shape — upload out of band, get a
14
+ * handle, pass the handle as an ordinary argument — which is also what the
15
+ * protocol's own draft file-transfer proposal settles on.
16
+ *
17
+ * Two blobs per handle rather than one framed payload: `BlobStore.get` returns
18
+ * bytes and nothing else, so the filename and type live in a sidecar and the
19
+ * payload stays byte-exact.
20
+ */
21
+
22
+ export const STAGED_PREFIX = 'attachments/';
23
+
24
+ export const stagedBytesKey = (handle: string): string => `${STAGED_PREFIX}${handle}`;
25
+ export const stagedMetaKey = (handle: string): string => `${STAGED_PREFIX}${handle}.json`;
26
+
27
+ /** How long a staged file stays fetchable. Long enough to compose a mail around. */
28
+ export const STAGED_TTL_MS = 24 * 60 * 60 * 1000;
29
+
30
+ /**
31
+ * Handles are checked against this before they reach the store.
32
+ *
33
+ * `assertSafeKey` would already reject traversal, but a caller-supplied handle
34
+ * deserves a readable refusal rather than an internal invariant surfacing as a
35
+ * tool error.
36
+ */
37
+ const HANDLE = /^[A-Za-z0-9_-]{1,128}$/;
38
+
39
+ export interface StagedMetadata {
40
+ readonly filename?: string;
41
+ readonly content_type?: string;
42
+ readonly sha256?: string;
43
+ readonly expires_at?: number;
44
+ }
45
+
46
+ export interface StagedFile {
47
+ readonly bytes: Uint8Array;
48
+ readonly filename: string | null;
49
+ readonly contentType: string | null;
50
+ }
51
+
52
+ export function isHandle(value: string): boolean {
53
+ return HANDLE.test(value);
54
+ }
55
+
56
+ /** `att_` plus 128 bits, which is unguessable and still fits on one line. */
57
+ export function newHandle(): string {
58
+ return `att_${randomBytes(16).toString('hex')}`;
59
+ }
60
+
61
+ export async function putStaged(
62
+ storage: BlobStore,
63
+ input: {
64
+ readonly handle: string;
65
+ readonly bytes: Uint8Array;
66
+ readonly metadata: StagedMetadata;
67
+ },
68
+ ): Promise<void> {
69
+ await storage.put(stagedBytesKey(input.handle), input.bytes, {
70
+ contentType: input.metadata.content_type ?? 'application/octet-stream',
71
+ });
72
+ await storage.put(
73
+ stagedMetaKey(input.handle),
74
+ new TextEncoder().encode(JSON.stringify(input.metadata)),
75
+ { contentType: 'application/json' },
76
+ );
77
+ }
78
+
79
+ /** Null for both "never staged" and "staged and expired" — the caller cannot act on the difference. */
80
+ export async function getStaged(
81
+ storage: BlobStore,
82
+ handle: string,
83
+ ): Promise<StagedFile | null> {
84
+ if (!isHandle(handle)) throw new Error(`"${handle}" is not a handle.`);
85
+
86
+ const bytes = await storage.get(stagedBytesKey(handle));
87
+ if (!bytes) return null;
88
+
89
+ const meta = await storage.get(stagedMetaKey(handle));
90
+ if (!meta) return { bytes, filename: null, contentType: null };
91
+
92
+ let parsed: StagedMetadata;
93
+ try {
94
+ parsed = JSON.parse(new TextDecoder().decode(meta)) as StagedMetadata;
95
+ } catch {
96
+ // Unreadable sidecar. The payload is still the payload, so serve it rather
97
+ // than losing a file to a corrupt metadata write.
98
+ return { bytes, filename: null, contentType: null };
99
+ }
100
+
101
+ if (parsed.expires_at !== undefined && parsed.expires_at < Date.now()) return null;
102
+
103
+ return {
104
+ bytes,
105
+ filename: parsed.filename ?? null,
106
+ contentType: parsed.content_type ?? null,
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Drop everything past its expiry.
112
+ *
113
+ * Swept on write rather than on a timer: there is no scheduler in this process,
114
+ * and staging is the only thing that creates the garbage, so the moment someone
115
+ * stages a file is both the cheapest and the most reliable time to clear the
116
+ * last batch.
117
+ */
118
+ export async function sweepStaged(storage: BlobStore): Promise<number> {
119
+ const now = Date.now();
120
+ let removed = 0;
121
+
122
+ for (const blob of await storage.list(STAGED_PREFIX)) {
123
+ if (!blob.key.endsWith('.json')) continue;
124
+
125
+ const meta = await storage.get(blob.key);
126
+ if (!meta) continue;
127
+
128
+ let expiresAt: number | undefined;
129
+ try {
130
+ expiresAt = (JSON.parse(new TextDecoder().decode(meta)) as StagedMetadata).expires_at;
131
+ } catch {
132
+ continue;
133
+ }
134
+ if (expiresAt === undefined || expiresAt >= now) continue;
135
+
136
+ const handle = blob.key.slice(STAGED_PREFIX.length, -'.json'.length);
137
+ await storage.delete(stagedBytesKey(handle));
138
+ await storage.delete(blob.key);
139
+ removed += 1;
140
+ }
141
+
142
+ return removed;
143
+ }
@@ -0,0 +1,301 @@
1
+ import { lookup } from 'node:dns/promises';
2
+ import { isIP } from 'node:net';
3
+
4
+ /**
5
+ * Fetching bytes from a URL a caller named, without becoming an SSRF gadget.
6
+ *
7
+ * This is the one attachment source that adds an attack surface rather than just
8
+ * a capability, and the threat model is concrete rather than theoretical: an
9
+ * agent reads a hostile email, the email says "attach the file at this URL", and
10
+ * the endpoint obliges. Deployed on Cloud Run the interesting target is one hop
11
+ * away — `169.254.169.254` hands out the service account token to anything that
12
+ * asks it.
13
+ *
14
+ * Four rules, in order of how much they buy:
15
+ *
16
+ * 1. HTTPS only. Plaintext to a named host is not a use case worth the hole.
17
+ * 2. Resolve the hostname and check **every** address it answers with, not the
18
+ * first. A name with one public and one private A record would otherwise
19
+ * pass a check and then connect wherever the OS felt like.
20
+ * 3. Do not follow redirects implicitly. Each hop is a fresh URL that has to
21
+ * pass the same checks, because a public host redirecting to `127.0.0.1` is
22
+ * the standard bypass.
23
+ * 4. Cap the body while reading it, not by trusting `Content-Length`.
24
+ *
25
+ * What this deliberately does not do is parse IP literals by hand. MCP's own
26
+ * security guidance is blunt that custom parsers miss octal, hex and
27
+ * IPv4-mapped-IPv6 encodings, so the only IPs classified here are ones `isIP`
28
+ * has already accepted as canonical or `dns.lookup` produced — an octal literal
29
+ * like `0177.0.0.1` is not a valid address, so it falls through to name
30
+ * resolution and fails there.
31
+ *
32
+ * Known residual risk: a name could resolve to a safe address during the check
33
+ * and a hostile one during the connection. Closing that needs the connection
34
+ * pinned to the address we validated, which means owning the socket — `fetch`
35
+ * does not expose it, and rewriting the URL to the literal IP would break TLS
36
+ * verification, trading a narrow race for a broken one. Recorded rather than
37
+ * hidden.
38
+ */
39
+
40
+ const MAX_REDIRECTS = 3;
41
+
42
+ export interface FetchedFile {
43
+ readonly bytes: Uint8Array;
44
+ readonly contentType: string | null;
45
+ readonly filename: string | null;
46
+ }
47
+
48
+ export type AddressLookup = (hostname: string) => Promise<readonly string[]>;
49
+
50
+ const resolveAddresses: AddressLookup = async (hostname) => {
51
+ if (isIP(hostname) !== 0) return [hostname];
52
+ const answers = await lookup(hostname, { all: true, verbatim: true });
53
+ return answers.map((answer) => answer.address);
54
+ };
55
+
56
+ export async function fetchFromUrl(input: {
57
+ readonly url: string;
58
+ readonly maxBytes: number;
59
+ readonly fetch?: typeof globalThis.fetch;
60
+ readonly addresses?: AddressLookup;
61
+ readonly signal?: AbortSignal | undefined;
62
+ }): Promise<FetchedFile> {
63
+ const doFetch = input.fetch ?? globalThis.fetch;
64
+ const addresses = input.addresses ?? resolveAddresses;
65
+
66
+ let target = await checkedUrl(input.url, addresses);
67
+
68
+ for (let hop = 0; ; hop += 1) {
69
+ const response = await doFetch(target.href, {
70
+ redirect: 'manual',
71
+ headers: { accept: '*/*' },
72
+ ...(input.signal ? { signal: input.signal } : {}),
73
+ });
74
+
75
+ if (response.status >= 300 && response.status < 400) {
76
+ const location = response.headers.get('location');
77
+ if (!location) throw new Error(`${target.href} redirected without a Location header.`);
78
+ if (hop >= MAX_REDIRECTS) {
79
+ throw new Error(`${input.url} redirected more than ${MAX_REDIRECTS} times.`);
80
+ }
81
+ // Resolved against the current URL so a relative Location works, then
82
+ // checked from scratch: a hop is a new destination, not a continuation.
83
+ target = await checkedUrl(new URL(location, target).href, addresses);
84
+ continue;
85
+ }
86
+
87
+ if (!response.ok) {
88
+ throw new Error(`${target.href} answered ${response.status} ${response.statusText}.`);
89
+ }
90
+
91
+ return {
92
+ bytes: await readCapped(response, input.maxBytes, target.href),
93
+ contentType: normalizeContentType(response.headers.get('content-type')),
94
+ filename: filenameFromDisposition(response.headers.get('content-disposition')),
95
+ };
96
+ }
97
+ }
98
+
99
+ /** Parse, require HTTPS, and refuse a host that resolves anywhere internal. */
100
+ async function checkedUrl(candidate: string, addresses: AddressLookup): Promise<URL> {
101
+ let url: URL;
102
+ try {
103
+ url = new URL(candidate);
104
+ } catch {
105
+ throw new Error(`"${candidate}" is not a URL.`);
106
+ }
107
+
108
+ if (url.protocol !== 'https:') {
109
+ throw new Error(
110
+ `Attachment URLs must be https — "${url.protocol}//" is not fetched. Stage the file instead if it is not reachable over HTTPS.`,
111
+ );
112
+ }
113
+
114
+ let resolved: readonly string[];
115
+ try {
116
+ resolved = await addresses(url.hostname);
117
+ } catch {
118
+ throw new Error(`${url.hostname} does not resolve.`);
119
+ }
120
+
121
+ if (resolved.length === 0) throw new Error(`${url.hostname} does not resolve.`);
122
+
123
+ for (const address of resolved) {
124
+ if (isBlocked(address)) {
125
+ throw new Error(
126
+ `${url.hostname} resolves to ${address}, which is a private, loopback, or link-local address. Refusing to fetch it.`,
127
+ );
128
+ }
129
+ }
130
+
131
+ return url;
132
+ }
133
+
134
+ /**
135
+ * Is this address one we must never connect to on a caller's behalf?
136
+ *
137
+ * Covers the ranges RFC 9728 §7.7 names, plus multicast and reserved space. The
138
+ * IPv4-mapped and NAT64 cases unwrap and re-check, because `::ffff:127.0.0.1` is
139
+ * loopback wearing a different spelling.
140
+ */
141
+ export function isBlocked(address: string): boolean {
142
+ const version = isIP(address);
143
+ if (version === 4) return isBlockedV4(address);
144
+ if (version === 6) return isBlockedV6(address);
145
+ // Not an address at all. Treated as blocked because every caller here has
146
+ // already resolved a name, so an unparseable value means something upstream
147
+ // is wrong and connecting anyway is the worse failure.
148
+ return true;
149
+ }
150
+
151
+ function isBlockedV4(address: string): boolean {
152
+ const parts = address.split('.').map(Number);
153
+ const [a = 0, b = 0] = parts;
154
+
155
+ if (a === 0) return true; // 0.0.0.0/8 — "this host"
156
+ if (a === 10) return true; // private
157
+ if (a === 127) return true; // loopback
158
+ if (a === 169 && b === 254) return true; // link-local, incl. cloud metadata
159
+ if (a === 172 && b >= 16 && b <= 31) return true; // private
160
+ if (a === 192 && b === 168) return true; // private
161
+ if (a === 192 && b === 0) return true; // 192.0.0.0/24 protocol assignments
162
+ if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
163
+ if (a === 198 && (b === 18 || b === 19)) return true; // benchmarking
164
+ if (a >= 224) return true; // multicast, reserved, broadcast
165
+
166
+ return false;
167
+ }
168
+
169
+ function isBlockedV6(address: string): boolean {
170
+ const bytes = ipv6Bytes(address);
171
+ if (!bytes) return true;
172
+
173
+ // IPv4-mapped (::ffff:0:0/96) and NAT64 (64:ff9b::/96) both carry a v4
174
+ // address in the last four bytes. Judge them as what they actually reach.
175
+ const mapped =
176
+ bytes.slice(0, 10).every((byte) => byte === 0) && bytes[10] === 0xff && bytes[11] === 0xff;
177
+ const nat64 =
178
+ bytes[0] === 0x00 &&
179
+ bytes[1] === 0x64 &&
180
+ bytes[2] === 0xff &&
181
+ bytes[3] === 0x9b &&
182
+ bytes.slice(4, 12).every((byte) => byte === 0);
183
+ if (mapped || nat64) {
184
+ return isBlockedV4(bytes.slice(12).join('.'));
185
+ }
186
+
187
+ if (bytes.every((byte) => byte === 0)) return true; // ::
188
+ if (bytes.slice(0, 15).every((byte) => byte === 0) && bytes[15] === 1) return true; // ::1
189
+ if ((bytes[0]! & 0xfe) === 0xfc) return true; // fc00::/7 unique local
190
+ if (bytes[0] === 0xfe && (bytes[1]! & 0xc0) === 0x80) return true; // fe80::/10 link-local
191
+ if (bytes[0] === 0xff) return true; // ff00::/8 multicast
192
+
193
+ return false;
194
+ }
195
+
196
+ /**
197
+ * Expand an IPv6 address to its sixteen bytes.
198
+ *
199
+ * Only ever called with a string `isIP` accepted, so the shape is already
200
+ * canonical — this handles `::` compression and a trailing dotted-quad, and
201
+ * returns null rather than guessing on anything else.
202
+ */
203
+ function ipv6Bytes(address: string): number[] | null {
204
+ let text = address;
205
+
206
+ // A trailing IPv4 form (::ffff:127.0.0.1) becomes two more hextets.
207
+ const dotted = text.match(/(\d+\.\d+\.\d+\.\d+)$/);
208
+ if (dotted) {
209
+ const octets = dotted[1]!.split('.').map(Number);
210
+ if (octets.some((octet) => Number.isNaN(octet) || octet > 255)) return null;
211
+ const high = ((octets[0]! << 8) | octets[1]!).toString(16);
212
+ const low = ((octets[2]! << 8) | octets[3]!).toString(16);
213
+ text = `${text.slice(0, dotted.index)}${high}:${low}`;
214
+ }
215
+
216
+ const [head, tail, ...extra] = text.split('::');
217
+ if (extra.length > 0) return null;
218
+
219
+ const parse = (group: string | undefined): number[] => {
220
+ if (!group) return [];
221
+ return group.split(':').flatMap((hextet) => {
222
+ const value = Number.parseInt(hextet, 16);
223
+ return [(value >> 8) & 0xff, value & 0xff];
224
+ });
225
+ };
226
+
227
+ const front = parse(head);
228
+ const back = tail === undefined ? [] : parse(tail);
229
+ const gap = 16 - front.length - back.length;
230
+ if (gap < 0) return null;
231
+ if (tail === undefined && gap !== 0) return null;
232
+
233
+ return [...front, ...Array<number>(gap).fill(0), ...back];
234
+ }
235
+
236
+ /**
237
+ * Read the body, stopping at the cap.
238
+ *
239
+ * Streamed rather than `arrayBuffer()` because `Content-Length` is the server's
240
+ * claim about the server's own body — a hostile or broken one can understate it,
241
+ * and buffering first to measure second is how a size limit becomes a memory
242
+ * limit instead.
243
+ */
244
+ async function readCapped(response: Response, maxBytes: number, href: string): Promise<Uint8Array> {
245
+ const declared = Number(response.headers.get('content-length') ?? Number.NaN);
246
+ if (Number.isFinite(declared) && declared > maxBytes) {
247
+ throw new Error(`${href} is ${declared} bytes, over the ${maxBytes} byte limit.`);
248
+ }
249
+
250
+ if (!response.body) return new Uint8Array();
251
+
252
+ const reader = response.body.getReader();
253
+ const chunks: Uint8Array[] = [];
254
+ let total = 0;
255
+
256
+ try {
257
+ for (;;) {
258
+ const { done, value } = await reader.read();
259
+ if (done) break;
260
+ total += value.byteLength;
261
+ if (total > maxBytes) {
262
+ throw new Error(`${href} is larger than the ${maxBytes} byte limit.`);
263
+ }
264
+ chunks.push(value);
265
+ }
266
+ } finally {
267
+ await reader.cancel().catch(() => {});
268
+ }
269
+
270
+ const bytes = new Uint8Array(total);
271
+ let offset = 0;
272
+ for (const chunk of chunks) {
273
+ bytes.set(chunk, offset);
274
+ offset += chunk.byteLength;
275
+ }
276
+ return bytes;
277
+ }
278
+
279
+ function normalizeContentType(header: string | null): string | null {
280
+ if (!header) return null;
281
+ const value = header.split(';')[0]?.trim().toLowerCase();
282
+ return value ? value : null;
283
+ }
284
+
285
+ /** `attachment; filename="report.pdf"`, and the RFC 5987 `filename*` form. */
286
+ export function filenameFromDisposition(header: string | null): string | null {
287
+ if (!header) return null;
288
+
289
+ const extended = header.match(/filename\*\s*=\s*[^']*'[^']*'([^;]+)/i);
290
+ if (extended?.[1]) {
291
+ try {
292
+ return decodeURIComponent(extended[1].trim());
293
+ } catch {
294
+ /* fall through to the plain form */
295
+ }
296
+ }
297
+
298
+ const plain = header.match(/filename\s*=\s*("([^"]*)"|[^;]+)/i);
299
+ const value = plain?.[2] ?? plain?.[1];
300
+ return value ? value.trim() : null;
301
+ }
@@ -0,0 +1,135 @@
1
+ import { z } from 'zod';
2
+ import { credentialRef, identifier } from './primitives.ts';
3
+
4
+ /**
5
+ * Credential types — how we prove who we are, orthogonal to how we connect.
6
+ *
7
+ * One folder per method under `../auth/`, and the union below is the complete
8
+ * list of what a manifest may declare today. Adding one is a schema member
9
+ * here, a folder there, and a branch in `../auth/resolve.ts` — see that
10
+ * directory's README, which names the ones not built yet.
11
+ */
12
+
13
+ export const authNoneSchema = z.object({ kind: z.literal('none') });
14
+
15
+ /**
16
+ * A client somebody else operates, so the operator does not have to register one.
17
+ *
18
+ * An installed application cannot hold a confidential client: whatever ships in
19
+ * the binary is readable by whoever runs it. That leaves exactly two honest
20
+ * arrangements, and this declares the second — the operator registers their own,
21
+ * or somebody runs one and performs the exchange on their behalf. The secret
22
+ * lives behind these endpoints and never reaches this machine.
23
+ *
24
+ * Vendor-free by construction. The URL is data a provider supplies, so nothing
25
+ * in this component learns whose client is behind it, or whose broker.
26
+ */
27
+ export const authBrokerSchema = z.object({
28
+ /** Base URL. `<url>/config`, `<url>/exchange`, `<url>/refresh`. */
29
+ url: z.url(),
30
+ /** Who runs it, named in the sentence shown before consent. */
31
+ operator: z.string().min(1),
32
+ docs_url: z.url().optional(),
33
+ });
34
+
35
+ export type AuthBroker = z.infer<typeof authBrokerSchema>;
36
+
37
+ export const authOAuthSchema = z.object({
38
+ kind: z.literal('oauth'),
39
+ /**
40
+ * `dynamic` — the authorization server offers Dynamic Client Registration, so
41
+ * we register ourselves and the operator does nothing at all (Notion, Linear).
42
+ *
43
+ * `manual` — the vendor requires a pre-registered client, so the operator
44
+ * supplies an id and secret (Google, including for Google's own MCP servers).
45
+ */
46
+ registration: z.enum(['dynamic', 'manual']).default('dynamic'),
47
+ /** Which `oauth_apps` entry holds the client, for `manual`. Shared across providers of a vendor. */
48
+ app: identifier.optional(),
49
+ /**
50
+ * Where to authorise when the profile declares no `oauth_apps` entry of its own.
51
+ *
52
+ * Additive rather than a third `registration` value, because the manifest is
53
+ * not what chooses. `registration: manual` stays true either way — the vendor
54
+ * does require a pre-registered client — and `app` still names the entry that
55
+ * overrides this. What decides is the profile: declaring the entry means "my
56
+ * own client", leaving it out means "the one the broker operates". A manifest
57
+ * that claimed one or the other would be wrong half the time.
58
+ */
59
+ broker: authBrokerSchema.optional(),
60
+ scopes: z.array(z.string()).default([]),
61
+ /**
62
+ * Usually discovered from the resource's metadata; set only to override.
63
+ *
64
+ * Required for an `http` connector, which has no MCP metadata document to
65
+ * discover from — a REST API is just a base URL, and where its authorization
66
+ * server lives is not something the API itself announces.
67
+ */
68
+ authorize_url: z.url().optional(),
69
+ token_url: z.url().optional(),
70
+ /**
71
+ * Extra parameters on the authorization request.
72
+ *
73
+ * Google needs `access_type=offline` and `prompt=consent`, without which it
74
+ * returns an access token and no refresh token — the connection then works
75
+ * for an hour and dies, which is a miserable thing to debug.
76
+ */
77
+ authorize_params: z.record(z.string(), z.string()).optional(),
78
+ });
79
+
80
+ export const authTokenSchema = z.object({
81
+ kind: z.enum(['bearer', 'api_key', 'header', 'basic']),
82
+ /** Header name for `header` / `api_key`. Defaults to `Authorization` / `X-API-Key`. */
83
+ header: z.string().optional(),
84
+ /** Send an `api_key` as a query parameter instead of a header. */
85
+ query: z.string().optional(),
86
+ /**
87
+ * The vendor group this credential belongs to, when several providers share
88
+ * one secret *per account*.
89
+ *
90
+ * Apple issues an app-specific password at account scope, so one password
91
+ * genuinely unlocks iCloud Mail, Calendar, and Contacts together — three
92
+ * providers, because they speak two protocols, but one thing to type. Setting
93
+ * `app: icloud` on all three moves the derived ref from `<provider>/<account>`
94
+ * to `icloud/<account>`.
95
+ *
96
+ * The same field on an *OAuth* block means something different and narrower:
97
+ * which `oauth_apps` entry holds the client. It deliberately does not move
98
+ * where tokens land, because Gmail and Drive share a Google client while
99
+ * holding separate tokens granted under different scopes.
100
+ */
101
+ app: identifier.optional(),
102
+ /**
103
+ * Omit for a per-account credential, which is the usual case and derives.
104
+ *
105
+ * Declaring one means the opposite: a single secret shared by *every* account
106
+ * of this provider — a service key, where the key is itself the identity.
107
+ */
108
+ credential_ref: credentialRef.optional(),
109
+ });
110
+
111
+ /**
112
+ * A pluggable strategy, for auth no declarative form should try to express.
113
+ *
114
+ * This is the *only* place per-vendor code is permitted outside `local`
115
+ * providers, and it exists because real APIs do things config cannot describe:
116
+ * bunq generates an RSA keypair, runs a three-step handshake, signs every
117
+ * request, and verifies the response signature. That earns ~150 lines of auth.
118
+ * It must never come to mean per-endpoint code again.
119
+ */
120
+ export const authStrategySchema = z.object({
121
+ kind: z.literal('strategy'),
122
+ strategy: identifier,
123
+ credential_ref: credentialRef.optional(),
124
+ /** Strategy-specific settings, validated by the strategy itself. */
125
+ options: z.record(z.string(), z.unknown()).optional(),
126
+ });
127
+
128
+ export const authSchema = z.discriminatedUnion('kind', [
129
+ authNoneSchema,
130
+ authOAuthSchema,
131
+ authTokenSchema,
132
+ authStrategySchema,
133
+ ]);
134
+
135
+ export type AuthConfig = z.infer<typeof authSchema>;
@@ -0,0 +1,23 @@
1
+ import { z } from 'zod';
2
+ import { identifier } from './primitives.ts';
3
+
4
+ /**
5
+ * A named group of capabilities.
6
+ *
7
+ * For `http` connectors these are derived from the HTTP method — GET and HEAD
8
+ * are `read`, mutating verbs are `write` — so a spec yields meaningful bundles
9
+ * with no curation. A manifest may still declare its own.
10
+ */
11
+ export const bundleSchema = z.object({
12
+ name: identifier,
13
+ description: z.string().default(''),
14
+ oauth_scopes: z.array(z.string()).default([]),
15
+ /** Glob patterns over capability names. Empty means "everything not matched by another bundle". */
16
+ capabilities: z.array(z.string()).default([]),
17
+ default: z.boolean().default(false),
18
+ });
19
+
20
+ export const READ_BUNDLE = 'read';
21
+ export const WRITE_BUNDLE = 'write';
22
+
23
+ export type ScopeBundle = z.infer<typeof bundleSchema>;