@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,192 @@
1
+ import { serveStdio, StdioServerTransport } from '@modelcontextprotocol/server/stdio';
2
+ import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/server';
3
+ import { ownerPrincipal } from '#auth';
4
+ import type { Logger } from '#connectivity';
5
+ import {
6
+ buildMcpServer,
7
+ capabilityIdForToolName,
8
+ toolNameFor,
9
+ visibleCapabilities,
10
+ type ProfileRuntime,
11
+ } from './mcp/index.ts';
12
+
13
+ /**
14
+ * The stdio surface.
15
+ *
16
+ * Same registry, same policy, same audit as the HTTP endpoint — a different
17
+ * boundary. Over HTTP a caller proves who they are with a bearer token, because
18
+ * anyone who can reach the port might be anyone. Over stdio the pipe *is* the
19
+ * proof: this process was spawned by the client holding the other end of it, as
20
+ * the operator, with no port for anyone else to reach. There is nothing left to
21
+ * authenticate, so this path has no authenticator and no token — it serves the
22
+ * owner principal directly.
23
+ *
24
+ * It exists because a client can be unable to speak HTTP at all. Claude
25
+ * Desktop's `claude_desktop_config.json` validates each entry against
26
+ * `{ command, args?, env?, extensionId? }` — a `url` is not a field it has, so
27
+ * "point it at the endpoint" is not available and something must speak MCP on
28
+ * its stdin and stdout.
29
+ *
30
+ * Two differences from HTTP are worth knowing, and neither is incidental:
31
+ *
32
+ * - **The tool list is fixed for the connection.** `createMcpHandler` builds a
33
+ * fresh server per request, so an HTTP caller sees a skill added a moment ago.
34
+ * `serveStdio` pins one instance for the life of the pipe (the era decision is
35
+ * made once, at the opening exchange), so a skill added mid-session appears on
36
+ * the next launch. A client that spawns this per session — which is how they
37
+ * all work — gets the current list every time it starts.
38
+ * - **One connection, one process.** The client owns the lifetime: it spawns the
39
+ * process, and closing the pipe ends it.
40
+ */
41
+
42
+ export interface StdioOptions {
43
+ /** Every profile this surface serves, keyed by name. */
44
+ readonly profiles: ReadonlyMap<string, ProfileRuntime>;
45
+ /** Which profile owns the connection — the one a call defaults to naming. */
46
+ readonly primary: string;
47
+ readonly log: Logger;
48
+ readonly version?: string | undefined;
49
+ readonly clientLabel?: string | undefined;
50
+ /**
51
+ * Bring your own transport, rather than this process's stdin and stdout.
52
+ *
53
+ * A test drives the real surface over a pair of in-memory streams with it. It
54
+ * is the same seam the SDK offers for serving stdio over a socket.
55
+ */
56
+ readonly transport?: Transport | undefined;
57
+ }
58
+
59
+ export interface StdioSurface {
60
+ /** Tears down the connection and the server instance pinned to it. */
61
+ close(): Promise<void>;
62
+ }
63
+
64
+ export function serveOverStdio(options: StdioOptions): StdioSurface {
65
+ if (!options.profiles.has(options.primary)) {
66
+ throw new Error(`Profile "${options.primary}" is not among those being served.`);
67
+ }
68
+
69
+ const principal = ownerPrincipal(options.primary);
70
+
71
+ /**
72
+ * Wire names this principal may see, and every capability id behind them.
73
+ *
74
+ * Computed once because the pinned instance's tool list is fixed for the
75
+ * connection anyway — the memoisation the HTTP path needs exists only because
76
+ * it rebuilds per request.
77
+ */
78
+ const visible = new Set(
79
+ visibleCapabilities({ profiles: options.profiles, principal }).map(toolNameFor),
80
+ );
81
+ const allCapabilityIds = [
82
+ ...new Set(
83
+ [...options.profiles.values()].flatMap((runtime) =>
84
+ runtime.registry.capabilities().map(({ id }) => id),
85
+ ),
86
+ ),
87
+ ];
88
+
89
+ const handle = serveStdio(
90
+ () =>
91
+ buildMcpServer({
92
+ profiles: options.profiles,
93
+ principal,
94
+ ...(options.clientLabel ? { clientLabel: options.clientLabel } : {}),
95
+ ...(options.version ? { version: options.version } : {}),
96
+ }),
97
+ {
98
+ transport: auditRefusals(options.transport ?? new StdioServerTransport(), (message) =>
99
+ recordRefusal(message, options, visible, allCapabilityIds),
100
+ ),
101
+ onerror: (error: Error) => options.log.error('mcp stdio error', { message: error.message }),
102
+ },
103
+ );
104
+
105
+ return { close: () => handle.close() };
106
+ }
107
+
108
+ /**
109
+ * A call naming a tool policy filtering hid, recorded as a refusal.
110
+ *
111
+ * The same trace the HTTP edge writes, from the only place this transport can
112
+ * write it. Policy-filtered discovery means an unpermitted tool is never
113
+ * advertised, so the protocol layer answers a call naming one before any of our
114
+ * code runs — and it would otherwise leave no trace at all, which is what
115
+ * `audit.every-invocation` in `docs/detailed/security.md` promises it does not.
116
+ *
117
+ * HTTP reads the method and target from the 2026-07-28 headers, which is exact
118
+ * for an envelope client and blind to a 2025-era one — a documented gap over
119
+ * there, and not one here. There are no headers on a pipe, so this reads the
120
+ * body unconditionally, which is cheap because the transport has already parsed
121
+ * it into a message.
122
+ */
123
+ function recordRefusal(
124
+ message: JSONRPCMessage,
125
+ options: StdioOptions,
126
+ visible: ReadonlySet<string>,
127
+ allCapabilityIds: readonly string[],
128
+ ): void {
129
+ if (!('method' in message) || !('id' in message)) return;
130
+ if (message.method !== 'tools/call' && message.method !== 'prompts/get') return;
131
+
132
+ const params = (message as { params?: { name?: unknown } }).params;
133
+ const toolName = typeof params?.name === 'string' ? params.name : undefined;
134
+ if (!toolName || visible.has(toolName)) return;
135
+
136
+ // Not awaited: `onmessage` is synchronous, and holding the message back to
137
+ // wait on a store would delay the refusal the caller is owed. The row lands
138
+ // just after the error does.
139
+ //
140
+ // `resources/read` is absent for the same reason it is absent over HTTP: a
141
+ // concrete URI does not match a wire name, so recovering the capability id
142
+ // means matching it against every registered template. `resources.test.ts`
143
+ // asserts that gap rather than hiding it.
144
+ void options.profiles
145
+ .get(options.primary)!
146
+ .dispatcher.recordRefusal({
147
+ principal: ownerPrincipal(options.primary),
148
+ capabilityId: capabilityIdForToolName(toolName, allCapabilityIds),
149
+ ...(options.clientLabel ? { clientLabel: options.clientLabel } : {}),
150
+ })
151
+ .catch((error: Error) =>
152
+ options.log.warn('could not record refusal', { message: error.message }),
153
+ );
154
+ }
155
+
156
+ /**
157
+ * The transport, with a look at every inbound message on its way past.
158
+ *
159
+ * A wrapper rather than a subclass because the SDK owns the transport it is
160
+ * handed: it installs its own `onmessage`, starts it, and closes it. Assigning
161
+ * the inner transport's callbacks here and forwarding them keeps that ownership
162
+ * intact — the entry sees exactly what the pipe delivered, in the same order.
163
+ */
164
+ function auditRefusals(inner: Transport, inspect: (message: JSONRPCMessage) => void): Transport {
165
+ const outer: Transport = {
166
+ start: () => inner.start(),
167
+ send: (message, sendOptions) => inner.send(message, sendOptions),
168
+ close: () => inner.close(),
169
+ ...(inner.setProtocolVersion
170
+ ? { setProtocolVersion: (version: string) => inner.setProtocolVersion!(version) }
171
+ : {}),
172
+ ...(inner.setSupportedProtocolVersions
173
+ ? {
174
+ setSupportedProtocolVersions: (versions: string[]) =>
175
+ inner.setSupportedProtocolVersions!(versions),
176
+ }
177
+ : {}),
178
+ };
179
+
180
+ inner.onmessage = (message, extra) => {
181
+ try {
182
+ inspect(message);
183
+ } catch {
184
+ // An audit attempt must never cost the caller their message.
185
+ }
186
+ outer.onmessage?.(message, extra);
187
+ };
188
+ inner.onclose = () => outer.onclose?.();
189
+ inner.onerror = (error) => outer.onerror?.(error);
190
+
191
+ return outer;
192
+ }
@@ -0,0 +1,223 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { scopeBlobStore, type BlobStore } from './index.ts';
3
+
4
+ /**
5
+ * The `BlobStore` contract, once, run against every adapter.
6
+ *
7
+ * `docs/detailed/init.md` promises that no application-layer code differs between the
8
+ * local and deployed targets. For blobs that promise now has a consumer —
9
+ * `providers/owner/src/memory.ts` stores entry bodies here — and it is only
10
+ * worth something if the adapters actually behave identically. A per-adapter
11
+ * test file cannot say so; it can only say each one passes the tests someone
12
+ * remembered to write for it. This suite is the assertion itself: filesystem,
13
+ * S3, and the in-memory store are held to one set of behaviours, and an
14
+ * adapter that diverges fails here rather than in production on whichever
15
+ * target the operator happened to deploy.
16
+ *
17
+ * Adapter-specific mechanism — write-then-rename, `.meta` sidecars,
18
+ * `ListObjectsV2` pagination — stays in the adapter's own test file. What
19
+ * lives here is behaviour every caller can rely on regardless of where it is
20
+ * running.
21
+ *
22
+ * Not exported from `package.json`: this imports `bun:test`, so it is
23
+ * reachable only by relative path from a test file, the same way `testing.ts`
24
+ * is kept away from application code.
25
+ */
26
+
27
+ export interface ContractBlobStore {
28
+ /** A handle on this store. */
29
+ open(): BlobStore;
30
+ dispose(): Promise<void>;
31
+ }
32
+
33
+ const bytes = (text: string) => new TextEncoder().encode(text);
34
+ const text = (data: Uint8Array | null) => (data ? new TextDecoder().decode(data) : null);
35
+
36
+ export function describeBlobStoreContract(
37
+ name: string,
38
+ createStore: () => Promise<ContractBlobStore>,
39
+ ): void {
40
+ /** Open a fresh store, run against it, and clean up either way. */
41
+ const use = async (run: (store: BlobStore) => Promise<void>): Promise<void> => {
42
+ const handle = await createStore();
43
+ try {
44
+ await run(handle.open());
45
+ } finally {
46
+ await handle.dispose();
47
+ }
48
+ };
49
+
50
+ describe(`${name}: round trip`, () => {
51
+ test('stores, reads, and deletes', async () => {
52
+ await use(async (store) => {
53
+ expect(await store.get('note.txt')).toBeNull();
54
+ expect(await store.has('note.txt')).toBe(false);
55
+
56
+ await store.put('note.txt', bytes('hello'));
57
+ expect(text(await store.get('note.txt'))).toBe('hello');
58
+ expect(await store.has('note.txt')).toBe(true);
59
+
60
+ await store.delete('note.txt');
61
+ expect(await store.get('note.txt')).toBeNull();
62
+ expect(await store.has('note.txt')).toBe(false);
63
+ });
64
+ });
65
+
66
+ test('accepts a nested key without the caller creating anything first', async () => {
67
+ await use(async (store) => {
68
+ await store.put('a/b/c/deep.txt', bytes('deep'));
69
+ expect(text(await store.get('a/b/c/deep.txt'))).toBe('deep');
70
+ });
71
+ });
72
+
73
+ test('overwrites in place', async () => {
74
+ await use(async (store) => {
75
+ await store.put('one.txt', bytes('1'));
76
+ await store.put('one.txt', bytes('overwritten'));
77
+
78
+ expect(text(await store.get('one.txt'))).toBe('overwritten');
79
+ expect((await store.list()).map((entry) => entry.key)).toEqual(['one.txt']);
80
+ });
81
+ });
82
+
83
+ test('deleting something absent is not an error', async () => {
84
+ await use(async (store) => {
85
+ await store.delete('never-existed.txt');
86
+ });
87
+ });
88
+
89
+ test('round-trips binary data unchanged', async () => {
90
+ await use(async (store) => {
91
+ const data = new Uint8Array([0, 1, 2, 253, 254, 255]);
92
+
93
+ await store.put('raw.bin', data);
94
+ expect(await store.get('raw.bin')).toEqual(data);
95
+ });
96
+ });
97
+
98
+ test('round-trips an empty blob rather than reporting it absent', async () => {
99
+ await use(async (store) => {
100
+ await store.put('empty.bin', new Uint8Array([]));
101
+
102
+ expect(await store.has('empty.bin')).toBe(true);
103
+ expect(await store.get('empty.bin')).toEqual(new Uint8Array([]));
104
+ });
105
+ });
106
+ });
107
+
108
+ describe(`${name}: listing`, () => {
109
+ test('lists recursively, sorted, with forward-slash keys', async () => {
110
+ await use(async (store) => {
111
+ await store.put('one.txt', bytes('1'));
112
+ await store.put('nested/two.txt', bytes('22'));
113
+ await store.put('nested/deeper/three.txt', bytes('333'));
114
+
115
+ expect((await store.list()).map((entry) => entry.key)).toEqual([
116
+ 'nested/deeper/three.txt',
117
+ 'nested/two.txt',
118
+ 'one.txt',
119
+ ]);
120
+ });
121
+ });
122
+
123
+ test('filters by prefix and reports sizes', async () => {
124
+ await use(async (store) => {
125
+ await store.put('a/one.txt', bytes('1'));
126
+ await store.put('b/two.txt', bytes('22'));
127
+
128
+ const listed = await store.list('a/');
129
+ expect(listed.map((entry) => entry.key)).toEqual(['a/one.txt']);
130
+ expect(listed[0]?.size).toBe(1);
131
+ });
132
+ });
133
+
134
+ test('reports the content type it was given', async () => {
135
+ await use(async (store) => {
136
+ await store.put('doc.txt', bytes('x'), { contentType: 'text/plain' });
137
+
138
+ const listed = await store.list();
139
+ expect(listed.map((entry) => entry.key)).toEqual(['doc.txt']);
140
+ expect(listed[0]?.contentType).toContain('text/plain');
141
+ });
142
+ });
143
+
144
+ test('listing an empty store returns nothing rather than throwing', async () => {
145
+ await use(async (store) => {
146
+ expect(await store.list()).toEqual([]);
147
+ });
148
+ });
149
+
150
+ test('a deleted blob leaves the key space', async () => {
151
+ await use(async (store) => {
152
+ await store.put('gone.txt', bytes('x'));
153
+ await store.delete('gone.txt');
154
+
155
+ expect(await store.list()).toEqual([]);
156
+ });
157
+ });
158
+ });
159
+
160
+ describe(`${name}: containment`, () => {
161
+ test('refuses a key that resolves outside the store root', async () => {
162
+ await use(async (store) => {
163
+ // `scopeBlobStore` rejects these earlier, but an adapter is usable
164
+ // directly and containment belongs where a key becomes an address.
165
+ await expect(store.get('../outside.txt')).rejects.toThrow(/outside the store root/);
166
+ await expect(store.put('a/../../outside.txt', bytes('x'))).rejects.toThrow(
167
+ /outside the store root/,
168
+ );
169
+ await expect(store.delete('../../etc/passwd')).rejects.toThrow(/outside the store root/);
170
+ await expect(store.has('../outside.txt')).rejects.toThrow(/outside the store root/);
171
+ });
172
+ });
173
+
174
+ test('refuses a key that resolves to the root itself', async () => {
175
+ await use(async (store) => {
176
+ await expect(store.get('.')).rejects.toThrow(/outside the store root/);
177
+ });
178
+ });
179
+
180
+ test('allows a traversal that stays inside, normalised', async () => {
181
+ await use(async (store) => {
182
+ // Weaker than `scopeBlobStore`'s rule on purpose: the namespace
183
+ // boundary refuses a `..` segment outright, an adapter only has to
184
+ // know where the key lands.
185
+ await store.put('a/../b.txt', bytes('inside'));
186
+
187
+ expect(text(await store.get('b.txt'))).toBe('inside');
188
+ expect((await store.list()).map((entry) => entry.key)).toEqual(['b.txt']);
189
+ });
190
+ });
191
+ });
192
+
193
+ describe(`${name}: composed with namespace scoping`, () => {
194
+ test('two connections on one store cannot see each other', async () => {
195
+ await use(async (base) => {
196
+ const a = scopeBlobStore(base, 'example/a');
197
+ const b = scopeBlobStore(base, 'example/b');
198
+
199
+ await a.put('note.txt', bytes('from a'));
200
+ await b.put('note.txt', bytes('from b'));
201
+
202
+ expect(text(await a.get('note.txt'))).toBe('from a');
203
+ expect(text(await b.get('note.txt'))).toBe('from b');
204
+ expect((await a.list()).map((entry) => entry.key)).toEqual(['note.txt']);
205
+ expect(await a.has('note.txt')).toBe(true);
206
+ });
207
+ });
208
+
209
+ test('deleting through one namespace leaves the other alone', async () => {
210
+ await use(async (base) => {
211
+ const a = scopeBlobStore(base, 'example/a');
212
+ const b = scopeBlobStore(base, 'example/b');
213
+
214
+ await a.put('note.txt', bytes('from a'));
215
+ await b.put('note.txt', bytes('from b'));
216
+ await a.delete('note.txt');
217
+
218
+ expect(await a.get('note.txt')).toBeNull();
219
+ expect(text(await b.get('note.txt'))).toBe('from b');
220
+ });
221
+ });
222
+ });
223
+ }
@@ -0,0 +1,125 @@
1
+ import { posix } from 'node:path';
2
+
3
+ /**
4
+ * Blob storage.
5
+ *
6
+ * No provider consumes this in M1 or M2 — the example and Gmail read providers
7
+ * store no bytes. The interface and the filesystem adapter exist now because
8
+ * the owner layer (M3: memory attachments, vault documents) is the workload it
9
+ * was defined for, and because retrofitting a storage interface after
10
+ * providers exist is more expensive than defining it before.
11
+ *
12
+ * Do not add S3, Azure, or GCS adapters until a provider actually writes bytes.
13
+ */
14
+
15
+ /** A blob key. Always relative to whatever namespace the caller was scoped to. */
16
+ export type BlobKey = string;
17
+
18
+ export interface BlobMetadata {
19
+ readonly key: BlobKey;
20
+ readonly size: number;
21
+ readonly contentType?: string;
22
+ readonly modifiedAt: Date;
23
+ }
24
+
25
+ export interface BlobStore {
26
+ put(key: BlobKey, data: Uint8Array, options?: { contentType?: string }): Promise<void>;
27
+ get(key: BlobKey): Promise<Uint8Array | null>;
28
+ has(key: BlobKey): Promise<boolean>;
29
+ delete(key: BlobKey): Promise<void>;
30
+ list(prefix?: string): Promise<BlobMetadata[]>;
31
+ }
32
+
33
+ /**
34
+ * Restrict a store to a key namespace.
35
+ *
36
+ * Providers never receive a raw `BlobStore`; they receive one scoped to
37
+ * `<provider>/<connection>`, so one connection's bytes are not addressable from
38
+ * another. The scoping is enforced here rather than trusted to the provider.
39
+ */
40
+ export function scopeBlobStore(base: BlobStore, namespace: string): BlobStore {
41
+ const prefix = normaliseNamespace(namespace);
42
+ const scoped = (key: BlobKey): BlobKey => {
43
+ assertSafeKey(key);
44
+ return `${prefix}${key}`;
45
+ };
46
+
47
+ // Every method is `async` on purpose. A key-validation failure must surface
48
+ // as a rejected promise, matching the declared interface — if these threw
49
+ // synchronously, callers would have to both try/catch and .catch() the same
50
+ // call, and one of the two would inevitably be forgotten.
51
+ return {
52
+ async put(key, data, options) {
53
+ return base.put(scoped(key), data, options);
54
+ },
55
+ async get(key) {
56
+ return base.get(scoped(key));
57
+ },
58
+ async has(key) {
59
+ return base.has(scoped(key));
60
+ },
61
+ async delete(key) {
62
+ return base.delete(scoped(key));
63
+ },
64
+ async list(innerPrefix) {
65
+ const entries = await base.list(`${prefix}${innerPrefix ?? ''}`);
66
+ return entries.map((entry) => ({ ...entry, key: entry.key.slice(prefix.length) }));
67
+ },
68
+ };
69
+ }
70
+
71
+ function normaliseNamespace(namespace: string): string {
72
+ if (namespace.length === 0) throw new Error('Blob namespace must not be empty');
73
+ return namespace.endsWith('/') ? namespace : `${namespace}/`;
74
+ }
75
+
76
+ /**
77
+ * A virtual root deep enough that `..` can actually escape it.
78
+ *
79
+ * `posix.resolve('/', '../x')` clamps to `/x` — the traversal disappears
80
+ * instead of being caught — so resolving against `/` would silently accept
81
+ * every key a real store rejects.
82
+ */
83
+ const CONTAINMENT_ROOT = '/blobs';
84
+
85
+ /**
86
+ * Resolve a key to its canonical form, refusing anything that escapes the
87
+ * store.
88
+ *
89
+ * This is the *adapter's* containment rule, and it is deliberately not
90
+ * `assertSafeKey` above. That one guards the namespace boundary and so refuses
91
+ * a `..` segment outright, because a provider writing one has a bug worth
92
+ * surfacing. This one asks the weaker question an adapter has to ask — does
93
+ * the key, once resolved, still land inside the store — so `a/../b` normalises
94
+ * to `b` rather than failing.
95
+ *
96
+ * It lives here so every adapter answers identically. An operator who switches
97
+ * targets must not find that a key their local store refused is one their
98
+ * deployed store happily writes; `conformance.ts` asserts exactly that, and it
99
+ * can only do so if there is one rule rather than one per adapter.
100
+ */
101
+ export function containedKey(key: BlobKey): BlobKey {
102
+ if (key.includes('\0')) throw new Error('Blob key must not contain a NUL byte');
103
+
104
+ const resolved = posix.resolve(CONTAINMENT_ROOT, key);
105
+ const relative = posix.relative(CONTAINMENT_ROOT, resolved);
106
+
107
+ if (relative === '' || relative === '..' || relative.startsWith('../')) {
108
+ throw new Error(`Blob key resolves outside the store root: ${key}`);
109
+ }
110
+ return relative;
111
+ }
112
+
113
+ /**
114
+ * A provider must not be able to escape its namespace with `../`, an absolute
115
+ * path, or a NUL byte. This is a boundary, so it throws rather than sanitising
116
+ * — silently rewriting a traversal attempt hides a provider bug.
117
+ */
118
+ function assertSafeKey(key: BlobKey): void {
119
+ if (key.length === 0) throw new Error('Blob key must not be empty');
120
+ if (key.startsWith('/')) throw new Error(`Blob key must be relative: ${key}`);
121
+ if (key.includes('\0')) throw new Error('Blob key must not contain a NUL byte');
122
+ if (key.split('/').includes('..')) {
123
+ throw new Error(`Blob key must not traverse outside its namespace: ${key}`);
124
+ }
125
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * An in-memory `BlobStore` for tests.
3
+ *
4
+ * Test-only, behind a separate entry point so application code cannot reach it
5
+ * by accident. The filesystem and S3 adapters are the real ones.
6
+ *
7
+ * It goes through `containedKey` like they do. A Map cannot be escaped, so
8
+ * containment buys it no safety — but a provider exercised against this store
9
+ * and shipped against a real one must not discover that a key it relies on is
10
+ * refused in production. `conformance.ts` holds all three to the one rule.
11
+ */
12
+
13
+ import { containedKey, type BlobMetadata, type BlobStore } from './index.ts';
14
+
15
+ export function createMemoryBlobStore(): BlobStore {
16
+ const blobs = new Map<string, { data: Uint8Array; contentType?: string; modifiedAt: Date }>();
17
+
18
+ return {
19
+ async put(key, data, options) {
20
+ blobs.set(containedKey(key), {
21
+ data,
22
+ ...(options?.contentType ? { contentType: options.contentType } : {}),
23
+ modifiedAt: new Date(),
24
+ });
25
+ },
26
+ async get(key) {
27
+ return blobs.get(containedKey(key))?.data ?? null;
28
+ },
29
+ async has(key) {
30
+ return blobs.has(containedKey(key));
31
+ },
32
+ async delete(key) {
33
+ blobs.delete(containedKey(key));
34
+ },
35
+ async list(prefix) {
36
+ const out: BlobMetadata[] = [];
37
+ for (const [key, blob] of blobs) {
38
+ if (prefix && !key.startsWith(prefix)) continue;
39
+ out.push({
40
+ key,
41
+ size: blob.data.byteLength,
42
+ modifiedAt: blob.modifiedAt,
43
+ ...(blob.contentType ? { contentType: blob.contentType } : {}),
44
+ });
45
+ }
46
+ return out.sort((a, b) => a.key.localeCompare(b.key));
47
+ },
48
+ };
49
+ }