@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,194 @@
1
+ import { z } from 'zod';
2
+ import {
3
+ defineLocalProvider,
4
+ keepKeys,
5
+ redaction,
6
+ type Capability,
7
+ type ProviderDefinition,
8
+ } from '#connectivity';
9
+ import { assertItemId, type VaultStore } from '#secrets';
10
+
11
+ /**
12
+ * `vault` — the owner's own secret material.
13
+ *
14
+ * Three properties, each of which is a decision recorded in ADR-012:
15
+ *
16
+ * **Tools only, never resources.** Resources are listable and cacheable, and
17
+ * both are wrong for a secret (`docs/detailed/providers.md`).
18
+ *
19
+ * **Per-item policy through the capability name.** Each stored item gets its own
20
+ * read capability — `vault.get.github_token` — so `deny: [vault.get.bank]` and
21
+ * `allow: [vault.get.*]` are both already expressible against the policy engine
22
+ * exactly as it stands. The alternative was teaching policy about arguments,
23
+ * which `packages/core/src/config/schema.ts` warns against by name: a policy
24
+ * expression language is a language with its own bugs, and a bug here is the one
25
+ * this project cannot afford.
26
+ *
27
+ * Because capabilities are fixed for the life of a process, the item list is
28
+ * read from the store when the runtime is built. **An item written by
29
+ * `vault.put` is not readable until the endpoint restarts.** That is a feature:
30
+ * a write cannot hand itself a read, so granting access to a new secret is a
31
+ * deliberate act by the operator between two runs.
32
+ *
33
+ * There is deliberately no `vault.list`. The policy-filtered tool list *is* the
34
+ * listing, and it is the only one that cannot over-report — an agent granted one
35
+ * item cannot discover that the others exist.
36
+ *
37
+ * **A separate store and a separate encryption key.** Never `SecretStore`.
38
+ * See `vault-store.ts`; the store arrives as a constructor argument rather than
39
+ * through `ProviderContext`, because `ProviderContext` deliberately carries no
40
+ * path to any raw backend.
41
+ */
42
+
43
+ export interface VaultProviderOptions {
44
+ readonly store: VaultStore;
45
+ /**
46
+ * Which items exist, read from the store at startup. Each becomes a
47
+ * `vault.get.<id>` capability.
48
+ *
49
+ * Passed in rather than read here because building a provider is synchronous
50
+ * and reading an encrypted file is not — and because the runtime is the right
51
+ * place to decide when the store is opened.
52
+ */
53
+ readonly items?: ReadonlyArray<{ id: string; description?: string }>;
54
+ }
55
+
56
+ export function createVaultProvider(options: VaultProviderOptions): ProviderDefinition {
57
+ const { store } = options;
58
+
59
+ // Deduplicated across connections: a capability id is provider-wide, so two
60
+ // connections holding an item of the same name contribute one capability. The
61
+ // handler still reads from the invoked connection's own namespace, so the two
62
+ // values never mix.
63
+ const items = new Map<string, string | undefined>();
64
+ for (const item of options.items ?? []) {
65
+ if (!items.has(item.id)) items.set(item.id, item.description);
66
+ }
67
+
68
+ const reads: Capability[] = [...items].map(([id, description]) => ({
69
+ kind: 'tool' as const,
70
+ name: `get.${id}`,
71
+ title: `Read vault item "${id}"`,
72
+ description:
73
+ description ??
74
+ `Return the stored value of vault item "${id}". Handle it as a secret: it is the owner's credential, not context to quote back.`,
75
+ inputSchema: z.object({}),
76
+ // Nothing to redact — the id is in the capability name, which the audit
77
+ // event records as `capability`, and there are no arguments at all.
78
+ async handler(_input, context) {
79
+ const item = await store.get(context.connection.id, id);
80
+ if (!item) {
81
+ return {
82
+ content: [{ type: 'text', text: `No vault item "${id}" on ${context.connection.key}.` }],
83
+ isError: true,
84
+ };
85
+ }
86
+
87
+ // Length rather than value: enough to tell a truncated paste from a
88
+ // correct one, and it never reaches the log as the value itself.
89
+ context.audit.annotate({ item: id, bytes: item.value.length });
90
+ return { content: [{ type: 'text', text: item.value }] };
91
+ },
92
+ }));
93
+
94
+ return defineLocalProvider({
95
+ id: 'vault',
96
+ name: 'Vault',
97
+ version: '1.0.0',
98
+ description:
99
+ "The owner's own passwords and API keys, in a store separate from the system's credentials and under a separate key. Reads are granted one item at a time.",
100
+
101
+ configSchema: z.object({}),
102
+ connectionSchema: z.object({}),
103
+
104
+ bundles: [
105
+ {
106
+ name: 'read',
107
+ description: 'Read stored items. Grant one at a time, not as a bundle.',
108
+ oauth_scopes: [],
109
+ capabilities: [...items.keys()].map((id) => `get.${id}`),
110
+ default: true,
111
+ },
112
+ {
113
+ name: 'write',
114
+ description: 'Store and remove items.',
115
+ oauth_scopes: [],
116
+ capabilities: ['put', 'remove'],
117
+ },
118
+ ],
119
+
120
+ capabilities: [
121
+ ...reads,
122
+
123
+ {
124
+ kind: 'tool',
125
+ name: 'put',
126
+ title: 'Store a vault item',
127
+ description:
128
+ 'Store or replace a secret under an id. The item becomes readable only after the endpoint restarts, and only if policy grants "vault.get.<id>" — a write cannot grant itself a read.',
129
+ inputSchema: z.object({
130
+ id: z
131
+ .string()
132
+ .min(1)
133
+ .describe('Item id: lowercase letters, digits and "_". Becomes part of a capability name.'),
134
+ value: z.string().min(1).describe('The secret to store'),
135
+ description: z.string().optional().describe('What this is, for the operator'),
136
+ }),
137
+ // The id and its description are names worth recording — an audit log
138
+ // that cannot say *which* item was written answers very little. The
139
+ // value is withheld outright rather than type-marked, because
140
+ // `<string:40>` still discloses a secret's length. Redaction resolves
141
+ // before the policy decision in `dispatch.ts`, so a denied call is
142
+ // redacted identically to an allowed one.
143
+ redact: redaction({ keep: ['id', 'description'], withhold: ['value'] }),
144
+ async handler({ id, value, description }, context) {
145
+ assertItemId(id);
146
+ await store.put(context.connection.id, {
147
+ id,
148
+ value,
149
+ ...(description ? { description } : {}),
150
+ });
151
+
152
+ context.audit.annotate({ item: id, bytes: value.length });
153
+
154
+ return {
155
+ content: [
156
+ {
157
+ type: 'text',
158
+ text:
159
+ `Stored vault item "${id}" on ${context.connection.key}. ` +
160
+ `It is not readable yet: restart the endpoint, and allow "vault.get.${id}" in policy.`,
161
+ },
162
+ ],
163
+ };
164
+ },
165
+ },
166
+
167
+ {
168
+ kind: 'tool',
169
+ name: 'remove',
170
+ title: 'Remove a vault item',
171
+ description: 'Delete a stored item. Its read capability disappears when the endpoint restarts.',
172
+ inputSchema: z.object({
173
+ id: z.string().min(1).describe('Item id'),
174
+ }),
175
+ redact: keepKeys('id'),
176
+ async handler({ id }, context) {
177
+ const removed = await store.delete(context.connection.id, id);
178
+
179
+ return {
180
+ content: [
181
+ {
182
+ type: 'text',
183
+ text: removed
184
+ ? `Removed vault item "${id}" from ${context.connection.key}.`
185
+ : `No vault item "${id}" on ${context.connection.key}.`,
186
+ },
187
+ ],
188
+ ...(removed ? {} : { isError: true }),
189
+ };
190
+ },
191
+ },
192
+ ],
193
+ });
194
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * What exists, and what it is called.
3
+ *
4
+ * The registry holds every provider available to a profile — built in, or
5
+ * declared as workspace YAML — and the capabilities each one contributes,
6
+ * addressed `provider.capability`. `reconcile` is the other half of the same
7
+ * question: the config file says what should exist, the database records what
8
+ * did, and reconcile makes the second match the first without ever deleting
9
+ * history.
10
+ *
11
+ * `policy-bridge` turns a profile's declared rules into the document
12
+ * `#policy` evaluates, and lives here because it is a translation of config,
13
+ * not a policy decision.
14
+ */
15
+
16
+ export {
17
+ ProviderRegistry,
18
+ matchesGlob,
19
+ type RegisteredCapability,
20
+ type RegisteredProvider,
21
+ } from './registry.ts';
22
+
23
+ export {
24
+ applyReconcile,
25
+ credentialRefFor,
26
+ formatPlan,
27
+ planIsNoop,
28
+ planReconcile,
29
+ ownClientRefsFor,
30
+ rotatableCredentialRefsFor,
31
+ type AuthenticatingProviders,
32
+ type ReconcileAction,
33
+ type ReconcilePlan,
34
+ } from './reconcile.ts';
35
+
36
+ export { toPolicyDocument } from './policy-bridge.ts';
@@ -0,0 +1,32 @@
1
+ import type { PolicyDocument, PolicyRule } from '#policy';
2
+ import type { Config } from '#profile';
3
+
4
+ /**
5
+ * Turn the config's `policy` block into the document the policy engine
6
+ * evaluates.
7
+ *
8
+ * The config format keeps `allow` and `deny` as separate lists because that is
9
+ * what reads clearly in YAML; the engine wants one list carrying an effect,
10
+ * because evaluation must consider both together to make deny win regardless of
11
+ * ordering. Converting here keeps that difference from leaking either way.
12
+ */
13
+ export function toPolicyDocument(config: Config): PolicyDocument {
14
+ const rules: PolicyRule[] = [];
15
+
16
+ for (const [effect, entries] of [
17
+ ['allow', config.policy.allow],
18
+ ['deny', config.policy.deny],
19
+ ] as const) {
20
+ for (const entry of entries) {
21
+ rules.push({
22
+ capability: entry.capability,
23
+ effect,
24
+ ...('expires_at' in entry && entry.expires_at
25
+ ? { expiresAt: new Date(entry.expires_at) }
26
+ : {}),
27
+ });
28
+ }
29
+ }
30
+
31
+ return { rules };
32
+ }
@@ -0,0 +1,313 @@
1
+ import type { SecretStore } from '#secrets';
2
+ import type { ConnectionRecord, ConnectionStatus, RuntimeState } from '#stores/state';
3
+ import {
4
+ credentialRefForConnection,
5
+ rotatableCredentialRefs,
6
+ type ProviderManifest,
7
+ } from '#connectivity';
8
+ import type { ConnectionConfig, Config } from '#profile';
9
+
10
+ /**
11
+ * Where a connection's credential lives.
12
+ *
13
+ * The single authority, because there used to be two that disagreed: reconcile
14
+ * derived `<provider>/<id>` and never read the manifest, while the request
15
+ * authorizer read the manifest and never derived. A provider declaring
16
+ * `credential_ref: mything/api_key` was therefore reported unauthorized forever
17
+ * — `doctor` told you to run `connect`, and `connect` did not help.
18
+ *
19
+ * The manifest answers it, and the connection's own `credential_ref` does not
20
+ * overrule that. It used to, and the override was reported rather than obeyed:
21
+ * `resolveSecretRefs` treats the field as an *addition* to what a connection may
22
+ * reach, and every path that actually reads a credential — the OAuth refresh,
23
+ * `connect`, `requirements`, `setup` — derives from the manifest and has never
24
+ * read it. Substituting here made the status disagree with the request in both
25
+ * directions: `active` on a connection whose every call 401s, and unauthorized
26
+ * on one that works, which is the same bug this function was written to end
27
+ * arriving from the other side.
28
+ *
29
+ * It is still the answer when the manifest gives none. A `local` provider
30
+ * authenticates with nothing, so there is no derived ref to contradict, and
31
+ * `resolveSecretRefs` makes this field that connection's whole allowlist —
32
+ * which leaves reporting whether it is present the only signal there is.
33
+ */
34
+ export function credentialRefFor(
35
+ connection: ConnectionConfig,
36
+ manifest: ProviderManifest | undefined,
37
+ ): string | undefined {
38
+ const derived = manifest ? credentialRefForConnection(manifest, connection.id) : undefined;
39
+ return derived ?? connection.credential_ref;
40
+ }
41
+
42
+ /**
43
+ * The same question for a deploy, which needs the refs a *running* instance
44
+ * rewrites so it can grant them and nothing else.
45
+ *
46
+ * Deliberately not routed through `credentialRefFor`, and so deliberately blind
47
+ * to a hand-placed `credential_ref`: the refresh path derives from the manifest
48
+ * and has never read the config field, so the ref written is the derived one
49
+ * whatever the connection says. Granting what the config names instead would
50
+ * bind a secret nobody writes and leave the one that is written unbound.
51
+ *
52
+ * `#deployments` cannot import `#connectivity` — see `architecture.test.ts` —
53
+ * which is why the crossing happens here, next to the sibling that already
54
+ * makes it.
55
+ */
56
+ export function rotatableCredentialRefsFor(
57
+ connection: ConnectionConfig,
58
+ manifest: ProviderManifest | undefined,
59
+ ): readonly string[] {
60
+ return manifest ? rotatableCredentialRefs(manifest, connection.id) : [];
61
+ }
62
+
63
+ /**
64
+ * The OAuth client a revision signs its refreshes with, where the profile holds one.
65
+ *
66
+ * Read-only, and only for a profile that declares the entry: the client is the
67
+ * operator's, shared by every connection of that vendor, and ADR-026's line is
68
+ * that a revision rotates what is its own and never rewrites somebody else's.
69
+ * So this is deliberately *not* part of `rotatableCredentialRefsFor`.
70
+ *
71
+ * These were missing, and the failure was silent in the worst way. The
72
+ * gcp-secret-manager adapter answers `null` only on a 404 — an *unbound* secret
73
+ * is a 403, which throws — so a bring-your-own Google connection on Cloud Run
74
+ * would serve until its access token expired and then fail every call, an hour
75
+ * after the revision reported healthy. A profile that authorises against a
76
+ * broker declares no entry and so adds nothing here, which is the arrangement
77
+ * this is meant to leave alone.
78
+ *
79
+ * Here rather than in `#deployments` for the reason its sibling gives: that
80
+ * component cannot import `#connectivity`, and this needs the manifest.
81
+ */
82
+ export function ownClientRefsFor(
83
+ manifest: ProviderManifest | undefined,
84
+ oauthApps: Readonly<Record<string, { client_id_ref: string; client_secret_ref: string }>>,
85
+ ): readonly string[] {
86
+ if (manifest?.auth.kind !== 'oauth' || manifest.auth.registration !== 'manual') return [];
87
+ const app = manifest.auth.app;
88
+ if (!app) return [];
89
+
90
+ const declared = oauthApps[app];
91
+ return declared ? [declared.client_id_ref, declared.client_secret_ref] : [];
92
+ }
93
+
94
+ /**
95
+ * How to find a provider's manifest, so a missing `credential_ref` can derive.
96
+ *
97
+ * A lookup rather than a boolean: "does this authenticate" was never enough to
98
+ * answer "where does the credential live", and pretending otherwise is what let
99
+ * the two answers drift apart.
100
+ */
101
+ export type AuthenticatingProviders = (providerId: string) => ProviderManifest | undefined;
102
+
103
+ /**
104
+ * Reconcile declared config against runtime state.
105
+ *
106
+ * Planning and applying are separate functions over the same plan, so
107
+ * `lanes link plan` and `lanes link start` cannot disagree about what is about to happen. If
108
+ * the preview were computed differently from the mutation, the preview would
109
+ * eventually become a lie.
110
+ */
111
+
112
+ export type ReconcileAction =
113
+ | { kind: 'create'; connection: string; displayName: string; status: ConnectionStatus }
114
+ | { kind: 'update'; connection: string; changes: readonly string[] }
115
+ | { kind: 'status'; connection: string; from: ConnectionStatus; to: ConnectionStatus; reason: string }
116
+ /**
117
+ * An undeclared connection is disabled, never deleted. Deleting it would
118
+ * orphan its audit history, and the log's value is precisely that it outlives
119
+ * the configuration that produced it.
120
+ */
121
+ | { kind: 'disable'; connection: string; reason: string }
122
+ | { kind: 'unchanged'; connection: string };
123
+
124
+ export interface ReconcilePlan {
125
+ readonly actions: readonly ReconcileAction[];
126
+ /** Declared in config but absent from the state. */
127
+ readonly missingInDatabase: readonly string[];
128
+ /** Present in the state but no longer declared. */
129
+ readonly undeclared: readonly string[];
130
+ /** Declared connections whose credential could not be resolved. */
131
+ readonly unauthorized: readonly string[];
132
+ }
133
+
134
+ export function planIsNoop(plan: ReconcilePlan): boolean {
135
+ return plan.actions.every((action) => action.kind === 'unchanged');
136
+ }
137
+
138
+ /**
139
+ * Work out what reconcile would do, without touching anything.
140
+ *
141
+ * A missing credential does NOT fail: the connection is marked `unauthorized`,
142
+ * startup continues, and the problem surfaces in `lanes link doctor` and as an
143
+ * actionable error if that connection's capability is actually called. A
144
+ * half-configured Gmail account must not stop the other providers from serving.
145
+ */
146
+ export async function planReconcile(
147
+ config: Config,
148
+ state: RuntimeState,
149
+ credentials: SecretStore,
150
+ /**
151
+ * Optional so tests and callers without a registry keep working: without it,
152
+ * only an explicit `credential_ref` is checked, which is the pre-derivation
153
+ * behaviour.
154
+ */
155
+ manifestFor: AuthenticatingProviders = () => undefined,
156
+ ): Promise<ReconcilePlan> {
157
+ const existing = new Map<string, ConnectionRecord>();
158
+ for (const record of await state.connections.list()) {
159
+ existing.set(`${record.provider}.${record.id}`, record);
160
+ }
161
+
162
+ const actions: ReconcileAction[] = [];
163
+ const missingInDatabase: string[] = [];
164
+ const unauthorized: string[] = [];
165
+ const declared = new Set<string>();
166
+
167
+ for (const connection of config.connections) {
168
+ const key = `${connection.provider}.${connection.id}`;
169
+ declared.add(key);
170
+
171
+ const ref = credentialRefFor(connection, manifestFor(connection.provider));
172
+ const authorized = ref
173
+ ? await credentials.has(ref)
174
+ : true; // A provider needing no credential is authorized by construction.
175
+
176
+ if (!authorized) unauthorized.push(key);
177
+
178
+ // Declaring the connection is what enables it. There is no separate
179
+ // `enabled` flag to disagree with the connection's own existence; to turn
180
+ // one off, delete it or deny its capabilities.
181
+ const desired: ConnectionStatus = authorized ? 'active' : 'unauthorized';
182
+
183
+ const record = existing.get(key);
184
+ if (!record) {
185
+ missingInDatabase.push(key);
186
+ actions.push({
187
+ kind: 'create',
188
+ connection: key,
189
+ displayName: connection.account,
190
+ status: desired,
191
+ });
192
+ continue;
193
+ }
194
+
195
+ const changes: string[] = [];
196
+ if (record.displayName !== connection.account) changes.push('account');
197
+
198
+ if (changes.length > 0) actions.push({ kind: 'update', connection: key, changes });
199
+
200
+ if (record.status !== desired) {
201
+ actions.push({
202
+ kind: 'status',
203
+ connection: key,
204
+ from: record.status,
205
+ to: desired,
206
+ reason: authorized
207
+ ? 'credential resolves'
208
+ : `credential ${ref} is missing from the credential store`,
209
+ });
210
+ } else if (changes.length === 0) {
211
+ actions.push({ kind: 'unchanged', connection: key });
212
+ }
213
+ }
214
+
215
+ const undeclared: string[] = [];
216
+ for (const [key, record] of existing) {
217
+ if (declared.has(key)) continue;
218
+ undeclared.push(key);
219
+ if (record.status !== 'disabled') {
220
+ actions.push({
221
+ kind: 'disable',
222
+ connection: key,
223
+ reason: 'no longer declared in config; disabled rather than deleted to preserve audit history',
224
+ });
225
+ }
226
+ }
227
+
228
+ return { actions, missingInDatabase, undeclared, unauthorized };
229
+ }
230
+
231
+ export async function applyReconcile(
232
+ config: Config,
233
+ state: RuntimeState,
234
+ plan: ReconcilePlan,
235
+ ): Promise<void> {
236
+ const byKey = new Map(config.connections.map((c) => [`${c.provider}.${c.id}`, c]));
237
+
238
+ for (const action of plan.actions) {
239
+ if (action.kind === 'unchanged') continue;
240
+
241
+ const [provider = '', id = ''] = splitConnectionKey(action.connection);
242
+
243
+ if (action.kind === 'disable') {
244
+ await state.connections.setStatus(provider, id, 'disabled');
245
+ continue;
246
+ }
247
+
248
+ if (action.kind === 'status') {
249
+ await state.connections.setStatus(provider, id, action.to);
250
+ continue;
251
+ }
252
+
253
+ const declared = byKey.get(action.connection);
254
+ if (!declared) continue;
255
+
256
+ await state.connections.upsert({
257
+ provider,
258
+ id,
259
+ displayName: declared.account,
260
+ status: action.kind === 'create' ? action.status : await currentStatus(state, provider, id),
261
+ });
262
+ }
263
+ }
264
+
265
+ async function currentStatus(
266
+ state: RuntimeState,
267
+ provider: string,
268
+ id: string,
269
+ ): Promise<ConnectionStatus> {
270
+ return (await state.connections.get(provider, id))?.status ?? 'active';
271
+ }
272
+
273
+ function splitConnectionKey(key: string): [string, string] {
274
+ const index = key.indexOf('.');
275
+ return index === -1 ? [key, ''] : [key.slice(0, index), key.slice(index + 1)];
276
+ }
277
+
278
+ /**
279
+ * Render a plan for `lanes link plan`.
280
+ *
281
+ * Reconcile disables undeclared connections, so this exists specifically so
282
+ * that outcome is never a surprise.
283
+ */
284
+ export function formatPlan(plan: ReconcilePlan): string {
285
+ if (planIsNoop(plan)) return 'No changes. Runtime state matches the declared config.';
286
+
287
+ const lines: string[] = [];
288
+ for (const action of plan.actions) {
289
+ switch (action.kind) {
290
+ case 'create':
291
+ lines.push(` + ${action.connection} create (${action.status})`);
292
+ break;
293
+ case 'update':
294
+ lines.push(` ~ ${action.connection} update ${action.changes.join(', ')}`);
295
+ break;
296
+ case 'status':
297
+ lines.push(` ~ ${action.connection} ${action.from} -> ${action.to} (${action.reason})`);
298
+ break;
299
+ case 'disable':
300
+ lines.push(` - ${action.connection} disable (${action.reason})`);
301
+ break;
302
+ case 'unchanged':
303
+ break;
304
+ }
305
+ }
306
+
307
+ if (plan.unauthorized.length > 0) {
308
+ lines.push('', 'Unauthorized connections (startup continues; these will error if called):');
309
+ for (const key of plan.unauthorized) lines.push(` ${key} — run: lanes link connect ${key}`);
310
+ }
311
+
312
+ return lines.join('\n');
313
+ }