@lanes-sh/link 0.6.11 → 0.7.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 (201) hide show
  1. package/README.md +62 -45
  2. package/instructions/skills/lanes-link/SKILL.md +25 -0
  3. package/package.json +3 -2
  4. package/src/audit/conformance.ts +1 -1
  5. package/src/cli/argv.ts +14 -1
  6. package/src/cli/commands/connect/acquire.ts +100 -0
  7. package/src/cli/commands/connect/custom/index.ts +1 -1
  8. package/src/cli/commands/connect/declare.ts +19 -0
  9. package/src/cli/commands/connect/index.ts +54 -56
  10. package/src/cli/commands/connect/requirements.ts +23 -1
  11. package/src/cli/commands/connect/setup.ts +1 -1
  12. package/src/cli/commands/connect/variables.ts +220 -0
  13. package/src/cli/commands/knowledge/index.ts +22 -4
  14. package/src/cli/commands/knowledge/migrate.ts +61 -43
  15. package/src/cli/commands/operate/tools.ts +1 -1
  16. package/src/cli/commands/owner/entities.ts +244 -0
  17. package/src/cli/commands/owner/shared.ts +14 -0
  18. package/src/cli/commands/owner/vault.ts +2 -2
  19. package/src/cli/commands/owner.ts +13 -3
  20. package/src/cli/commands/setup.ts +12 -0
  21. package/src/cli/config-edit.ts +16 -6
  22. package/src/cli/config-repair.ts +10 -0
  23. package/src/cli/dispatch-owner.ts +30 -1
  24. package/src/cli/main.ts +2 -1
  25. package/src/cli/runtime/open.ts +14 -7
  26. package/src/cli/runtime/registry.ts +6 -1
  27. package/src/cli/selection.ts +11 -0
  28. package/src/cli/usage.ts +13 -2
  29. package/src/connectivity/auth/strategy/index.ts +1 -1
  30. package/src/connectivity/capability.ts +1 -1
  31. package/src/connectivity/context.ts +1 -1
  32. package/src/connectivity/index.ts +1 -1
  33. package/src/connectivity/mail/attachments.ts +42 -73
  34. package/src/connectivity/mail/content-type.ts +71 -0
  35. package/src/connectivity/mail/index.ts +1 -1
  36. package/src/connectivity/manifest/provider.ts +50 -0
  37. package/src/connectivity/manifest/variables.ts +173 -0
  38. package/src/connectivity/transports/factory.ts +74 -1
  39. package/src/connectivity/transports/http/index.ts +8 -2
  40. package/src/connectivity/transports/http/keys.ts +83 -0
  41. package/src/connectivity/transports/imap/attachment.ts +53 -10
  42. package/src/connectivity/transports/imap/capabilities.ts +28 -0
  43. package/src/connectivity/transports/imap/download.ts +111 -0
  44. package/src/connectivity/transports/imap/index.ts +13 -0
  45. package/src/connectivity/transports/imap/operations.ts +1 -0
  46. package/src/deployments/adapters/s3.ts +1 -1
  47. package/src/deployments/gcp/provision.ts +1 -1
  48. package/src/deployments/knowledge.ts +44 -18
  49. package/src/deployments/prepare.ts +1 -1
  50. package/src/deployments/serving.ts +1 -1
  51. package/src/dispatch/dispatch.ts +17 -10
  52. package/src/dispatch/staging.ts +76 -1
  53. package/src/profile/knowledge.ts +19 -11
  54. package/src/profile/schema.ts +2 -2
  55. package/src/providers/README.md +189 -0
  56. package/src/providers/airtable/index.ts +10 -0
  57. package/src/providers/algolia/index.ts +10 -0
  58. package/src/providers/amplitude/index.ts +10 -0
  59. package/src/providers/apify/index.ts +10 -0
  60. package/src/providers/asana/index.ts +10 -0
  61. package/src/providers/assets/provider.ts +11 -4
  62. package/src/providers/atlassian/index.ts +19 -0
  63. package/src/providers/attio/index.ts +10 -0
  64. package/src/providers/betterstack/index.ts +10 -0
  65. package/src/providers/box/index.ts +64 -0
  66. package/src/providers/brightdata/index.ts +10 -0
  67. package/src/providers/buildkite/index.ts +10 -0
  68. package/src/providers/bunq/index.ts +2 -2
  69. package/src/providers/calendly/index.ts +10 -0
  70. package/src/providers/canva/index.ts +10 -0
  71. package/src/providers/circleci/index.ts +10 -0
  72. package/src/providers/clickup/index.ts +10 -0
  73. package/src/providers/close/index.ts +10 -0
  74. package/src/providers/cloudflare_bindings/index.ts +14 -0
  75. package/src/providers/cloudflare_observability/index.ts +10 -0
  76. package/src/providers/contentful/index.ts +10 -0
  77. package/src/providers/custom/load.ts +1 -1
  78. package/src/providers/datadog/index.ts +10 -0
  79. package/src/providers/discord/index.ts +1 -1
  80. package/src/providers/discord/specs/discord.v10.json +0 -88
  81. package/src/providers/discord/specs/vendor.ts +1 -1
  82. package/src/providers/dropbox/index.ts +10 -0
  83. package/src/providers/entities/catalogue.ts +366 -0
  84. package/src/providers/entities/find.ts +279 -0
  85. package/src/providers/entities/provider.ts +334 -0
  86. package/src/providers/entities/render.ts +142 -0
  87. package/src/providers/entities/store.ts +276 -0
  88. package/src/providers/entities/writes.ts +279 -0
  89. package/src/providers/example/provider.ts +1 -1
  90. package/src/providers/expensify/index.ts +10 -0
  91. package/src/providers/fastmail/calendar/index.ts +21 -0
  92. package/src/providers/fastmail/contacts/index.ts +17 -0
  93. package/src/providers/fastmail/index.ts +4 -0
  94. package/src/providers/fastmail/mail/index.ts +38 -0
  95. package/src/providers/fastmail/shared/setup.ts +52 -0
  96. package/src/providers/figma/index.ts +10 -0
  97. package/src/providers/fireflies/index.ts +10 -0
  98. package/src/providers/flagsmith/index.ts +10 -0
  99. package/src/providers/gamma/index.ts +10 -0
  100. package/src/providers/github/index.ts +2 -2
  101. package/src/providers/google/gmail/attachment.ts +16 -1
  102. package/src/providers/google/gmail-imap/index.ts +7 -2
  103. package/src/providers/google/shared/oauth.ts +1 -1
  104. package/src/providers/google/shared/service-account.ts +1 -1
  105. package/src/providers/google/shared/setup.ts +1 -1
  106. package/src/providers/google/specs/calendar.v3.json +0 -32
  107. package/src/providers/google/specs/docs.v1.json +0 -76
  108. package/src/providers/google/specs/drive.v3.json +0 -32
  109. package/src/providers/google/specs/gmail.v1.json +0 -76
  110. package/src/providers/google/specs/people.v1.json +0 -77
  111. package/src/providers/google/specs/sheets.v4.json +0 -76
  112. package/src/providers/google/specs/tasks.v1.json +0 -76
  113. package/src/providers/grafana/index.ts +10 -0
  114. package/src/providers/heroku/index.ts +10 -0
  115. package/src/providers/hubspot/index.ts +75 -0
  116. package/src/providers/hygraph/index.ts +10 -0
  117. package/src/providers/icloud/mail/index.ts +5 -0
  118. package/src/providers/icloud/shared/setup.ts +1 -1
  119. package/src/providers/index.ts +255 -0
  120. package/src/providers/insightly/index.ts +10 -0
  121. package/src/providers/jam/index.ts +10 -0
  122. package/src/providers/klaviyo/index.ts +10 -0
  123. package/src/providers/mailbox/index.ts +87 -0
  124. package/src/providers/memory/provider.ts +3 -8
  125. package/src/providers/mercury/index.ts +10 -0
  126. package/src/providers/microsoft/calendar/index.ts +49 -0
  127. package/src/providers/microsoft/contacts/index.ts +50 -0
  128. package/src/providers/microsoft/drive/index.ts +62 -0
  129. package/src/providers/microsoft/index.ts +12 -0
  130. package/src/providers/microsoft/mail/index.ts +76 -0
  131. package/src/providers/microsoft/shared/oauth.ts +58 -0
  132. package/src/providers/microsoft/shared/scopes.ts +56 -0
  133. package/src/providers/microsoft/shared/setup.ts +45 -0
  134. package/src/providers/microsoft/specs/microsoft-todo.v1.json +755 -0
  135. package/src/providers/microsoft/specs/onedrive.v1.json +993 -0
  136. package/src/providers/microsoft/specs/outlook-calendar.v1.json +1743 -0
  137. package/src/providers/microsoft/specs/outlook-contacts.v1.json +430 -0
  138. package/src/providers/microsoft/specs/outlook-mail.v1.json +901 -0
  139. package/src/providers/microsoft/specs/vendor.ts +232 -0
  140. package/src/providers/microsoft/todo/index.ts +54 -0
  141. package/src/providers/miro/index.ts +10 -0
  142. package/src/providers/mixpanel/index.ts +10 -0
  143. package/src/providers/monday/index.ts +10 -0
  144. package/src/providers/mux/index.ts +10 -0
  145. package/src/providers/navan/index.ts +10 -0
  146. package/src/providers/neon/index.ts +10 -0
  147. package/src/providers/netlify/index.ts +10 -0
  148. package/src/providers/nextcloud/calendar/index.ts +28 -0
  149. package/src/providers/nextcloud/contacts/index.ts +18 -0
  150. package/src/providers/nextcloud/index.ts +3 -0
  151. package/src/providers/nextcloud/shared/setup.ts +59 -0
  152. package/src/providers/owner.ts +15 -3
  153. package/src/providers/paddle/index.ts +10 -0
  154. package/src/providers/paypal/index.ts +10 -0
  155. package/src/providers/posthog/index.ts +10 -0
  156. package/src/providers/prisma/index.ts +10 -0
  157. package/src/providers/ramp/index.ts +10 -0
  158. package/src/providers/recurly/index.ts +10 -0
  159. package/src/providers/reddit/index.ts +1 -1
  160. package/src/providers/remote/index.ts +10 -0
  161. package/src/providers/render/index.ts +35 -0
  162. package/src/providers/replicate/index.ts +10 -0
  163. package/src/providers/resend/index.ts +10 -0
  164. package/src/providers/riverside/index.ts +10 -0
  165. package/src/providers/rootly/index.ts +10 -0
  166. package/src/providers/rudderstack/index.ts +10 -0
  167. package/src/providers/salesloft/index.ts +10 -0
  168. package/src/providers/sanity/index.ts +10 -0
  169. package/src/providers/scopes.ts +2 -0
  170. package/src/providers/sentry/index.ts +10 -0
  171. package/src/providers/setup/plan.ts +11 -0
  172. package/src/providers/setup/provider.ts +12 -0
  173. package/src/providers/shared/openapi.ts +70 -0
  174. package/src/providers/shared/slug.ts +28 -0
  175. package/src/providers/shared/vendor-operations.ts +43 -1
  176. package/src/providers/shared/vendor-spec.ts +98 -17
  177. package/src/providers/shortcut/index.ts +10 -0
  178. package/src/providers/slack/index.ts +2 -2
  179. package/src/providers/square/index.ts +10 -0
  180. package/src/providers/storyblok/index.ts +10 -0
  181. package/src/providers/stripe/index.ts +10 -0
  182. package/src/providers/supabase/index.ts +10 -0
  183. package/src/providers/tasks/store.ts +2 -7
  184. package/src/providers/tavily/index.ts +10 -0
  185. package/src/providers/todoist/index.ts +10 -0
  186. package/src/providers/untested.ts +105 -0
  187. package/src/providers/vault/provider.ts +1 -1
  188. package/src/providers/vercel/index.ts +10 -0
  189. package/src/providers/vimeo/index.ts +10 -0
  190. package/src/providers/webflow/index.ts +10 -0
  191. package/src/providers/whimsical/index.ts +10 -0
  192. package/src/providers/wix/index.ts +10 -0
  193. package/src/providers/workable/index.ts +10 -0
  194. package/src/providers/yahoo_mail/index.ts +73 -0
  195. package/src/providers/zapier/index.ts +10 -0
  196. package/src/providers/zoho_mail/index.ts +79 -0
  197. package/src/secrets/document.ts +1 -1
  198. package/src/server/attachments.ts +129 -5
  199. package/src/server/index.ts +3 -3
  200. package/src/server/mcp/instructions.ts +88 -18
  201. package/src/server/stdio.ts +1 -1
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Providers nobody has connected to a real account yet.
3
+ *
4
+ * A manifest can be right in every way this repository can check — the schema
5
+ * validates, the spec generates registrable tools inside the budget, the scopes
6
+ * are described, the endpoint answered an unauthenticated probe — and still not
7
+ * work. What none of that proves is the part only a live account proves: that
8
+ * the grant is the right one, that the identity probe reads a field the vendor
9
+ * actually returns, that a refresh token comes back, that the tool list is worth
10
+ * having.
11
+ *
12
+ * So this is an honest label rather than a warning. Everything here is expected
13
+ * to work and is shipped in that belief; none of it has been used in anger. A
14
+ * provider leaves this list when somebody connects it and it does what it says.
15
+ *
16
+ * One list, and the documentation is checked against it — `src/readme.test.ts`
17
+ * asserts the tables mark exactly these and no others, because a status that is
18
+ * maintained in three places is a status that is wrong in two of them.
19
+ */
20
+ export const UNTESTED_PROVIDERS: ReadonlySet<string> = new Set([
21
+ 'airtable',
22
+ 'algolia',
23
+ 'amplitude',
24
+ 'apify',
25
+ 'asana',
26
+ 'attio',
27
+ 'betterstack',
28
+ 'brightdata',
29
+ 'buildkite',
30
+ 'calendly',
31
+ 'canva',
32
+ 'circleci',
33
+ 'clickup',
34
+ 'close',
35
+ 'cloudflare_bindings',
36
+ 'cloudflare_observability',
37
+ 'contentful',
38
+ 'datadog',
39
+ 'dropbox',
40
+ 'expensify',
41
+ 'fastmail_calendar',
42
+ 'fastmail_contacts',
43
+ 'fastmail_mail',
44
+ 'figma',
45
+ 'fireflies',
46
+ 'flagsmith',
47
+ 'gamma',
48
+ 'grafana',
49
+ 'heroku',
50
+ 'hygraph',
51
+ 'insightly',
52
+ 'jam',
53
+ 'klaviyo',
54
+ 'mercury',
55
+ 'microsoft_todo',
56
+ 'miro',
57
+ 'mixpanel',
58
+ 'monday',
59
+ 'mux',
60
+ 'navan',
61
+ 'neon',
62
+ 'netlify',
63
+ 'onedrive',
64
+ 'outlook_calendar',
65
+ 'outlook_contacts',
66
+ 'outlook_mail',
67
+ 'paddle',
68
+ 'paypal',
69
+ 'posthog',
70
+ 'prisma',
71
+ 'ramp',
72
+ 'recurly',
73
+ 'remote',
74
+ 'replicate',
75
+ 'resend',
76
+ 'riverside',
77
+ 'rootly',
78
+ 'rudderstack',
79
+ 'salesloft',
80
+ 'sanity',
81
+ 'sentry',
82
+ 'shortcut',
83
+ 'square',
84
+ 'storyblok',
85
+ 'stripe',
86
+ 'supabase',
87
+ 'tavily',
88
+ 'todoist',
89
+ 'vercel',
90
+ 'vimeo',
91
+ 'webflow',
92
+ 'whimsical',
93
+ 'wix',
94
+ 'workable',
95
+ 'yahoo_mail',
96
+ 'zapier',
97
+ 'zoho_mail',
98
+ 'atlassian',
99
+ 'hubspot',
100
+ 'box',
101
+ 'render',
102
+ 'mailbox',
103
+ 'nextcloud_calendar',
104
+ 'nextcloud_contacts',
105
+ ]);
@@ -14,7 +14,7 @@ import { assertItemId, type VaultStore } from '#secrets';
14
14
  * Three properties, each of which is a decision recorded in ADR-012:
