@lanes-sh/link 0.6.10 → 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 (202) hide show
  1. package/.gcloudignore +25 -15
  2. package/README.md +62 -45
  3. package/instructions/skills/lanes-link/SKILL.md +25 -0
  4. package/package.json +3 -2
  5. package/src/audit/conformance.ts +1 -1
  6. package/src/cli/argv.ts +14 -1
  7. package/src/cli/commands/connect/acquire.ts +100 -0
  8. package/src/cli/commands/connect/custom/index.ts +1 -1
  9. package/src/cli/commands/connect/declare.ts +19 -0
  10. package/src/cli/commands/connect/index.ts +54 -56
  11. package/src/cli/commands/connect/requirements.ts +23 -1
  12. package/src/cli/commands/connect/setup.ts +1 -1
  13. package/src/cli/commands/connect/variables.ts +220 -0
  14. package/src/cli/commands/knowledge/index.ts +22 -4
  15. package/src/cli/commands/knowledge/migrate.ts +61 -43
  16. package/src/cli/commands/operate/tools.ts +1 -1
  17. package/src/cli/commands/owner/entities.ts +244 -0
  18. package/src/cli/commands/owner/shared.ts +14 -0
  19. package/src/cli/commands/owner/vault.ts +2 -2
  20. package/src/cli/commands/owner.ts +13 -3
  21. package/src/cli/commands/setup.ts +12 -0
  22. package/src/cli/config-edit.ts +16 -6
  23. package/src/cli/config-repair.ts +10 -0
  24. package/src/cli/dispatch-owner.ts +30 -1
  25. package/src/cli/main.ts +2 -1
  26. package/src/cli/runtime/open.ts +14 -7
  27. package/src/cli/runtime/registry.ts +6 -1
  28. package/src/cli/selection.ts +11 -0
  29. package/src/cli/usage.ts +13 -2
  30. package/src/connectivity/auth/strategy/index.ts +1 -1
  31. package/src/connectivity/capability.ts +1 -1
  32. package/src/connectivity/context.ts +1 -1
  33. package/src/connectivity/index.ts +1 -1
  34. package/src/connectivity/mail/attachments.ts +42 -73
  35. package/src/connectivity/mail/content-type.ts +71 -0
  36. package/src/connectivity/mail/index.ts +1 -1
  37. package/src/connectivity/manifest/provider.ts +50 -0
  38. package/src/connectivity/manifest/variables.ts +173 -0
  39. package/src/connectivity/transports/factory.ts +74 -1
  40. package/src/connectivity/transports/http/index.ts +8 -2
  41. package/src/connectivity/transports/http/keys.ts +83 -0
  42. package/src/connectivity/transports/imap/attachment.ts +53 -10
  43. package/src/connectivity/transports/imap/capabilities.ts +28 -0
  44. package/src/connectivity/transports/imap/download.ts +111 -0
  45. package/src/connectivity/transports/imap/index.ts +13 -0
  46. package/src/connectivity/transports/imap/operations.ts +1 -0
  47. package/src/deployments/adapters/s3.ts +1 -1
  48. package/src/deployments/gcp/provision.ts +1 -1
  49. package/src/deployments/knowledge.ts +44 -18
  50. package/src/deployments/prepare.ts +1 -1
  51. package/src/deployments/serving.ts +1 -1
  52. package/src/dispatch/dispatch.ts +17 -10
  53. package/src/dispatch/staging.ts +76 -1
  54. package/src/profile/knowledge.ts +19 -11
  55. package/src/profile/schema.ts +2 -2
  56. package/src/providers/README.md +189 -0
  57. package/src/providers/airtable/index.ts +10 -0
  58. package/src/providers/algolia/index.ts +10 -0
  59. package/src/providers/amplitude/index.ts +10 -0
  60. package/src/providers/apify/index.ts +10 -0
  61. package/src/providers/asana/index.ts +10 -0
  62. package/src/providers/assets/provider.ts +11 -4
  63. package/src/providers/atlassian/index.ts +19 -0
  64. package/src/providers/attio/index.ts +10 -0
  65. package/src/providers/betterstack/index.ts +10 -0
  66. package/src/providers/box/index.ts +64 -0
  67. package/src/providers/brightdata/index.ts +10 -0
  68. package/src/providers/buildkite/index.ts +10 -0
  69. package/src/providers/bunq/index.ts +2 -2
  70. package/src/providers/calendly/index.ts +10 -0
  71. package/src/providers/canva/index.ts +10 -0
  72. package/src/providers/circleci/index.ts +10 -0
  73. package/src/providers/clickup/index.ts +10 -0
  74. package/src/providers/close/index.ts +10 -0
  75. package/src/providers/cloudflare_bindings/index.ts +14 -0
  76. package/src/providers/cloudflare_observability/index.ts +10 -0
  77. package/src/providers/contentful/index.ts +10 -0
  78. package/src/providers/custom/load.ts +1 -1
  79. package/src/providers/datadog/index.ts +10 -0
  80. package/src/providers/discord/index.ts +1 -1
  81. package/src/providers/discord/specs/discord.v10.json +0 -88
  82. package/src/providers/discord/specs/vendor.ts +1 -1
  83. package/src/providers/dropbox/index.ts +10 -0
  84. package/src/providers/entities/catalogue.ts +366 -0
  85. package/src/providers/entities/find.ts +279 -0
  86. package/src/providers/entities/provider.ts +334 -0
  87. package/src/providers/entities/render.ts +142 -0
  88. package/src/providers/entities/store.ts +276 -0
  89. package/src/providers/entities/writes.ts +279 -0
  90. package/src/providers/example/provider.ts +1 -1
  91. package/src/providers/expensify/index.ts +10 -0
  92. package/src/providers/fastmail/calendar/index.ts +21 -0
  93. package/src/providers/fastmail/contacts/index.ts +17 -0
  94. package/src/providers/fastmail/index.ts +4 -0
  95. package/src/providers/fastmail/mail/index.ts +38 -0
  96. package/src/providers/fastmail/shared/setup.ts +52 -0
  97. package/src/providers/figma/index.ts +10 -0
  98. package/src/providers/fireflies/index.ts +10 -0
  99. package/src/providers/flagsmith/index.ts +10 -0
  100. package/src/providers/gamma/index.ts +10 -0
  101. package/src/providers/github/index.ts +2 -2
  102. package/src/providers/google/gmail/attachment.ts +16 -1
  103. package/src/providers/google/gmail-imap/index.ts +7 -2
  104. package/src/providers/google/shared/oauth.ts +1 -1
  105. package/src/providers/google/shared/service-account.ts +1 -1
  106. package/src/providers/google/shared/setup.ts +1 -1
  107. package/src/providers/google/specs/calendar.v3.json +0 -32
  108. package/src/providers/google/specs/docs.v1.json +0 -76
  109. package/src/providers/google/specs/drive.v3.json +0 -32
  110. package/src/providers/google/specs/gmail.v1.json +0 -76
  111. package/src/providers/google/specs/people.v1.json +0 -77
  112. package/src/providers/google/specs/sheets.v4.json +0 -76
  113. package/src/providers/google/specs/tasks.v1.json +0 -76
  114. package/src/providers/grafana/index.ts +10 -0
  115. package/src/providers/heroku/index.ts +10 -0
  116. package/src/providers/hubspot/index.ts +75 -0
  117. package/src/providers/hygraph/index.ts +10 -0
  118. package/src/providers/icloud/mail/index.ts +5 -0
  119. package/src/providers/icloud/shared/setup.ts +1 -1
  120. package/src/providers/index.ts +255 -0
  121. package/src/providers/insightly/index.ts +10 -0
  122. package/src/providers/jam/index.ts +10 -0
  123. package/src/providers/klaviyo/index.ts +10 -0
  124. package/src/providers/mailbox/index.ts +87 -0
  125. package/src/providers/memory/provider.ts +3 -8
  126. package/src/providers/mercury/index.ts +10 -0
  127. package/src/providers/microsoft/calendar/index.ts +49 -0
  128. package/src/providers/microsoft/contacts/index.ts +50 -0
  129. package/src/providers/microsoft/drive/index.ts +62 -0
  130. package/src/providers/microsoft/index.ts +12 -0
  131. package/src/providers/microsoft/mail/index.ts +76 -0
  132. package/src/providers/microsoft/shared/oauth.ts +58 -0
  133. package/src/providers/microsoft/shared/scopes.ts +56 -0
  134. package/src/providers/microsoft/shared/setup.ts +45 -0
  135. package/src/providers/microsoft/specs/microsoft-todo.v1.json +755 -0
  136. package/src/providers/microsoft/specs/onedrive.v1.json +993 -0
  137. package/src/providers/microsoft/specs/outlook-calendar.v1.json +1743 -0
  138. package/src/providers/microsoft/specs/outlook-contacts.v1.json +430 -0
  139. package/src/providers/microsoft/specs/outlook-mail.v1.json +901 -0
  140. package/src/providers/microsoft/specs/vendor.ts +232 -0
  141. package/src/providers/microsoft/todo/index.ts +54 -0
  142. package/src/providers/miro/index.ts +10 -0
  143. package/src/providers/mixpanel/index.ts +10 -0
  144. package/src/providers/monday/index.ts +10 -0
  145. package/src/providers/mux/index.ts +10 -0
  146. package/src/providers/navan/index.ts +10 -0
  147. package/src/providers/neon/index.ts +10 -0
  148. package/src/providers/netlify/index.ts +10 -0
  149. package/src/providers/nextcloud/calendar/index.ts +28 -0
  150. package/src/providers/nextcloud/contacts/index.ts +18 -0
  151. package/src/providers/nextcloud/index.ts +3 -0
  152. package/src/providers/nextcloud/shared/setup.ts +59 -0
  153. package/src/providers/owner.ts +15 -3
  154. package/src/providers/paddle/index.ts +10 -0
  155. package/src/providers/paypal/index.ts +10 -0
  156. package/src/providers/posthog/index.ts +10 -0
  157. package/src/providers/prisma/index.ts +10 -0
  158. package/src/providers/ramp/index.ts +10 -0
  159. package/src/providers/recurly/index.ts +10 -0
  160. package/src/providers/reddit/index.ts +1 -1
  161. package/src/providers/remote/index.ts +10 -0
  162. package/src/providers/render/index.ts +35 -0
  163. package/src/providers/replicate/index.ts +10 -0
  164. package/src/providers/resend/index.ts +10 -0
  165. package/src/providers/riverside/index.ts +10 -0
  166. package/src/providers/rootly/index.ts +10 -0
  167. package/src/providers/rudderstack/index.ts +10 -0
  168. package/src/providers/salesloft/index.ts +10 -0
  169. package/src/providers/sanity/index.ts +10 -0
  170. package/src/providers/scopes.ts +2 -0
  171. package/src/providers/sentry/index.ts +10 -0
  172. package/src/providers/setup/plan.ts +11 -0
  173. package/src/providers/setup/provider.ts +12 -0
  174. package/src/providers/shared/openapi.ts +70 -0
  175. package/src/providers/shared/slug.ts +28 -0
  176. package/src/providers/shared/vendor-operations.ts +43 -1
  177. package/src/providers/shared/vendor-spec.ts +98 -17
  178. package/src/providers/shortcut/index.ts +10 -0
  179. package/src/providers/slack/index.ts +2 -2
  180. package/src/providers/square/index.ts +10 -0
  181. package/src/providers/storyblok/index.ts +10 -0
  182. package/src/providers/stripe/index.ts +10 -0
  183. package/src/providers/supabase/index.ts +10 -0
  184. package/src/providers/tasks/store.ts +2 -7
  185. package/src/providers/tavily/index.ts +10 -0
  186. package/src/providers/todoist/index.ts +10 -0
  187. package/src/providers/untested.ts +105 -0
  188. package/src/providers/vault/provider.ts +1 -1
  189. package/src/providers/vercel/index.ts +10 -0
  190. package/src/providers/vimeo/index.ts +10 -0
  191. package/src/providers/webflow/index.ts +10 -0
  192. package/src/providers/whimsical/index.ts +10 -0
  193. package/src/providers/wix/index.ts +10 -0
  194. package/src/providers/workable/index.ts +10 -0
  195. package/src/providers/yahoo_mail/index.ts +73 -0
  196. package/src/providers/zapier/index.ts +10 -0
  197. package/src/providers/zoho_mail/index.ts +79 -0
  198. package/src/secrets/document.ts +1 -1
  199. package/src/server/attachments.ts +129 -5
  200. package/src/server/index.ts +3 -3
  201. package/src/server/mcp/instructions.ts +88 -18
  202. package/src/server/stdio.ts +1 -1
