@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,199 @@
1
+ import { parse as parseYaml } from 'yaml';
2
+ import { z } from 'zod';
3
+ import {
4
+ SUPPORTED_CONTRACT,
5
+ configSchema,
6
+ type Config,
7
+ type PolicyRuleConfig,
8
+ } from './schema.ts';
9
+ import { findSecrets, formatSecretFindings } from './secret-detection.ts';
10
+
11
+ export class ConfigError extends Error {
12
+ readonly findings: readonly string[];
13
+
14
+ constructor(message: string, findings: readonly string[] = []) {
15
+ super(message);
16
+ this.name = 'ConfigError';
17
+ this.findings = findings;
18
+ }
19
+ }
20
+
21
+ export interface LoadedConfig {
22
+ readonly config: Config;
23
+ readonly source: string;
24
+ /** `provider.id` for every declared connection, in declaration order. */
25
+ readonly connectionKeys: readonly string[];
26
+ }
27
+
28
+ /**
29
+ * Validate a raw parsed config.
30
+ *
31
+ * Order matters and is not arbitrary:
32
+ *
33
+ * 1. Contract major, before anything else. An unrecognised major means we do
34
+ * not know what the rest of the document means, so no further rule can be
35
+ * trusted to be reading what the operator wrote.
36
+ * 2. Secret detection, on the RAW object rather than the parsed one. Zod
37
+ * strips unknown keys, so a credential parked under a misspelled key would
38
+ * survive schema validation invisibly.
39
+ * 3. Schema shape.
40
+ * 4. Referential integrity, which needs a well-formed document to check.
41
+ */
42
+ export function validateConfig(raw: unknown, source = '<config>'): Config {
43
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
44
+ throw new ConfigError(`${source}: expected a YAML mapping at the top level`);
45
+ }
46
+
47
+ assertSupportedContract(raw, source);
48
+
49
+ const secrets = findSecrets(raw);
50
+ if (secrets.length > 0) {
51
+ throw new ConfigError(
52
+ `${source}: ${formatSecretFindings(secrets)}`,
53
+ secrets.map((finding) => finding.path),
54
+ );
55
+ }
56
+
57
+ const parsed = configSchema.safeParse(raw);
58
+ if (!parsed.success) {
59
+ throw new ConfigError(`${source}:\n${formatZodIssues(parsed.error)}`);
60
+ }
61
+
62
+ assertReferentialIntegrity(parsed.data, source);
63
+ return parsed.data;
64
+ }
65
+
66
+ /**
67
+ * An unknown contract major is rejected outright, never loaded best-effort.
68
+ *
69
+ * This file governs authorization. Guessing at a schema we do not implement
70
+ * risks reading a document as more permissive than the operator wrote it, and
71
+ * refusing to start is always the safer failure.
72
+ */
73
+ function assertSupportedContract(raw: object, source: string): void {
74
+ const contract = (raw as { contract?: unknown }).contract;
75
+
76
+ if (typeof contract !== 'number' || !Number.isInteger(contract)) {
77
+ throw new ConfigError(
78
+ `${source}: "contract" is required and must be an integer. This binary implements contract ${SUPPORTED_CONTRACT}.`,
79
+ );
80
+ }
81
+
82
+ if (contract !== SUPPORTED_CONTRACT) {
83
+ const direction = contract > SUPPORTED_CONTRACT ? 'newer than' : 'older than';
84
+ throw new ConfigError(
85
+ `${source}: contract ${contract} is ${direction} the contract this binary implements (${SUPPORTED_CONTRACT}). ` +
86
+ `Refusing to load rather than guessing at what the document means. ` +
87
+ (contract > SUPPORTED_CONTRACT
88
+ ? 'Upgrade lanes-link.'
89
+ : 'Migrate the config, or use a matching lanes-link version.'),
90
+ );
91
+ }
92
+ }
93
+
94
+ function formatZodIssues(error: z.ZodError): string {
95
+ return error.issues
96
+ .map((issue) => {
97
+ const path = issue.path.length > 0 ? issue.path.join('.') : '<root>';
98
+ return ` ${path}: ${issue.message}`;
99
+ })
100
+ .join('\n');
101
+ }
102
+
103
+ /**
104
+ * Cross-references, all of which fail rather than degrade.
105
+ *
106
+ * A policy rule naming a connection that does not exist is the important one:
107
+ * left to resolve silently it grants nothing, which looks identical to a
108
+ * working rule until the day someone relies on it.
109
+ */
110
+ function assertReferentialIntegrity(config: Config, source: string): void {
111
+ const problems: string[] = [];
112
+
113
+ const targetNames = new Set(Object.keys(config.targets));
114
+ if (targetNames.size === 0) {
115
+ problems.push('targets: at least one target must be declared');
116
+ }
117
+ if (!targetNames.has(config.instance.default_target)) {
118
+ problems.push(
119
+ `instance.default_target: "${config.instance.default_target}" is not a declared target (have: ${[...targetNames].join(', ') || 'none'})`,
120
+ );
121
+ }
122
+
123
+ // Only what holds for every platform. What one platform needs and the next
124
+ // has no concept of — a GCP project, an AWS role ARN — is refused by the
125
+ // driver that needs it, the way an adapter-specific field is refused by the
126
+ // code that opens the adapter rather than by this file.
127
+ for (const [name, target] of Object.entries(config.targets)) {
128
+ if (!target.deploy) continue;
129
+ for (const field of ['region', 'service'] as const) {
130
+ if (!target.deploy[field]) {
131
+ problems.push(`targets.${name}.deploy.${field}: required for a deployable target`);
132
+ }
133
+ }
134
+ }
135
+
136
+ // Connection ids are unique per provider, so `gmail.main` and
137
+ // `icloud_mail.main` can coexist.
138
+ const connectionKeys = new Set<string>();
139
+ const providerNames = new Set(config.connections.map((connection) => connection.provider));
140
+
141
+ config.connections.forEach((connection, index) => {
142
+ const key = `${connection.provider}.${connection.id}`;
143
+ if (connectionKeys.has(key)) {
144
+ problems.push(`connections[${index}]: duplicate connection "${key}"`);
145
+ }
146
+ connectionKeys.add(key);
147
+ });
148
+
149
+ const checkRules = (rules: readonly PolicyRuleConfig[], field: 'allow' | 'deny'): void => {
150
+ rules.forEach((rule, index) => {
151
+ const where = `policy.${field}[${index}]`;
152
+ if (rule.capability === '*') return;
153
+
154
+ // A rule naming a provider with no connection is almost always a typo,
155
+ // and one that fails open-looking: it reads as a grant while matching
156
+ // nothing. Worth saying, but not fatal for a `deny` — denying something
157
+ // you have not connected yet is a perfectly reasonable thing to write
158
+ // ahead of time.
159
+ const providerOfCapability = rule.capability.split('.')[0] ?? '';
160
+ if (field === 'allow' && !providerNames.has(providerOfCapability)) {
161
+ problems.push(
162
+ `${where}: "${rule.capability}" names provider "${providerOfCapability}", which has no connection` +
163
+ (providerNames.size > 0 ? ` (have: ${[...providerNames].join(', ')})` : ''),
164
+ );
165
+ }
166
+ });
167
+ };
168
+
169
+ checkRules(config.policy.allow, 'allow');
170
+ checkRules(config.policy.deny, 'deny');
171
+
172
+ if (problems.length > 0) {
173
+ throw new ConfigError(`${source}:\n${problems.map((p) => ` ${p}`).join('\n')}`, problems);
174
+ }
175
+ }
176
+
177
+ export function parseConfig(text: string, source = '<config>'): LoadedConfig {
178
+ let raw: unknown;
179
+ try {
180
+ raw = parseYaml(text);
181
+ } catch (error) {
182
+ throw new ConfigError(`${source}: could not parse YAML — ${(error as Error).message}`);
183
+ }
184
+
185
+ const config = validateConfig(raw, source);
186
+ return {
187
+ config,
188
+ source,
189
+ connectionKeys: config.connections.map((c) => `${c.provider}.${c.id}`),
190
+ };
191
+ }
192
+
193
+ export async function loadConfigFile(path: string): Promise<LoadedConfig> {
194
+ const file = Bun.file(path);
195
+ if (!(await file.exists())) {
196
+ throw new ConfigError(`${path}: no such config file`);
197
+ }
198
+ return parseConfig(await file.text(), path);
199
+ }
@@ -0,0 +1,45 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * The three string shapes the config contract is built out of.
5
+ *
6
+ * Their own file because more than one schema module needs them, and the
7
+ * alternative was either a circular import or a second copy of a regex that
8
+ * governs authorization. A duplicated `credentialRef` that drifted would let a
9
+ * literal secret through in whichever copy was looser.
10
+ */
11
+
12
+ export const identifier = z
13
+ .string()
14
+ .regex(/^[a-z][a-z0-9_]*$/, 'must be lowercase letters, digits, and underscores, starting with a letter');
15
+
16
+ export const credentialRef = z
17
+ .string()
18
+ .regex(
19
+ /^[a-z0-9][a-z0-9_-]*(?:\/[a-z0-9][a-z0-9_-]*)+$/,
20
+ 'must be a credential reference like "gmail/main", not a literal value',
21
+ );
22
+
23
+ /**
24
+ * `*`, `gmail.*`, `gmail.users.*`, `gmail.users.drafts.send`, `notion.get-comments`.
25
+ *
26
+ * The provider half is ours and stays strict. The capability half is **not**:
27
+ * it comes from an upstream MCP server or an OpenAPI document, and vendors name
28
+ * things however they like — Notion uses hyphens throughout, and an OpenAPI
29
+ * operationId is routinely dotted (`users.drafts.send`). Constraining it would
30
+ * mean refusing to express a policy rule for a tool that demonstrably exists,
31
+ * which is worse than a slightly looser grammar. Dots were rejected here until
32
+ * the HTTP connector shipped, which made `lanes link policy deny
33
+ * gmail.users.drafts.send` — the command `connect` itself prints — impossible
34
+ * to write.
35
+ *
36
+ * A trailing `.*` is the only wildcard, and it may appear at any depth, so
37
+ * `gmail.users.*` narrows without naming every operation. Do not build a policy
38
+ * expression language beyond that.
39
+ */
40
+ export const capabilityPattern = z
41
+ .string()
42
+ .regex(
43
+ /^(?:\*|[a-z][a-z0-9_]*\.(?:\*|[A-Za-z0-9][A-Za-z0-9_-]*(?:\.[A-Za-z0-9][A-Za-z0-9_-]*)*(?:\.\*)?))$/,
44
+ 'must be "*", "gmail.*", "gmail.users.*", or "gmail.users.drafts.send"',
45
+ );
@@ -0,0 +1,347 @@
1
+ import { z } from 'zod';
2
+ import { capabilityPattern, credentialRef, identifier } from './primitives.ts';
3
+ import { authorizationSchema } from './authorization.ts';
4
+
5
+ /**
6
+ * Configuration is declarative desired state. This file is the source of truth
7
+ * for what exists; the credential store holds values, and the database holds
8
+ * only runtime state.
9
+ *
10
+ * That split exists because the deployed target scales to zero: administering a
11
+ * remote instance by shelling into a container or connecting to its database is
12
+ * not workable. Declared config plus a reconcile step on boot removes that
13
+ * problem and makes the whole setup diffable and reproducible.
14
+ *
15
+ * The CLI's mutating commands edit this file rather than the database.
16
+ * Declarative config and an imperative CLI are not opposites — `lanes link connect` is
17
+ * a convenience that produces a correct file, so nobody has to hand-edit YAML,
18
+ * while the file remains the single source of truth.
19
+ */
20
+
21
+ /**
22
+ * The config contract major version.
23
+ *
24
+ * A config declaring a major this binary does not implement is rejected
25
+ * outright — never a best-effort load. Guessing at an unknown schema on a file
26
+ * that governs authorization is how you end up granting something the operator
27
+ * did not write.
28
+ */
29
+ export const SUPPORTED_CONTRACT = 1;
30
+
31
+ /**
32
+ * There is no `database:` block any more.
33
+ *
34
+ * State — connections, provider state, cursors — is one object per key in the
35
+ * blob store `storage:` already names, and the log is objects beside it
36
+ * (ADR-020). There was nothing left for a second declaration to choose. A
37
+ * profile written before this keeps its `database:` key and it is ignored:
38
+ * zod strips unknown keys, so an old file loads unchanged rather than failing
39
+ * on a field that no longer means anything.
40
+ */
41
+
42
+ export const credentialsTargetSchema = z.object({
43
+ adapter: z.enum(['file', 'gcp-secret-manager']),
44
+ path: z.string().optional(),
45
+ project: z.string().optional(),
46
+ });
47
+
48
+ export const storageTargetSchema = z.object({
49
+ adapter: z.enum(['filesystem', 'gcs', 's3']),
50
+ path: z.string().optional(),
51
+ bucket: z.string().optional(),
52
+ /**
53
+ * The S3-compatible service endpoint. Named for the protocol rather than a
54
+ * vendor (ADR-013) — Supabase Storage, R2, MinIO, and AWS differ only in this
55
+ * URL. `gcs` needs none of this: it addresses Google's own API and
56
+ * authenticates as the identity already present.
57
+ */
58
+ endpoint: z.string().optional(),
59
+ region: z.string().optional(),
60
+ /** Bucket-relative key prefix; the S3 equivalent of `path`. */
61
+ prefix: z.string().optional(),
62
+ /**
63
+ * Two refs rather than one, because `SecretStore` holds strings and a key
64
+ * pair is two of them. Packing both into one value would mean encoding a
65
+ * secret that routinely contains `/` and `+`, and an encoding bug in a
66
+ * credential path fails at the least convenient moment.
67
+ *
68
+ * The access key id is a ref too, though it is closer to a username than a
69
+ * secret: Supabase issues 32-character hex ids, which the config secret
70
+ * detector reads as a high-entropy blob and rejects inline.
71
+ */
72
+ access_key_id_ref: credentialRef.optional(),
73
+ secret_access_key_ref: credentialRef.optional(),
74
+ });
75
+
76
+ /**
77
+ * Extra places the log is copied to.
78
+ *
79
+ * Never a replacement: the durable log is objects in the blob store, written
80
+ * and awaited, and these are copies written best-effort behind a bounded
81
+ * queue. A collector being down must not fail a capability call, so a sink
82
+ * that can fail cannot be the one the guarantee rests on.
83
+ */
84
+ export const auditSinkSchema = z.discriminatedUnion('kind', [
85
+ z.object({ kind: z.literal('stdout') }),
86
+ z.object({
87
+ kind: z.literal('otlp'),
88
+ /** The logs endpoint, e.g. `https://collector.example/v1/logs`. */
89
+ endpoint: z.string().url(),
90
+ /** Static headers, as a credential ref: an API key belongs in the store. */
91
+ headers_ref: credentialRef.optional(),
92
+ service_name: z.string().optional(),
93
+ }),
94
+ ]);
95
+
96
+ export const auditTargetSchema = z.object({
97
+ /** Copies, in addition to the durable log. Empty or absent means no copies. */
98
+ sinks: z.array(auditSinkSchema).default([]),
99
+ });
100
+
101
+ /**
102
+ * Where the vault's encrypted document lives.
103
+ *
104
+ * Its own block rather than a reuse of `credentials`, because the vault must
105
+ * never be the credential store — the two hold different things and collapsing
106
+ * them is the single most damaging mistake available here (`docs/detailed/security.md`).
107
+ *
108
+ * Optional, defaulting to `file`: every profile written before ADR-014 keeps
109
+ * working unchanged, and a local run needs no vault configuration at all.
110
+ *
111
+ * `secret` is what a deployment uses: the whole sealed document as one entry in
112
+ * the target's own credential store. Not a merge of the two stores — it is
113
+ * ciphertext by the time it arrives, under a different key from a different
114
+ * environment variable, and the bucket arrangement it replaces was protecting
115
+ * the smaller asset with the taller fence (ADR-022).
116
+ *
117
+ * One entry rather than one per item, because the document is encrypted whole:
118
+ * item *names* are inside it, and a secret-per-item mapping would publish
119
+ * those names into a cloud IAM console to gain nothing this does not have.
120
+ *
121
+ * `blob` remains, for a target that would rather keep it beside the other
122
+ * objects.
123
+ */
124
+ export const vaultTargetSchema = z.object({
125
+ adapter: z.enum(['file', 'blob', 'secret']),
126
+ /** File path, or blob key. Defaults to `./data/<profile>.vault.enc` / `vault.enc`. */
127
+ path: z.string().optional(),
128
+ /** `secret` only: where the sealed document lives. Defaults to `vault/document`. */
129
+ ref: credentialRef.optional(),
130
+ });
131
+
132
+ /**
133
+ * Where a target is rolled out, and who may reach it once it is.
134
+ *
135
+ * `platform` is the discriminator, and it is the only thing that decides which
136
+ * driver runs — the block used to be named `cloudrun`, which meant the *name of
137
+ * a key* selected a vendor and a second host could only ever be a second
138
+ * optional block that the loader had to disambiguate. Adapters are named for the
139
+ * protocol and deployments for the vendor (ADR-013); `platform` is how a target
140
+ * says which vendor without the schema growing a key per vendor.
141
+ *
142
+ * `project` is optional here for the same reason `credentials.project` is: it
143
+ * means something to one platform and nothing to the next, so the driver that
144
+ * needs it is the thing that refuses without it. The alternative — a
145
+ * discriminated union per platform — buys precision this file cannot use and
146
+ * costs a schema edit on every field any host ever adds.
147
+ */
148
+ export const deployTargetSchema = z.object({
149
+ platform: z.enum(['cloudrun']),
150
+ region: z.string(),
151
+ service: z.string(),
152
+ /**
153
+ * `iam` puts the platform's own identity check in front of the service;
154
+ * `public` leaves the door open and relies on this application's gate.
155
+ *
156
+ * Defaults to `iam`, so a target that says nothing gets the closed one. Note
157
+ * what `iam` costs: it admits only callers who can mint an identity token for
158
+ * the host's own IAM, which no agent harness can do. A target reached by
159
+ * Claude or ChatGPT wants `public` plus an `auth.authorization` block, and
160
+ * that combination is the one `deploy` proposes.
161
+ */
162
+ access: z.enum(['iam', 'public']).default('iam'),
163
+ /** Required by the `cloudrun` driver; meaningless to a platform without projects. */
164
+ project: z.string().optional(),
165
+ /**
166
+ * The billing account to attach `project` to, when this deploy created it.
167
+ *
168
+ * Present only when the project did not exist at survey time, and it is what
169
+ * makes the difference between a deploy that provisions a project and one that
170
+ * deploys into a project you already had. Absent is the ordinary case, and
171
+ * means "assume the project is there and billed".
172
+ *
173
+ * Not a credential: a billing account id names an account, it does not
174
+ * authorise a charge against it — spending is authorised by the IAM role your
175
+ * own login holds on it. So it belongs in config rather than the secret store,
176
+ * and `secret-detection.ts` has no reason to refuse it.
177
+ */
178
+ billing_account: z.string().optional(),
179
+ /** The identity the running revision assumes. Needs read access to the credential store. */
180
+ service_account: z.string().optional(),
181
+ });
182
+
183
+ /**
184
+ * The pre-`deploy` spelling, still accepted.
185
+ *
186
+ * Normalised into `deploy` below rather than read anywhere, so exactly one shape
187
+ * reaches the rest of the codebase. It gains `access: iam` in the process, which
188
+ * is a deliberate change of default for a config that predates the field — a
189
+ * deploy that was open stays open only by saying so.
190
+ */
191
+ const legacyCloudRunSchema = z.object({
192
+ project: z.string(),
193
+ region: z.string(),
194
+ service: z.string(),
195
+ });
196
+
197
+ export const targetSchema = z
198
+ .object({
199
+ credentials: credentialsTargetSchema,
200
+ audit: auditTargetSchema.optional(),
201
+ storage: storageTargetSchema,
202
+ vault: vaultTargetSchema.optional(),
203
+ deploy: deployTargetSchema.optional(),
204
+ /** @deprecated Write `deploy` with `platform: cloudrun`. */
205
+ cloudrun: legacyCloudRunSchema.optional(),
206
+ })
207
+ .superRefine((target, ctx) => {
208
+ // Both present is refused rather than resolved by precedence: a second place
209
+ // to say where this deploys could only ever disagree with the first, and
210
+ // silently preferring one would roll a revision to the project the operator
211
+ // was not reading.
212
+ if (target.deploy && target.cloudrun) {
213
+ ctx.addIssue({
214
+ code: 'custom',
215
+ path: ['cloudrun'],
216
+ message:
217
+ 'both "deploy" and "cloudrun" are declared — remove "cloudrun", which "deploy" replaces',
218
+ });
219
+ }
220
+ })
221
+ .transform(({ cloudrun, ...target }) =>
222
+ target.deploy || !cloudrun
223
+ ? target
224
+ : { ...target, deploy: { ...cloudrun, platform: 'cloudrun' as const, access: 'iam' as const } },
225
+ );
226
+
227
+ /**
228
+ * A rule is a pattern, and usually just a string.
229
+ *
230
+ * The object form exists only for `expires_at`; writing `gmail.*` should not
231
+ * require learning a record shape. Both parse to the same thing.
232
+ */
233
+ export const policyRuleSchema = z.union([
234
+ capabilityPattern.transform((capability) => ({ capability })),
235
+ z.object({
236
+ capability: capabilityPattern,
237
+ expires_at: z.iso.datetime({ offset: true }).optional(),
238
+ }),
239
+ ]);
240
+
241
+ /**
242
+ * One policy block for the whole profile.
243
+ *
244
+ * Rules name capabilities, never connections. Every account of a provider in a
245
+ * profile is governed identically, and granularity comes from running a
246
+ * narrower profile — profiles already share no database, no credential store,
247
+ * and no URL, which is a stronger boundary than a policy row ever was. The
248
+ * agent still names which account it is calling; policy simply does not
249
+ * discriminate between them.
250
+ *
251
+ * Default deny still holds where it counts: an absent or empty `policy` grants
252
+ * nothing at all. `connect` writes `allow: ['*']` because a connection you just
253
+ * authorised and cannot use is a bad first impression — but that is a default
254
+ * written into your file, visible and editable, not a behaviour of the engine.
255
+ */
256
+ export const policySchema = z.object({
257
+ allow: z.array(policyRuleSchema).default([]),
258
+ deny: z.array(policyRuleSchema).default([]),
259
+ });
260
+
261
+ /**
262
+ * One authorised account.
263
+ *
264
+ * `account` is the identity as the provider knows it — an email address, a
265
+ * workspace name — resolved at connect time rather than invented. A file that
266
+ * says `Gmail main2` cannot answer the only question anyone asks of it, which
267
+ * is *whose mailbox is this*. `id` is the stable key that `credential_ref` and
268
+ * the agent's `connection` argument point at, and it is derived from `account`
269
+ * so it means something too.
270
+ */
271
+ export const connectionSchema = z.object({
272
+ id: z.string().regex(/^[a-z0-9][a-z0-9_]*$/, 'must be lowercase alphanumeric with underscores'),
273
+ provider: identifier,
274
+ account: z.string().min(1),
275
+ credential_ref: credentialRef.optional(),
276
+ /** Provider-specific, validated later against that provider's own schema. */
277
+ config: z.record(z.string(), z.unknown()).optional(),
278
+ });
279
+
280
+ export const oauthAppSchema = z.object({
281
+ client_id_ref: credentialRef,
282
+ client_secret_ref: credentialRef,
283
+ });
284
+
285
+
286
+ export const configSchema = z.object({
287
+ contract: z.number().int().positive(),
288
+
289
+ instance: z.object({
290
+ profile: identifier,
291
+ default_target: z.string().min(1),
292
+ port: z.number().int().min(1).max(65535).default(7337),
293
+ /**
294
+ * Loopback by default. Binding elsewhere is possible but the server
295
+ * refuses to combine it with anonymous auth.
296
+ */
297
+ host: z.string().default('127.0.0.1'),
298
+ }),
299
+
300
+ targets: z.record(z.string(), targetSchema),
301
+
302
+ auth: z
303
+ .object({
304
+ mode: z.literal('bearer').default('bearer'),
305
+ token_ref: credentialRef.default('profile/token'),
306
+ /**
307
+ * Additive. `mode` above still describes what the endpoint accepts on the
308
+ * wire — a bearer token — and this describes where a *remote* client can
309
+ * get one. Omitting it leaves every existing profile behaving identically.
310
+ */
311
+ authorization: authorizationSchema.optional(),
312
+ })
313
+ .default({ mode: 'bearer', token_ref: 'profile/token' }),
314
+
315
+ limits: z
316
+ .object({
317
+ requests_per_minute: z.number().int().positive().default(120),
318
+ upstream_calls_per_minute: z.number().int().positive().default(60),
319
+ })
320
+ .default({ requests_per_minute: 120, upstream_calls_per_minute: 60 }),
321
+
322
+ oauth_apps: z.record(identifier, oauthAppSchema).default({}),
323
+
324
+ /**
325
+ * There is no `providers` block. A provider is enabled by having a connection
326
+ * to it — a second place to say so could only ever disagree with the first,
327
+ * and everything else a provider needs is in its manifest.
328
+ */
329
+ connections: z.array(connectionSchema).default([]),
330
+ policy: policySchema.default({ allow: [], deny: [] }),
331
+ });
332
+
333
+ export type Config = z.infer<typeof configSchema>;
334
+ export type ConnectionConfig = z.infer<typeof connectionSchema>;
335
+ export type PolicyRuleConfig = z.infer<typeof policyRuleSchema>;
336
+ export type TargetConfig = z.infer<typeof targetSchema>;
337
+ export type DeployConfig = z.infer<typeof deployTargetSchema>;
338
+ export type AuthorizationConfig = z.infer<typeof authorizationSchema>;
339
+ export { authorizationSchema };
340
+
341
+ /** The workspace file: `lanes-link.yaml`, alongside a `profiles/` directory. */
342
+ export const workspaceSchema = z.object({
343
+ contract: z.number().int().positive(),
344
+ default_profile: identifier.optional(),
345
+ });
346
+
347
+ export type WorkspaceConfig = z.infer<typeof workspaceSchema>;