15
15
  *
16
16
  * **Tools only, never resources.** Resources are listable and cacheable, and
17
- * both are wrong for a secret (`docs/detailed/providers.md`).
17
+ * both are wrong for a secret (`https://lanes.sh/docs/link/capabilities`).
18
18
  *
19
19
  * **Per-item policy through the capability name.** Each stored item gets its own
20
20
  * read capability — `vault.get.github_token` — so `deny: [vault.get.bank]` and
@@ -0,0 +1,10 @@
1
+ import { defineProvider } from '#connectivity';
2
+
3
+ /** Vercel registers us at connect time — nothing for an operator to set up. */
4
+ export const vercel = defineProvider({
5
+ id: 'vercel',
6
+ name: 'Vercel',
7
+ description: 'Projects, deployments, build logs, and domains, via Vercel\'s official MCP server.',
8
+ connector: { kind: 'mcp', endpoint: 'https://mcp.vercel.com' },
9
+ auth: { kind: 'oauth', registration: 'dynamic' },
10
+ });
@@ -0,0 +1,10 @@
1
+ import { defineProvider } from '#connectivity';
2
+
3
+ /** Vimeo registers us at connect time — nothing for an operator to set up. */
4
+ export const vimeo = defineProvider({
5
+ id: 'vimeo',
6
+ name: 'Vimeo',
7
+ description: 'Videos, folders, showcases, and analytics, via Vimeo\'s official MCP server.',
8
+ connector: { kind: 'mcp', endpoint: 'https://mcp.vimeo.com/mcp' },
9
+ auth: { kind: 'oauth', registration: 'dynamic' },
10
+ });
@@ -0,0 +1,10 @@
1
+ import { defineProvider } from '#connectivity';
2
+
3
+ /** Webflow registers us at connect time — nothing for an operator to set up. */
4
+ export const webflow = defineProvider({
5
+ id: 'webflow',
6
+ name: 'Webflow',
7
+ description: 'Sites, pages, CMS collections, and items, via Webflow\'s official MCP server.',
8
+ connector: { kind: 'mcp', endpoint: 'https://mcp.webflow.com/mcp' },
9
+ auth: { kind: 'oauth', registration: 'dynamic' },
10
+ });
@@ -0,0 +1,10 @@
1
+ import { defineProvider } from '#connectivity';
2
+
3
+ /** Whimsical registers us at connect time — nothing for an operator to set up. */
4
+ export const whimsical = defineProvider({
5
+ id: 'whimsical',
6
+ name: 'Whimsical',
7
+ description: 'Boards, flowcharts, wireframes, and mind maps, via Whimsical\'s official MCP server.',
8
+ connector: { kind: 'mcp', endpoint: 'https://mcp.whimsical.com/mcp' },
9
+ auth: { kind: 'oauth', registration: 'dynamic' },
10
+ });
@@ -0,0 +1,10 @@
1
+ import { defineProvider } from '#connectivity';
2
+
3
+ /** Wix registers us at connect time — nothing for an operator to set up. */
4
+ export const wix = defineProvider({
5
+ id: 'wix',
6
+ name: 'Wix',
7
+ description: 'Sites, stores, bookings, and CMS data, via Wix\'s official MCP server.',
8
+ connector: { kind: 'mcp', endpoint: 'https://mcp.wix.com/mcp' },
9
+ auth: { kind: 'oauth', registration: 'dynamic' },
10
+ });
@@ -0,0 +1,10 @@
1
+ import { defineProvider } from '#connectivity';
2
+
3
+ /** Workable registers us at connect time — nothing for an operator to set up. */
4
+ export const workable = defineProvider({
5
+ id: 'workable',
6
+ name: 'Workable',
7
+ description: 'Jobs, candidates, and interviews, via Workable\'s official MCP server.',
8
+ connector: { kind: 'mcp', endpoint: 'https://mcp.workable.com/mcp' },
9
+ auth: { kind: 'oauth', registration: 'dynamic' },
10
+ });
@@ -0,0 +1,73 @@
1
+ import { defineProvider } from '#connectivity';
2
+
3
+ /**
4
+ * Yahoo Mail over IMAP.
5
+ *
6
+ * Yahoo requires an app password for IMAP — the account password is refused
7
+ * outright, and has been since Yahoo turned off "less secure app access". The
8
+ * same hosts serve AOL and the other Yahoo-operated domains under their own
9
+ * names, which is why this is one provider rather than a family.
10
+ */
11
+ export const yahooMail = defineProvider({
12
+ id: 'yahoo_mail',
13
+ name: 'Yahoo Mail',
14
+ description:
15
+ 'Read, search, and send mail in a Yahoo mailbox over IMAP and SMTP, with an app password.',
16
+ connector: {
17
+ kind: 'imap',
18
+ host: 'imap.mail.yahoo.com',
19
+ port: 993,
20
+ smtp: { host: 'smtp.mail.yahoo.com', port: 587, starttls: true },
21
+ },
22
+ auth: { kind: 'basic' },
23
+ identity: { kind: 'connector' },
24
+ setup: {
25
+ summary:
26
+ 'Yahoo Mail uses an app password, which is not your Yahoo account password. Yahoo refuses the ' +
27
+ 'account password for IMAP entirely, so there is no other route to a Yahoo mailbox here.',
28
+ docs_url: 'https://help.yahoo.com/kb/SLN15241.html',
29
+ steps: [
30
+ 'Sign in at https://login.yahoo.com/account/security.',
31
+ 'Choose "Generate app password" (it may be under "Other ways to sign in"). Name it "Lanes Link" — the name is the only way to revoke this one later without cutting off your other devices.',
32
+ 'Copy the sixteen characters. Yahoo shows them once, in four groups of four; the spaces are cosmetic and it is accepted either way.',
33
+ 'You are asked for your full address next, which is the one you sign in with.',
34
+ ],
35
+ troubleshooting:
36
+ 'For Yahoo this is almost always the account password used where an app password belongs — Yahoo ' +
37
+ 'refuses the account password for IMAP and reports it identically to a wrong one. Generate an app ' +
38
+ 'password at https://login.yahoo.com/account/security and re-run: lanes link connect yahoo_mail --replace.',
39
+ prompts: [
40
+ {
41
+ key: 'username',
42
+ label: 'Yahoo address (the full email address)',
43
+ secret: false,
44
+ scope: 'connection' as const,
45
+ field: 'username' as const,
46
+ },
47
+ {
48
+ key: 'password',
49
+ label: 'App password (sixteen characters)',
50
+ secret: true,
51
+ scope: 'connection' as const,
52
+ field: 'password' as const,
53
+ },
54
+ ],
55
+ },
56
+ redact: {
57
+ // Never the search terms — a query is content, and "who did I email about
58
+ // the diagnosis" is the whole message.
59
+ search_messages: ['mailbox', 'limit', 'unseen', 'flagged'],
60
+ // Identifiers only, and all of them: which mailbox, which message, which
61
+ // attachment. What the file turned out to be — name, size, type, digest —
62
+ // is recorded by the handler through `audit.annotate`, the same way the send
63
+ // path records what it attached.
64
+ get_attachment: ['mailbox', 'uid', 'message_id', 'attachment_id'],
65
+ get_message: ['mailbox', 'uid', 'include_body'],
66
+ mark_messages: ['mailbox', 'add_flags', 'remove_flags'],
67
+ move_messages: ['mailbox', 'destination', 'destination_flag'],
68
+ // Nothing. The recipients and the body are the message, and `attachments`
69
+ // may literally contain a file — what was attached is recorded by the send
70
+ // path itself through `audit.annotate`.
71
+ send_message: [],
72
+ },
73
+ });
@@ -0,0 +1,10 @@
1
+ import { defineProvider } from '#connectivity';
2
+
3
+ /** Zapier registers us at connect time — nothing for an operator to set up. */
4
+ export const zapier = defineProvider({
5
+ id: 'zapier',
6
+ name: 'Zapier',
7
+ description: 'Zaps, and the actions they reach across thousands of apps, via Zapier\'s official MCP server.',
8
+ connector: { kind: 'mcp', endpoint: 'https://mcp.zapier.com/api/mcp/mcp' },
9
+ auth: { kind: 'oauth', registration: 'dynamic' },
10
+ });
@@ -0,0 +1,79 @@
1
+ import { defineProvider } from '#connectivity';
2
+
3
+ /**
4
+ * Zoho Mail over IMAP.
5
+ *
6
+ * The hosts below are Zoho's global ones. An account created in a different
7
+ * data centre answers on a regional hostname instead — `imap.zoho.eu`,
8
+ * `imap.zoho.in`, `imap.zoho.com.au` — and the failure is a connection refused
9
+ * rather than an authentication error, which reads like the password is wrong.
10
+ * A manifest holds one host, so a regional account is a manifest of its own in
11
+ * `providers.d/`: the same two hosts with the suffix changed. `troubleshooting`
12
+ * below is where somebody actually hits this.
13
+ */
14
+ export const zohoMail = defineProvider({
15
+ id: 'zoho_mail',
16
+ name: 'Zoho Mail',
17
+ description:
18
+ 'Read, search, and send mail in a Zoho mailbox over IMAP and SMTP, with an application-specific password.',
19
+ connector: {
20
+ kind: 'imap',
21
+ host: 'imap.zoho.com',
22
+ port: 993,
23
+ smtp: { host: 'smtp.zoho.com', port: 587, starttls: true },
24
+ },
25
+ auth: { kind: 'basic' },
26
+ identity: { kind: 'connector' },
27
+ setup: {
28
+ summary:
29
+ 'Zoho Mail uses an application-specific password when two-factor authentication is on, which ' +
30
+ 'is the supported way to reach IMAP. It is not your Zoho account password, and it does not expire.',
31
+ docs_url: 'https://www.zoho.com/accounts/help/multi-factor-authentication/application-specific-passwords.html',
32
+ steps: [
33
+ 'Sign in at https://accounts.zoho.com and open Security → App Passwords.',
34
+ 'Choose "Generate New Password" and name it "Lanes Link" — the name is the only way to revoke this one later without cutting off your other devices.',
35
+ 'Copy the password Zoho shows. It is shown once.',
36
+ 'IMAP has to be enabled for the mailbox: Zoho Mail → Settings → Mail Accounts → IMAP Access.',
37
+ 'If your account is in a region other than the global one, the hosts differ — see the note below.',
38
+ ],
39
+ troubleshooting:
40
+ 'If the connection is refused rather than the password rejected, the account is likely in a regional ' +
41
+ 'data centre: imap.zoho.eu, imap.zoho.in, or imap.zoho.com.au rather than imap.zoho.com. Declare those ' +
42
+ 'hosts in a manifest of your own under providers.d/ — see docs/detailed/creating-a-provider.md. If the ' +
43
+ 'password is rejected, it is usually the Zoho account password used where an application-specific one ' +
44
+ 'belongs: generate one at https://accounts.zoho.com and re-run: lanes link connect zoho_mail --replace.',
45
+ prompts: [
46
+ {
47
+ key: 'username',
48
+ label: 'Zoho address (the full email address)',
49
+ secret: false,
50
+ scope: 'connection' as const,
51
+ field: 'username' as const,
52
+ },
53
+ {
54
+ key: 'password',
55
+ label: 'Application-specific password',
56
+ secret: true,
57
+ scope: 'connection' as const,
58
+ field: 'password' as const,
59
+ },
60
+ ],
61
+ },
62
+ redact: {
63
+ // Never the search terms — a query is content, and "who did I email about
64
+ // the diagnosis" is the whole message.
65
+ search_messages: ['mailbox', 'limit', 'unseen', 'flagged'],
66
+ // Identifiers only, and all of them: which mailbox, which message, which
67
+ // attachment. What the file turned out to be — name, size, type, digest —
68
+ // is recorded by the handler through `audit.annotate`, the same way the send
69
+ // path records what it attached.
70
+ get_attachment: ['mailbox', 'uid', 'message_id', 'attachment_id'],
71
+ get_message: ['mailbox', 'uid', 'include_body'],
72
+ mark_messages: ['mailbox', 'add_flags', 'remove_flags'],
73
+ move_messages: ['mailbox', 'destination', 'destination_flag'],
74
+ // Nothing. The recipients and the body are the message, and `attachments`
75
+ // may literally contain a file — what was attached is recorded by the send
76
+ // path itself through `audit.annotate`.
77
+ send_message: [],
78
+ },
79
+ });
@@ -17,7 +17,7 @@ import type { BlobStore } from '#stores/blobs';
17
17
  *