@@ -49,7 +49,7 @@ export interface ConnectionInfo {
49
49
  * provider can do anything that credential permits. There is no provider
50
50
  * sandbox in M1. Installing a third-party provider is equivalent to running
51
51
  * arbitrary code with access to that account, and
52
- * `docs/detailed/creating-a-provider.md` says so plainly.
52
+ * `https://lanes.sh/docs/link/creating-a-provider` says so plainly.
53
53
  */
54
54
  export interface ProviderContext {
55
55
  readonly connection: ConnectionInfo;
@@ -14,7 +14,7 @@
14
14
  * This barrel is also the **provider authoring surface**: a provider must be
15
15
  * writable without reading the rest of the codebase. If you need something not
16
16
  * exported here, that is a bug in this file rather than a reason to reach into
17
- * another component. See `docs/detailed/creating-a-provider.md`.
17
+ * another component. See `https://lanes.sh/docs/link/creating-a-provider`.
18
18
  */
19
19
 
20
20
  export type { ScopedStore, Logger, ConnectionInfo, ProviderContext } from './context.ts';
@@ -5,6 +5,7 @@ import type { BlobStore } from '#stores/blobs';
5
5
  import type { ResolvedAttachment } from './message.ts';
6
6
  import { getStaged } from './staging.ts';
7
7
  import { fetchFromUrl, type AddressLookup } from './url.ts';
8
+ import { basename, guessContentType } from './content-type.ts';
8
9
 
9
10
  /**
10
11
  * Turning a named attachment into bytes.
@@ -21,11 +22,12 @@ import { fetchFromUrl, type AddressLookup } from './url.ts';
21
22
  * url fetched over HTTPS, with the checks in `url.ts`
22
23
  * handle bytes staged earlier through the upload route
23
24
  * message_id an attachment already sitting in the mailbox being used
25
+ * uid the same, addressed the way the mailbox itself addresses it
24
26
  * data base64 inline — the escape hatch, not the path
25
27
  *
26
28
  * `path` is deliberately unrestricted: no allowlist, no confinement to a root.
27
29
  * The endpoint already holds its owner's credentials, so the filesystem is
28
- * treated the same way, and `docs/detailed/creating-a-provider.md`'s note that provider
30
+ * treated the same way, and `https://lanes.sh/docs/link/creating-a-provider`'s note that provider
29
31
  * code is trusted code applies here too. What makes that defensible is the audit
30
32
  * trail rather than a sandbox — every resolved attachment carries its origin and
31
33
  * a SHA-256, and the manifest's `redact` block keeps both, so "was this file ever
@@ -34,7 +36,7 @@ import { fetchFromUrl, type AddressLookup } from './url.ts';
34
36
  */
35
37
 
36
38
  /** The source keys, in the order they are reported when a caller supplies two. */
37
- const SOURCE_KEYS = ['path', 'url', 'handle', 'message_id', 'data'] as const;
39
+ const SOURCE_KEYS = ['path', 'url', 'handle', 'message_id', 'uid', 'data'] as const;
38
40
 
39
41
  export const attachmentRefSchema = z
40
42
  .strictObject({
@@ -48,10 +50,22 @@ export const attachmentRefSchema = z
48
50
  .string()
49
51
  .optional()
50
52
  .describe('Re-attach an attachment already on a message in this mailbox.'),
53
+ uid: z
54
+ .number()
55
+ .int()
56
+ .positive()
57
+ .optional()
58
+ .describe(
59
+ 'Re-attach from a message named by its mailbox UID, as search_messages and get_message report it. Pair with mailbox. Prefer this to message_id on a mailbox that has both: it addresses the message directly instead of searching every folder for a header.',
60
+ ),
61
+ mailbox: z
62
+ .string()
63
+ .optional()
64
+ .describe('Which mailbox the uid belongs to. Defaults to INBOX.'),
51
65
  attachment_id: z
52
66
  .string()
53
67
  .optional()
54
- .describe('Which attachment on that message. Required with message_id where the provider ids them.'),
68
+ .describe('Which attachment on that message. Required with message_id or uid where the provider ids them.'),
55
69
  data: z
56
70
  .string()
57
71
  .optional()
@@ -61,7 +75,7 @@ export const attachmentRefSchema = z
61
75
  filename: z.string().optional().describe('Overrides the name derived from the source.'),
62
76
  content_type: z.string().optional().describe('Overrides the type derived from the source.'),
63
77
  })
64
- .describe('One attachment, named by exactly one of path, url, handle, message_id, or data.');
78
+ .describe('One attachment, named by exactly one of path, url, handle, message_id, uid, or data.');
65
79
 
66
80
  export type AttachmentRef = z.infer<typeof attachmentRefSchema>;
67
81
 
@@ -82,9 +96,23 @@ export const attachmentsJsonSchema = ((): Record<string, unknown> => {
82
96
  return schema;
83
97
  })();
84
98
 
85
- /** Pulls bytes for `message_id` out of whatever mailbox the caller is already in. */
99
+ /**
100
+ * Pulls bytes for `message_id` or `uid` out of the mailbox the caller is already in.
101
+ *
102
+ * Two ways to name the message, because the two protocols name it differently
103
+ * and only one of them can do both. A `uid` is what IMAP itself uses and what
104
+ * `search_messages` and `get_message` already report, so it addresses a message
105
+ * directly; a `messageId` has to be *searched* for, folder by folder, and a
106
+ * server whose `HEADER MESSAGE-ID` matching is unreliable fails that search on a
107
+ * message it is holding. Gmail's REST API has no uid and takes its own id.
108
+ *
109
+ * Exactly one arrives set. An implementation that cannot serve the one it is
110
+ * given should say so naming the other, rather than returning nothing.
111
+ */
86
112
  export type MailboxAttachmentSource = (reference: {
87
- readonly messageId: string;
113
+ readonly messageId: string | undefined;
114
+ readonly mailbox: string | undefined;
115
+ readonly uid: number | undefined;
88
116
  readonly attachmentId: string | undefined;
89
117
  }) => Promise<{
90
118
  readonly bytes: Uint8Array;
@@ -213,21 +241,25 @@ async function bytesFor(
213
241
  case 'handle':
214
242
  return await fromHandle(ref.handle!, where, options.storage);
215
243
 
216
- case 'message_id': {
244
+ case 'message_id':
245
+ case 'uid': {
217
246
  if (!options.mailbox) {
218
247
  throw new Error(
219
- `${where} uses message_id, which this provider cannot resolve. Fetch the attachment and stage it, or use path or url.`,
248
+ `${where} uses ${source}, which only a mail connection can resolve this one holds no mailbox. ` +
249
+ `Name the file with path, url, or handle instead, or ask the mail connection itself to send it somewhere.`,
220
250
  );
221
251
  }
222
252
  const found = await options.mailbox({
223
- messageId: ref.message_id!,
253
+ messageId: ref.message_id,
254
+ mailbox: ref.mailbox,
255
+ uid: ref.uid,
224
256
  attachmentId: ref.attachment_id,
225
257
  });
226
258
  return {
227
259
  bytes: found.bytes,
228
260
  filename: found.filename,
229
261
  contentType: found.contentType,
230
- origin: `mailbox:${ref.message_id!}`,
262
+ origin: source === 'uid' ? `mailbox:${ref.mailbox ?? 'INBOX'}:${ref.uid!}` : `mailbox:${ref.message_id!}`,
231
263
  };
232
264
  }
233
265
 
@@ -307,66 +339,3 @@ function decodeBase64(value: string, where: string): Uint8Array {
307
339
  throw new Error(`${where}: data is not valid base64.`);
308
340
  }
309
341
  }
310
-
311
- function basename(path: string): string | null {
312
- const name = path.split(/[/\\]/).pop();
313
- return name ? decodeURIComponent(name) : null;
314
- }
315
-
316
- /**
317
- * Type from the filename, since three of the five sources do not report one.
318
- *
319
- * A table rather than `Bun.file().type`, which would resolve this in one line:
320
- * Bun-specific APIs are confined to two named files, and a mail composer is not
321
- * one of them. Deliberately short — what an attachment actually is, not a mime
322
- * database. Anything unlisted becomes `application/octet-stream`, which every
323
- * mail client handles as "a file", so the failure mode is a generic icon rather
324
- * than a broken attachment.
325
- */
326
- const CONTENT_TYPES: Readonly<Record<string, string>> = {
327
- pdf: 'application/pdf',
328
- txt: 'text/plain',
329
- md: 'text/markdown',
330
- csv: 'text/csv',
331
- html: 'text/html',
332
- json: 'application/json',
333
- xml: 'application/xml',
334
- ics: 'text/calendar',
335
- vcf: 'text/vcard',
336
- png: 'image/png',
337
- jpg: 'image/jpeg',
338
- jpeg: 'image/jpeg',
339
- gif: 'image/gif',
340
- webp: 'image/webp',
341
- svg: 'image/svg+xml',
342
- heic: 'image/heic',
343
- tiff: 'image/tiff',
344
- zip: 'application/zip',
345
- gz: 'application/gzip',
346
- mp3: 'audio/mpeg',
347
- m4a: 'audio/mp4',
348
- wav: 'audio/wav',
349
- mp4: 'video/mp4',
350
- mov: 'video/quicktime',
351
- doc: 'application/msword',
352
- docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
353
- xls: 'application/vnd.ms-excel',
354
- xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
355
- ppt: 'application/vnd.ms-powerpoint',
356
- pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
357
- // `.pages`, `.numbers` and `.key` are absent on purpose. Their registered
358
- // media types carry a vendor name, and `architecture.test.ts` refuses one
359
- // anywhere a request passes through — correctly, even though an IANA type is
360
- // not the vendor *knowledge* that rule is aimed at. They fall through to
361
- // octet-stream, which mail clients show as a file and the recipient's system
362
- // opens by extension anyway. Adding them back will fail the suite.
363
- };
364
-
365
- /**
366
- * Exported because `assets` needs exactly this and the table above must not be
367
- * copied — its `.pages`/`.numbers` note is a rule the copy would not carry.
368
- */
369
- export function guessContentType(filename: string): string {
370
- const extension = filename.split('.').pop()?.toLowerCase();
371
- return (extension ? CONTENT_TYPES[extension] : undefined) ?? 'application/octet-stream';
372
- }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * What a file is, from its name.
3
+ *
4
+ * Split out of `attachments.ts` when that file passed the size budget, and it is
5
+ * the right seam rather than a convenient one: resolving a *source* into bytes
6
+ * and deciding what those bytes *are* are separate questions, and only the
7
+ * second one is a lookup table nobody needs to read.
8
+ */
9
+
10
+ export function basename(path: string): string | null {
11
+ const name = path.split(/[/\\]/).pop();
12
+ return name ? decodeURIComponent(name) : null;
13
+ }
14
+
15
+ /**
16
+ * Type from the filename, since three of the five sources do not report one.
17
+ *
18
+ * A table rather than `Bun.file().type`, which would resolve this in one line:
19
+ * Bun-specific APIs are confined to two named files, and a mail composer is not
20
+ * one of them. Deliberately short — what an attachment actually is, not a mime
21
+ * database. Anything unlisted becomes `application/octet-stream`, which every
22
+ * mail client handles as "a file", so the failure mode is a generic icon rather
23
+ * than a broken attachment.
24
+ */
25
+ const CONTENT_TYPES: Readonly<Record<string, string>> = {
26
+ pdf: 'application/pdf',
27
+ txt: 'text/plain',
28
+ md: 'text/markdown',
29
+ csv: 'text/csv',
30
+ html: 'text/html',
31
+ json: 'application/json',
32
+ xml: 'application/xml',
33
+ ics: 'text/calendar',
34
+ vcf: 'text/vcard',
35
+ png: 'image/png',
36
+ jpg: 'image/jpeg',
37
+ jpeg: 'image/jpeg',
38
+ gif: 'image/gif',
39
+ webp: 'image/webp',
40
+ svg: 'image/svg+xml',
41
+ heic: 'image/heic',
42
+ tiff: 'image/tiff',
43
+ zip: 'application/zip',
44
+ gz: 'application/gzip',
45
+ mp3: 'audio/mpeg',
46
+ m4a: 'audio/mp4',
47
+ wav: 'audio/wav',
48
+ mp4: 'video/mp4',
49
+ mov: 'video/quicktime',
50
+ doc: 'application/msword',
51
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
52
+ xls: 'application/vnd.ms-excel',
53
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
54
+ ppt: 'application/vnd.ms-powerpoint',
55
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
56
+ // `.pages`, `.numbers` and `.key` are absent on purpose. Their registered
57
+ // media types carry a vendor name, and `architecture.test.ts` refuses one
58
+ // anywhere a request passes through — correctly, even though an IANA type is
59
+ // not the vendor *knowledge* that rule is aimed at. They fall through to
60
+ // octet-stream, which mail clients show as a file and the recipient's system
61
+ // opens by extension anyway. Adding them back will fail the suite.
62
+ };
63
+
64
+ /**
65
+ * Exported because `assets` needs exactly this and the table above must not be
66
+ * copied — its `.pages`/`.numbers` note is a rule the copy would not carry.
67
+ */
68
+ export function guessContentType(filename: string): string {
69
+ const extension = filename.split('.').pop()?.toLowerCase();
70
+ return (extension ? CONTENT_TYPES[extension] : undefined) ?? 'application/octet-stream';
71
+ }
@@ -21,10 +21,10 @@ export { receiptFor } from './message.ts';
21
21
  export { composeMime } from './compose.ts';
22
22
 
23
23
  export type { AttachmentRef, MailboxAttachmentSource, ResolveOptions } from './attachments.ts';
24
+ export { guessContentType } from './content-type.ts';
24
25
  export {
25
26
  attachmentRefSchema,
26
27
  attachmentsJsonSchema,
27
- guessContentType,
28
28
  resolveAttachments,
29
29
  } from './attachments.ts';
30
30
 
@@ -5,6 +5,7 @@ import { connectorSchema } from './connector.ts';
5
5
  import { identitySchema } from './identity.ts';
6
6
  import { identifier } from './primitives.ts';
7
7
  import { setupSchema } from './setup.ts';
8
+ import { connectionVariableSchema, placeholdersInConnector } from './variables.ts';
8
9
 
9
10
  /**
10
11
  * A provider manifest.
@@ -60,6 +61,14 @@ export const providerManifestSchema = z.object({
60
61
  * not silently discarded.
61
62
  */
62
63
  hints: z.record(z.string(), z.string()).optional(),
64
+ /**
65
+ * What this connection has to say about *where* the service is.
66
+ *
67
+ * Absent for every provider whose address is the same for everybody, which is
68
+ * almost all of them. Present for a multi-tenant host that carries the tenant,
69
+ * or anything self-hosted — see `./variables.ts`.
70
+ */
71
+ variables: z.array(connectionVariableSchema).default([]),
63
72
  });
64
73
 
65
74
  export type ProviderManifest = z.infer<typeof providerManifestSchema>;
@@ -69,6 +78,9 @@ export type ProviderManifest = z.infer<typeof providerManifestSchema>;
69
78
  *
70
79
  * The order is read: `#server/mcp`'s instructions emit one paragraph per
71
80
  * reachable id in this sequence, so it is the order an agent meets them in.
81
+ * `entities` is appended rather than inserted alphabetically so that it lands
82
+ * beside `identity`: the two answer the same question about different people,
83
+ * and the instructions collapse them into one paragraph when both are reachable.
72
84
  */
73
85
  export const RESERVED_PROVIDER_IDS: readonly string[] = [
74
86
  'memory',
@@ -78,6 +90,7 @@ export const RESERVED_PROVIDER_IDS: readonly string[] = [
78
90
  'vault',
79
91
  'setup',
80
92
  'identity',
93
+ 'entities',
81
94
  ];
82
95
 
83
96
  /**
@@ -104,6 +117,43 @@ export function defineProvider(input: unknown): ProviderManifest {
104
117
 
105
118
  const manifest = parsed.data;
106
119
 
120
+ // R12 and R13. The two halves of a variable have to agree, and neither failure
121
+ // is visible without this: a placeholder nothing fills reaches the vendor
122
+ // verbatim, so the request goes to a host literally called `{site}.zendesk.com`
123
+ // and fails as DNS rather than as configuration. A variable nothing uses is the
124
+ // opposite and quieter — `connect` asks the operator a question, stores the
125
+ // answer, and nothing ever reads it.
126
+ {
127
+ // `fs` and `local` are the two that hold no address a caller supplies.
128
+ // `local` is our own code, and `fs.root` *is* substitutable — a vault path
129
+ // differs per person — so only `local` is refused here.
130
+ if (manifest.connector.kind === 'local' && manifest.variables.length > 0) {
131
+ throw new Error(
132
+ `Provider "${manifest.id}": a local connector is our own code and has no address to fill in, so it cannot declare variables.`,
133
+ );
134
+ }
135
+
136
+ const placeholders = placeholdersInConnector(
137
+ manifest.connector as unknown as Record<string, unknown>,
138
+ );
139
+ const declared = new Set(manifest.variables.map((variable) => variable.key));
140
+
141
+ const unfilled = placeholders.filter((name) => !declared.has(name));
142
+ if (unfilled.length > 0) {
143
+ throw new Error(
144
+ `Provider "${manifest.id}": the connector names {${unfilled.join('}, {')}} and the manifest declares no variable for ${unfilled.length === 1 ? 'it' : 'them'}. Add one under "variables", or the address is sent to the vendor with the braces still in it.`,
145
+ );
146
+ }
147
+
148
+ const unused = [...declared].filter((key) => !placeholders.includes(key));
149
+ if (unused.length > 0) {
150
+ throw new Error(
151
+ `Provider "${manifest.id}": variable${unused.length === 1 ? '' : 's'} ${unused.join(', ')} ${unused.length === 1 ? 'appears' : 'appear'} in no address, so connect would ask for ${unused.length === 1 ? 'a value' : 'values'} nothing reads. Put {${unused[0]}} in the connector's address — or drop it.`,
152
+ );
153
+ }
154
+
155
+ }
156
+
107
157
  if (manifest.auth.kind === 'oauth' && manifest.auth.registration === 'manual') {
108
158
  if (!manifest.auth.app) {
109
159
  throw new Error(
@@ -0,0 +1,173 @@
1
+ import { z } from 'zod';
2
+ import { identifier } from './primitives.ts';
3
+
4
+ /**
5
+ * Where a service lives, when that is a property of the connection rather than
6
+ * of the provider.
7
+ *
8
+ * `base_url`, `endpoint` and `host` are one value per manifest, which is right
9
+ * for a vendor that runs one address for everybody — Gmail is
10
+ * `gmail.googleapis.com` for every account there has ever been. It is wrong for
11
+ * two large families:
12
+ *
13
+ * - **multi-tenant SaaS whose host carries the tenant** — Zendesk is
14
+ * `<subdomain>.zendesk.com`, Shopify `<shop>.myshopify.com`, Atlassian
15
+ * `api.atlassian.com/ex/jira/<cloudid>`
16
+ * - **anything self-hosted** — a Nextcloud, a Gitea, a Home Assistant, each at
17
+ * an address only its owner knows
18
+ *
19
+ * Both were reachable already, but only by hand-writing a YAML manifest per
20
+ * instance in `providers.d/`. What was impossible was a *built-in* for any of
21
+ * them, because a built-in cannot know the address. A declared variable is the
22
+ * missing half: the manifest says a placeholder is there and what fills it, and
23
+ * the connection supplies the value.
24
+ *
25
+ * Deliberately not a credential. A hostname is not a secret and does not belong
26
+ * in the secret store; it lives in the connection's own `config`, which is what
27
+ * that field has always been for.
28
+ */
29
+
30
+ /**
31
+ * What a value is allowed to be, and the reason this is not cosmetic.
32
+ *
33
+ * A variable is substituted into a URL, so an unconstrained value chooses the
34
+ * host the operator's credential is sent to: `acme.zendesk.com/../..@evil.test`
35
+ * in a `{site}` would leave a manifest that reads as Zendesk and authenticates
36
+ * to somebody else. This is the check that stops that, and it runs at
37
+ * *substitution* rather than only at the prompt — config is a file that can be
38
+ * edited by hand, and a deployed revision reads it without ever seeing a prompt.
39
+ *
40
+ * One DNS label or path segment: letters, digits, and the three separators that
41
+ * appear inside real subdomains and tenant ids. No dots by default, because a
42
+ * dot is how you leave the domain the manifest named; a provider whose value
43
+ * genuinely contains one says so with its own `pattern`.
44
+ */
45
+ export const DEFAULT_VARIABLE_PATTERN = '^[A-Za-z0-9][A-Za-z0-9_-]*$';
46
+
47
+ export const connectionVariableSchema = z.object({
48
+ /** The name inside the braces. `{site}` is `key: 'site'`. */
49
+ key: identifier,
50
+ /** What to call it when asking, in the vendor's own word — "subdomain", not "variable". */
51
+ label: z.string().min(1),
52
+ /** One line: what it is and where the operator finds it. */
53
+ description: z.string().min(1),
54
+ /** A real-looking value, shown in the prompt and in the refusal. */
55
+ example: z.string().min(1),
56
+ /**
57
+ * Override the default only to *narrow* it or to admit a character the
58
+ * default excludes — a cloud id with dots, a path segment with slashes. A
59
+ * pattern that admits `/` or `@` is choosing to trust the value with the host,
60
+ * and should say why.
61
+ */
62
+ pattern: z
63
+ .string()
64
+ .default(DEFAULT_VARIABLE_PATTERN)
65
+ // Anchored here rather than trusted to be anchored, and compiled here
66
+ // rather than at the first call.
67
+ //
68
+ // `test` matches a substring, so an unanchored pattern silently voids the
69
+ // guarantee this field exists for: `[a-z.]+` accepts `acme.evil.test/@x`,
70
+ // which is then substituted into the URL the operator's credential is sent
71
+ // to. Both built-ins anchor; a manifest in `providers.d/` is not obliged to
72
+ // read this file first, and a security control that depends on remembering
73
+ // is not one.
74
+ //
75
+ // Wrapped rather than refused, because what an author means by a pattern is
76
+ // always "the value looks like this" — nobody writes one intending a
77
+ // substring match. An invalid regex is refused, though, and refused at load
78
+ // rather than surfacing as a raw SyntaxError when a connector is built.
79
+ .transform((pattern) => (/^\^.*\$$/.test(pattern) ? pattern : `^(?:${pattern})$`))
80
+ .superRefine((pattern, context) => {
81
+ try {
82
+ new RegExp(pattern);
83
+ } catch (failure) {
84
+ context.addIssue({
85
+ code: 'custom',
86
+ message: `is not a usable regular expression: ${(failure as Error).message}`,
87
+ });
88
+ }
89
+ }),
90
+ });
91
+
92
+ export type ConnectionVariable = z.infer<typeof connectionVariableSchema>;
93
+
94
+ /** Every `{name}` in a string, in the order they appear, without duplicates. */
95
+ export function placeholdersIn(text: string): string[] {
96
+ return [...new Set([...text.matchAll(/\{([a-z][a-z0-9_]*)\}/g)].map((match) => match[1]!))];
97
+ }
98
+
99
+ /**
100
+ * Every placeholder anywhere in a connector, however deep.
101
+ *
102
+ * A field list was the first shape and it was wrong within one provider: the
103
+ * `imap` connector's submission host is `smtp.host`, a level down, so a generic
104
+ * mailbox declaring `{smtp_host}` was refused for using a variable that
105
+ * "appears in no address". Walking the object has no list to keep in step with
106
+ * a transport schema, and a `{name}` in a field where it means nothing is
107
+ * simply never substituted rather than silently wrong.
108
+ */
109
+ export function placeholdersInConnector(connector: Record<string, unknown>): string[] {
110
+ const found = new Set<string>();
111
+
112
+ const walk = (node: unknown): void => {
113
+ if (typeof node === 'string') {
114
+ for (const name of placeholdersIn(node)) found.add(name);
115
+ return;
116
+ }
117
+ if (node === null || typeof node !== 'object') return;
118
+ for (const value of Object.values(node as Record<string, unknown>)) walk(value);
119
+ };
120
+
121
+ walk(connector);
122
+ return [...found];
123
+ }
124
+
125
+ /**
126
+ * Fill a connector's address in, or say exactly what is wrong.
127
+ *
128
+ * Returns the connector untouched when it declares no variables, which is every
129
+ * provider but a handful — so this costs nothing where it is not used and cannot
130
+ * quietly reshape a connector that was already complete.
131
+ */
132
+ export function applyVariables(
133
+ connector: Record<string, unknown>,
134
+ variables: readonly ConnectionVariable[],
135
+ values: Readonly<Record<string, unknown>>,
136
+ ): Record<string, unknown> {
137
+ if (variables.length === 0) return connector;
138
+
139
+ const resolved = new Map<string, string>();
140
+ for (const variable of variables) {
141
+ const value = values[variable.key];
142
+ if (typeof value !== 'string' || value === '') {
143
+ throw new Error(
144
+ `This connection does not say what "${variable.key}" is (${variable.label}). ` +
145
+ `Reconnect it, or add it under this connection's config: ${variable.key}: ${variable.example}`,
146
+ );
147
+ }
148
+ if (!new RegExp(variable.pattern).test(value)) {
149
+ throw new Error(
150
+ `"${value}" is not a usable ${variable.label} — it must match ${variable.pattern}, ` +
151
+ `like ${variable.example}. The value is put into the address this connection calls, ` +
152
+ `so anything that could change the host is refused rather than sent.`,
153
+ );
154
+ }
155
+ resolved.set(variable.key, value);
156
+ }
157
+
158
+ const substitute = (node: unknown): unknown => {
159
+ if (typeof node === 'string') {
160
+ return node.replace(/\{([a-z][a-z0-9_]*)\}/g, (whole, name: string) =>
161
+ resolved.get(name) ?? whole,
162
+ );
163
+ }
164
+ if (node === null || typeof node !== 'object') return node;
165
+ if (Array.isArray(node)) return node.map(substitute);
166
+
167
+ return Object.fromEntries(
168
+ Object.entries(node as Record<string, unknown>).map(([key, value]) => [key, substitute(value)]),
169
+ );
170
+ };
171
+
172
+ return substitute(connector) as Record<string, unknown>;
173
+ }
@@ -1,4 +1,5 @@
1
1
  import type { AnyConnector, ProviderDefinition, ProviderManifest } from '#connectivity';
2
+ import { applyVariables } from '#connectivity/manifest/variables.ts';
2
3
  import type { SecretStore } from '#secrets';
3
4
  import type { ProviderRegistry } from '#registry';
4
5
  import { basicCredential } from '#connectivity/auth/basic/index.ts';
@@ -22,6 +23,29 @@ import { createMcpConnector } from './mcp/index.ts';
22
23
  export interface ConnectorFactoryOptions {
23
24
  readonly registry: ProviderRegistry;
24
25
  readonly credentials: SecretStore;
26
+ /**
27
+ * What one connection says about where its service is.
28
+ *
29
+ * Optional, and read only by a provider that declares `variables` — which is
30
+ * a handful. A caller that omits it is saying every provider it serves has a
31
+ * fixed address, which was true of all of them until Zendesk-shaped hosts
32
+ * arrived, and is still true of almost all.
33
+ */
34
+ readonly connectionConfig?: (
35
+ providerId: string,
36
+ connectionId: string,
37
+ ) => Readonly<Record<string, unknown>> | undefined;
38
+ /**
39
+ * Whether this connection is declared at all.
40
+ *
41
+ * Separate from its config, and the distinction is load-bearing: a provider
42
+ * nobody has connected is not a misconfiguration — every surface that lists
43
+ * what *could* be connected asks the factory about one — while a row that has
44
+ * been declared and carries no address is exactly the case worth failing
45
+ * loudly. Without this the two are indistinguishable, and the quiet answer
46
+ * wins for both.
47
+ */
48
+ readonly isDeclared?: (providerId: string, connectionId: string) => boolean;
25
49
  }
26
50
 
27
51
  /**
@@ -61,7 +85,13 @@ export function connectorFactory(options: ConnectorFactoryOptions): ConnectorFac
61
85
  const entry = options.registry.get(providerId);
62
86
  if (!entry) return undefined;
63
87
 
64
- const connector = wrap(entry.definition, build(entry.manifest, entry.definition, connectionId, options));
88
+ // Filled in before the switch, so no transport case knows this happened —
89
+ // which is the property worth keeping. A variable changes *where* a
90
+ // connector points, and nothing about how it speaks.
91
+ const manifest = fillAddress(entry.manifest, providerId, connectionId, options);
92
+ if (!manifest) return undefined;
93
+
94
+ const connector = wrap(entry.definition, build(manifest, entry.definition, connectionId, options));
65
95
  if (connector) cache.set(key, connector);
66
96
  return connector;
67
97
  };
@@ -82,6 +112,49 @@ export function connectorFactory(options: ConnectorFactoryOptions): ConnectorFac
82
112
  return factory;
83
113
  }
84
114
 
115
+ /**
116
+ * Substitute what this connection says about where its service is.
117
+ *
118
+ * Returns the manifest itself when it declares no variables, which is the
119
+ * overwhelming majority — so the cost is one array check per connector built,
120
+ * and a provider with a fixed address cannot be affected by any of this.
121
+ *
122
+ * A missing or malformed value throws, and throwing here is deliberate. The
123
+ * alternative is a connector pointed at `{site}.zendesk.com`, which fails as a
124
+ * DNS error at the first call and says nothing about the real problem; this
125
+ * fails at construction, naming the variable and what it should look like.
126
+ */
127
+ function fillAddress(
128
+ manifest: ProviderManifest,
129
+ providerId: string,
130
+ connectionId: string,
131
+ options: ConnectorFactoryOptions,
132
+ ): ProviderManifest | undefined {
133
+ if (manifest.variables.length === 0) return manifest;
134
+
135
+ const values = options.connectionConfig?.(providerId, connectionId) ?? {};
136
+
137
+ // A provider nobody has connected has no address and that is not an error:
138
+ // the dashboard asks the factory about every provider it lists, and a throw
139
+ // here took the whole page down rather than showing one as unconnected.
140
+ //
141
+ // A *declared* connection missing its address is the opposite, and used to be
142
+ // silent for the same reason — which is how a `connect` that never wrote the
143
+ // value produced a provider that was simply, wordlessly dead.
144
+ if (Object.keys(values).length === 0 && !options.isDeclared?.(providerId, connectionId)) {
145
+ return undefined;
146
+ }
147
+ const connector = applyVariables(
148
+ manifest.connector as unknown as Record<string, unknown>,
149
+ manifest.variables,
150
+ values,
151
+ );
152
+
153
+ // The cast is honest: `applyVariables` rewrites string fields in place and
154
+ // never touches `kind`, so the union member is the one it started as.
155
+ return { ...manifest, connector: connector as unknown as ProviderManifest['connector'] };
156
+ }
157
+
85
158
  /**
86
159
  * Layer a provider's own capabilities over a remote connector.
87
160
  *