18
18
  * What is *not* merged is the two stores themselves. They keep separate
19
19
  * documents, separate keys, and separate environment variables, because that is
20
- * the boundary `docs/detailed/security.md` is built on: one master secret reused across
20
+ * the boundary `https://lanes.sh/docs/link/security` is built on: one master secret reused across
21
21
  * purposes turns any single compromise into a total one. Sharing the code that
22
22
  * seals a document is the opposite of sharing the key that opens it.
23
23
  *
@@ -1,3 +1,4 @@
1
+ import { isHandle } from '#connectivity/mail';
1
2
  import { mergeCapabilities, type ProfileRuntime } from './mcp/index.ts';
2
3
  import type { Principal } from '#auth';
3
4
 
@@ -51,11 +52,20 @@ export interface StageOptions {
51
52
  readonly clientLabel?: string | undefined;
52
53
  }
53
54
 
54
- export async function stageAttachment(options: StageOptions): Promise<Response> {
55
+ /**
56
+ * One path, both directions.
57
+ *
58
+ * `POST` takes bytes and gives back a handle; `GET` takes a handle and gives
59
+ * back the bytes. Same path, same query, same authorization, because they are
60
+ * the two ends of one idea and splitting them across two routes would invite
61
+ * the scoping to drift apart — which is the one thing that must not happen
62
+ * here, since the scoping is what keeps one account's files out of another's.
63
+ */
64
+ export async function handleAttachments(options: StageOptions): Promise<Response> {
55
65
  const { request } = options;
56
66
 
57
- if (request.method !== 'POST') {
58
- return problem(405, 'Stage an attachment with POST.');
67
+ if (request.method !== 'POST' && request.method !== 'GET') {
68
+ return problem(405, 'Stage an attachment with POST, or fetch one with GET.');
59
69
  }
60
70
 
61
71
  const url = new URL(request.url);
@@ -82,7 +92,8 @@ export async function stageAttachment(options: StageOptions): Promise<Response>
82
92
 
83
93
  // The same policy that decides which connections a tool may name. Staging into
84
94
  // an account the caller cannot act on would be a write they are not permitted
85
- // to make, even though nothing is sent by it.
95
+ // to make, even though nothing is sent by it — and fetching back out of one is
96
+ // a read they are not permitted to make, which matters more.
86
97
  if (!reachable(runtime, options, profileName, target)) {
87
98
  return problem(
88
99
  403,
@@ -90,6 +101,10 @@ export async function stageAttachment(options: StageOptions): Promise<Response>
90
101
  );
91
102
  }
92
103
 
104
+ if (request.method === 'GET') {
105
+ return await fetchAttachment({ options, runtime, url, providerId, connectionId, target });
106
+ }
107
+
93
108
  const bytes = await readCapped(request);
94
109
  if (!bytes) {
95
110
  return problem(413, `An attachment must be smaller than ${MAX_UPLOAD_BYTES} bytes.`);
@@ -124,6 +139,83 @@ export async function stageAttachment(options: StageOptions): Promise<Response>
124
139
  });
125
140
  }
126
141
 
142
+ /**
143
+ * Handing a staged file back.
144
+ *
145
+ * The counterpart to the upload, and the thing that makes an attachment which
146
+ * arrived by mail reachable at all: `get_attachment` on a mail connection pulls
147
+ * the bytes out of the mailbox and stages them *in that same connection's*
148
+ * namespace, and this is where the client collects them. No provider gains
149
+ * reach it did not have — the bytes were already inside that account's area —
150
+ * and nothing crosses between two connections.
151
+ *
152
+ * The bytes go out over HTTP for the same reason they come in that way: a file
153
+ * in a tool result is base64 in the model's context, which for a 239 KB PDF is
154
+ * around 320,000 characters of something no model needs to read.
155
+ */
156
+ async function fetchAttachment(input: {
157
+ readonly options: StageOptions;
158
+ readonly runtime: ProfileRuntime;
159
+ readonly url: URL;
160
+ readonly providerId: string;
161
+ readonly connectionId: string;
162
+ readonly target: string;
163
+ }): Promise<Response> {
164
+ const handle = input.url.searchParams.get('handle');
165
+ if (!handle) {
166
+ return problem(400, 'Name the file to fetch, as ?handle=att_… — the handle a stage or a get_attachment returned.');
167
+ }
168
+ if (!isHandle(handle)) {
169
+ return problem(400, `"${handle}" is not a handle.`);
170
+ }
171
+
172
+ const found = await input.runtime.dispatcher.fetchStagedAttachment({
173
+ principal: input.options.principal,
174
+ providerId: input.providerId,
175
+ connectionId: input.connectionId,
176
+ handle,
177
+ clientLabel: input.options.clientLabel,
178
+ });
179
+
180
+ // One answer for absent, expired, and staged-against-another-connection. The
181
+ // caller can act on none of the three differently, and telling them apart is
182
+ // how someone probes for handles belonging to an account they cannot reach.
183
+ if (!found) {
184
+ return problem(
185
+ 404,
186
+ `No staged file "${handle}" for ${input.target}. A staged file lasts 24 hours and belongs to the connection it was staged for.`,
187
+ );
188
+ }
189
+
190
+ return new Response(found.bytes, {
191
+ headers: {
192
+ 'content-type': found.contentType ?? 'application/octet-stream',
193
+ 'content-length': String(found.bytes.byteLength),
194
+ // `attachment` rather than `inline`: this is a file the caller asked for,
195
+ // and a browser rendering someone's mail attachment in the tab is not it.
196
+ 'content-disposition': dispositionFor(found.filename),
197
+ // Handles are unguessable and short-lived, but a shared cache holding
198
+ // somebody's mail attachment is not a trade worth making.
199
+ 'cache-control': 'no-store',
200
+ },
201
+ });
202
+ }
203
+
204
+ /**
205
+ * A `Content-Disposition` that survives a non-ASCII filename.
206
+ *
207
+ * RFC 6266: the bare `filename` is the fallback for old clients and must stay
208
+ * ASCII, and `filename*` carries the real one percent-encoded. Mail attachments
209
+ * are exactly where this bites — an invoice from a German sender is called
210
+ * `Rechnung_Kaufmännisch.pdf` and a raw header would be rejected outright.
211
+ */
212
+ function dispositionFor(filename: string | null): string {
213
+ if (!filename) return 'attachment';
214
+
215
+ const ascii = filename.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
216
+ return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
217
+ }
218
+
127
219
  /** Whether this principal can reach that connection for anything at all. */
128
220
  function reachable(
129
221
  runtime: ProfileRuntime,
@@ -188,10 +280,42 @@ async function readCapped(request: Request): Promise<Uint8Array | null> {
188
280
  * `Content-Disposition` and stored beside the bytes, and neither wants a path.
189
281
  */
190
282
  function filenameFrom(header: string | null): string {
191
- const candidate = (header ?? '').split(/[/\\]/).pop()?.trim() ?? '';
283
+ const candidate = decodeHeader(header ?? '').split(/[/\\]/).pop()?.trim() ?? '';
192
284
  return candidate === '' || candidate === '.' || candidate === '..' ? 'attachment' : candidate;
193
285
  }
194
286
 
287
+ /**
288
+ * A header value as the bytes it actually was.
289
+ *
290
+ * An HTTP header carries bytes, and a runtime hands them back one character per
291
+ * byte — so a filename a client sent as UTF-8 arrives here as mojibake and is
292
+ * *stored* that way. `Rechnung_Kaufmännisch.pdf` became
293
+ * `Rechnung_Kaufmännisch.pdf` in the metadata sidecar, and stayed wrong on the
294
+ * way back out; it was invisible while there was no way to fetch a file back.
295
+ *
296
+ * Pure ASCII is returned untouched, which is almost every filename. Anything
297
+ * that is not valid UTF-8 keeps whatever it was rather than being replaced with
298
+ * question marks — a name we cannot decode is still the name the client chose.
299
+ */
300
+ function decodeHeader(value: string): string {
301
+ // Nothing to do for ASCII, and a code point above 0xff means the runtime has
302
+ // already decoded it — reinterpreting that would be the corruption, not the fix.
303
+ let latin1 = false;
304
+ for (const character of value) {
305
+ const code = character.codePointAt(0) ?? 0;
306
+ if (code > 0xff) return value;
307
+ if (code > 0x7f) latin1 = true;
308
+ }
309
+ if (!latin1) return value;
310
+
311
+ try {
312
+ const bytes = Uint8Array.from(value, (character) => character.charCodeAt(0));
313
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
314
+ } catch {
315
+ return value;
316
+ }
317
+ }
318
+
195
319
  function problem(status: number, message: string): Response {
196
320
  return Response.json({ error: message }, { status });
197
321
  }
@@ -1,7 +1,7 @@
1
1
  import type { Authenticator } from '#auth';
2
2
  import type { Logger } from '#connectivity';
3
3
  import { capabilityIdForToolName } from '#server/mcp';
4
- import { ATTACHMENTS_PATH, stageAttachment } from './attachments.ts';
4
+ import { ATTACHMENTS_PATH, handleAttachments } from './attachments.ts';
5
5
  import { allowedHostnamesFor, rebindingRefusal } from './rebinding.ts';
6
6
  import { ANY_ORIGIN, corsAware, type CorsPolicy } from './cors.ts';
7
7
  import type { Generation } from './generation.ts';
@@ -234,7 +234,7 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
234
234
  // into one named connection rather than a shared area, so a staged file
235
235
  // stays as isolated as the account it was staged for.
236
236
  if (url.pathname === ATTACHMENTS_PATH) {
237
- return await stageAttachment({
237
+ return await handleAttachments({
238
238
  profiles: generation.profiles,
239
239
  primary: options.primary,
240
240
  principal: outcome.principal,
@@ -262,7 +262,7 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
262
262
  // whose default is `'stateless'`, so those requests are served rather
263
263
  // than refused. This check short-circuits and the refusal goes
264
264
  // unrecorded. That is the second documented exception to
265
- // `audit.every-invocation` in `docs/detailed/security.md`, asserted in
265
+ // `audit.every-invocation` in `https://lanes.sh/docs/link/security`, asserted in
266
266
  // `index.test.ts`. Closing it means cloning and parsing the body when
267
267
  // the header is absent, which is what `stdio.ts` does for want of
268
268
  // headers.