@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,179 @@
1
+ /**
2
+ * Policy evaluation.
3
+ *
4
+ * This is the product. `gmail.search = allow` and `gmail.send = deny` are
5
+ * decisions the runtime enforces, not instructions a model is asked to respect.
6
+ * Everything else in this codebase exists to make this module's answer binding.
7
+ *
8
+ * Two invariants, both tested:
9
+ *
10
+ * 1. DEFAULT DENY. Nothing is reachable unless a rule grants it. An empty
11
+ * policy grants nothing at all.
12
+ * 2. TIGHTEN ONLY. Composition can narrow, never widen. A profile rule can
13
+ * deny what the instance floor allowed; it can never allow what the floor
14
+ * withheld. The floor is empty in M1 — the invariant is implemented
15
+ * anyway, because it is what makes delegated access safe to add later,
16
+ * and retrofitting it once rules exist in the wild is not possible.
17
+ */
18
+
19
+ export type { AuthorizationResult } from '#audit';
20
+ import type { AuthorizationResult } from '#audit';
21
+
22
+ export type { LimitConfig } from './limits.ts';
23
+ export { RateLimiter, DEFAULT_LIMITS } from './limits.ts';
24
+
25
+ /**
26
+ * `approval_required` is RESERVED. The model carries the state so that
27
+ * configuration written today stays loadable, but there is no approval engine
28
+ * and a rule carrying it is treated as `deny` until one exists — failing
29
+ * closed rather than silently permitting.
30
+ */
31
+ export type Effect = 'allow' | 'deny' | 'approval_required';
32
+
33
+ export interface PolicyRule {
34
+ /** `*`, `gmail.*`, or `gmail.search`. Those three forms, and no others. */
35
+ readonly capability: string;
36
+ readonly effect: Effect;
37
+ readonly expiresAt?: Date;
38
+ }
39
+
40
+ export interface PolicyDocument {
41
+ readonly rules: readonly PolicyRule[];
42
+ }
43
+
44
+ export const EMPTY_POLICY: PolicyDocument = { rules: [] };
45
+
46
+ export interface PolicyRequest {
47
+ /**
48
+ * The authenticated principal. One per profile in M1 (the owner). Carried
49
+ * explicitly so that adding delegated principals later is new rows rather
50
+ * than a new signature on the dispatch path.
51
+ */
52
+ readonly principal: string;
53
+ /** Fully qualified, e.g. `gmail.search`. */
54
+ readonly capability: string;
55
+ /**
56
+ * Fully qualified, e.g. `gmail.main`.
57
+ *
58
+ * Carried for audit, not for the decision: rules name capabilities only, so
59
+ * every account of a provider within a profile is governed identically.
60
+ * Granularity between accounts comes from a second profile, which shares no
61
+ * database, credential store, or URL with the first.
62
+ */
63
+ readonly connection: string;
64
+ /** Defaults to now; injectable so expiry is testable without clock games. */
65
+ readonly at?: Date;
66
+ }
67
+
68
+ export interface PolicyDecision {
69
+ readonly allowed: boolean;
70
+ readonly reason: AuthorizationResult;
71
+ readonly matched?: PolicyRule;
72
+ }
73
+
74
+ /** `*` matches everything, `gmail.*` matches one provider, otherwise literal. */
75
+ export function capabilityMatches(pattern: string, capability: string): boolean {
76
+ if (pattern === '*') return true;
77
+ if (pattern === capability) return true;
78
+ if (!pattern.endsWith('.*')) return false;
79
+ const prefix = pattern.slice(0, -1); // keep the dot: `gmail.*` -> `gmail.`
80
+ return capability.startsWith(prefix);
81
+ }
82
+
83
+ export function isRuleActive(rule: PolicyRule, at: Date): boolean {
84
+ return rule.expiresAt === undefined || rule.expiresAt > at;
85
+ }
86
+
87
+ function findMatch(
88
+ document: PolicyDocument,
89
+ request: PolicyRequest,
90
+ at: Date,
91
+ effects: readonly Effect[],
92
+ ): PolicyRule | undefined {
93
+ return document.rules.find(
94
+ (rule) =>
95
+ effects.includes(rule.effect) &&
96
+ isRuleActive(rule, at) &&
97
+ capabilityMatches(rule.capability, request.capability),
98
+ );
99
+ }
100
+
101
+ /**
102
+ * Evaluate one profile document. Deny wins over allow regardless of order, so
103
+ * rule ordering in a config file cannot change the answer — a denial is never
104
+ * something you can accidentally out-rank by putting an allow above it.
105
+ */
106
+ export function evaluateDocument(
107
+ document: PolicyDocument,
108
+ request: PolicyRequest,
109
+ ): PolicyDecision {
110
+ const at = request.at ?? new Date();
111
+
112
+ const denial = findMatch(document, request, at, ['deny', 'approval_required']);
113
+ if (denial) {
114
+ return { allowed: false, reason: 'denied_by_policy', matched: denial };
115
+ }
116
+
117
+ const allowance = findMatch(document, request, at, ['allow']);
118
+ if (allowance) {
119
+ return { allowed: true, reason: 'allowed', matched: allowance };
120
+ }
121
+
122
+ return { allowed: false, reason: 'denied_default' };
123
+ }
124
+
125
+ /**
126
+ * Compose an optional instance floor with the profile's policy.
127
+ *
128
+ * Monotonically tightening: the floor is evaluated first and its denial is
129
+ * final. The profile document is only ever consulted to narrow further, so
130
+ * there is no arrangement of profile rules that can widen past the floor.
131
+ */
132
+ export function evaluate(
133
+ request: PolicyRequest,
134
+ profile: PolicyDocument,
135
+ floor?: PolicyDocument,
136
+ ): PolicyDecision {
137
+ if (floor) {
138
+ const floorDecision = evaluateDocument(floor, request);
139
+ if (!floorDecision.allowed) return floorDecision;
140
+ }
141
+ return evaluateDocument(profile, request);
142
+ }
143
+
144
+ /**
145
+ * The connections a principal may use for a given capability.
146
+ *
147
+ * This is what populates the `connection` argument enum, so a client cannot
148
+ * even discover a connection it has no grant for. Discovery filtering and
149
+ * invocation enforcement therefore share one implementation — if they were
150
+ * separate, they could disagree, and a leak in discovery is still a leak.
151
+ */
152
+ export function allowedConnections(
153
+ capability: string,
154
+ connections: readonly string[],
155
+ principal: string,
156
+ profile: PolicyDocument,
157
+ floor?: PolicyDocument,
158
+ at?: Date,
159
+ ): string[] {
160
+ // Only this capability's own provider. `gmail.search` must never offer a
161
+ // Notion account, and that constraint is structural rather than a policy
162
+ // decision — it used to fall out of rules naming a connection, so dropping
163
+ // that had to put it back explicitly.
164
+ const provider = capability.slice(0, capability.indexOf('.'));
165
+ const ofProvider = connections.filter((connection) => connection.startsWith(`${provider}.`));
166
+
167
+ // All or nothing beyond that, since rules do not discriminate between
168
+ // accounts. The list shape is kept because it is what the `connection` enum
169
+ // wants, and because a future principal-scoped rule would restore the
170
+ // filtering without touching any caller.
171
+ const first = ofProvider[0];
172
+ if (first === undefined) return [];
173
+
174
+ const request: PolicyRequest = at
175
+ ? { principal, capability, connection: first, at }
176
+ : { principal, capability, connection: first };
177
+
178
+ return evaluate(request, profile, floor).allowed ? ofProvider : [];
179
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Rate limits.
3
+ *
4
+ * These blunt a runaway agent retry loop and protect a vendor's quota. They are
5
+ * NOT a security boundary, and the guarantee table says so: an authorised caller
6
+ * that stays under the limit is not constrained by this module in any way.
7
+ *
8
+ * Limits are per instance. On Cloud Run, which scales horizontally, they are
9
+ * therefore not global — a shared counter store would be needed for that and is
10
+ * deliberately out of scope. Documented rather than silently misleading.
11
+ */
12
+
13
+ export interface LimitConfig {
14
+ /** Per profile — the endpoint as a whole. */
15
+ readonly requestsPerMinute: number;
16
+ /** Per connection — protects the upstream vendor's quota. */
17
+ readonly upstreamCallsPerMinute: number;
18
+ }
19
+
20
+ export const DEFAULT_LIMITS: LimitConfig = {
21
+ requestsPerMinute: 120,
22
+ upstreamCallsPerMinute: 60,
23
+ };
24
+
25
+ interface Bucket {
26
+ tokens: number;
27
+ lastRefill: number;
28
+ }
29
+
30
+ /**
31
+ * Token bucket, refilled continuously rather than on a fixed window boundary.
32
+ *
33
+ * A fixed window lets a caller spend the whole budget in the last instant of
34
+ * one window and again in the first instant of the next, delivering double the
35
+ * intended rate exactly when a retry storm is worst.
36
+ */
37
+ export class RateLimiter {
38
+ readonly #buckets = new Map<string, Bucket>();
39
+ readonly #now: () => number;
40
+
41
+ constructor(now: () => number = Date.now) {
42
+ this.#now = now;
43
+ }
44
+
45
+ /**
46
+ * Consume one token. Returns whether the call may proceed, and when the
47
+ * bucket will next have capacity — callers surface that as a retry hint
48
+ * rather than leaving an agent to guess.
49
+ */
50
+ take(key: string, perMinute: number): { allowed: boolean; retryAfterMs: number } {
51
+ if (perMinute <= 0) return { allowed: false, retryAfterMs: 60_000 };
52
+
53
+ const now = this.#now();
54
+ const refillPerMs = perMinute / 60_000;
55
+ const bucket = this.#buckets.get(key) ?? { tokens: perMinute, lastRefill: now };
56
+
57
+ bucket.tokens = Math.min(perMinute, bucket.tokens + (now - bucket.lastRefill) * refillPerMs);
58
+ bucket.lastRefill = now;
59
+
60
+ if (bucket.tokens < 1) {
61
+ this.#buckets.set(key, bucket);
62
+ return { allowed: false, retryAfterMs: Math.ceil((1 - bucket.tokens) / refillPerMs) };
63
+ }
64
+
65
+ bucket.tokens -= 1;
66
+ this.#buckets.set(key, bucket);
67
+ return { allowed: true, retryAfterMs: 0 };
68
+ }
69
+
70
+ /** Drop idle buckets so a long-lived process does not accumulate keys forever. */
71
+ prune(idleMs = 300_000): void {
72
+ const cutoff = this.#now() - idleMs;
73
+ for (const [key, bucket] of this.#buckets) {
74
+ if (bucket.lastRefill < cutoff) this.#buckets.delete(key);
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,81 @@
1
+ import { z } from 'zod';
2
+ import { credentialRef } from './primitives.ts';
3
+
4
+ /**
5
+ * How a remote MCP client gets a token, when a bearer string is not enough.
6
+ *
7
+ * A bearer token is what `claude mcp add --header` and every local registration
8
+ * carry, and it stays the mechanism for those. It is not a mechanism a *remote*
9
+ * client can use: Claude's and ChatGPT's connector flows expect the OAuth
10
+ * handshake the MCP specification describes — a `401` naming a protected
11
+ * resource, discovery, an authorization code with PKCE — and offer nowhere to
12
+ * paste a fixed string.
13
+ *
14
+ * Two modes, because the choice is a real one and neither answer suits
15
+ * everybody:
16
+ *
17
+ * - `self` — this endpoint issues the tokens. It registers clients dynamically,
18
+ * so adding a connector is pasting the URL and approving once. Nothing to set
19
+ * up, which is why it is the documented default: an identity provider you must
20
+ * first go and configure in a console is the most expensive step in the whole
21
+ * product, and it is paid by every user.
22
+ * - `oidc` — an issuer you already run does it, and this endpoint only verifies.
23
+ * The cost is that the issuer must be reachable, must be registerable by the
24
+ * client (Google, for instance, supports neither dynamic registration nor
25
+ * client-ID metadata documents, so its client id has to be pasted in by hand),
26
+ * and must be told this endpoint's callback.
27
+ *
28
+ * Named for the protocol, not for a vendor — `issuer` is a URL, so pointing this
29
+ * at Entra, Auth0, or an internal provider is an edit rather than a code path.
30
+ * Absent means neither: bearer token only, exactly as before.
31
+ */
32
+ const selfAuthorizationSchema = z.object({
33
+ mode: z.literal('self'),
34
+ /**
35
+ * How long an issued access token lives.
36
+ *
37
+ * Short by design and refreshed rather than lengthened: a client that holds a
38
+ * long-lived token has something worth stealing, and the refresh path is the
39
+ * one that can be revoked by dropping a row.
40
+ */
41
+ access_token_ttl_minutes: z.number().int().positive().max(1440).default(60),
42
+ });
43
+
44
+ const oidcAuthorizationSchema = z.object({
45
+ mode: z.literal('oidc'),
46
+ /** Discovery root — `<issuer>/.well-known/openid-configuration` must resolve. */
47
+ issuer: z.url(),
48
+ /**
49
+ * The audience a presented token must name.
50
+ *
51
+ * Not decoration. Without it, any token the same issuer minted for any other
52
+ * application would open this endpoint — the confused-deputy case the MCP
53
+ * authorization spec calls out by name.
54
+ */
55
+ client_id_ref: credentialRef,
56
+ /**
57
+ * Where to ask about a token, when the issuer's discovery document does not
58
+ * say.
59
+ *
60
+ * Access tokens are often opaque, so checking one means asking the issuer.
61
+ * RFC 7662 standardised that as `introspection_endpoint` in the metadata, and
62
+ * several large issuers ship an equivalent without advertising one. A URL
63
+ * here rather than a case in a switch — the same reasoning that puts the
64
+ * S3-compatible service endpoint in config rather than naming a vendor.
65
+ */
66
+ introspection_endpoint: z.url().optional(),
67
+ /**
68
+ * Which subjects the issuer may vouch for. Matched against `sub` and against
69
+ * a verified `email`.
70
+ *
71
+ * At least one, always. An empty list is the kind of default that reads as
72
+ * "everyone" to whoever writes it and "no one" to whoever implemented it, and
73
+ * default deny is not a thing to leave ambiguous.
74
+ */
75
+ allowed_subjects: z.array(z.string().min(1)).min(1),
76
+ });
77
+
78
+ export const authorizationSchema = z.discriminatedUnion('mode', [
79
+ selfAuthorizationSchema,
80
+ oidcAuthorizationSchema,
81
+ ]);
@@ -0,0 +1,71 @@
1
+ import { createFilesystemBlobStore } from '#deployments/adapters/filesystem.ts';
2
+ import { createGcsBlobStore } from '#deployments/adapters/gcs.ts';
3
+ import type { BlobStore } from '#stores/blobs';
4
+
5
+ /**
6
+ * The workspace as a store, so a deployment's config does not have to be baked
7
+ * into its image.
8
+ *
9
+ * `LANES_LINK_HOME` used to be a directory and nothing else, which is why the
10
+ * Dockerfile copies `lanes-link.yaml` and `profiles/` in at build time. Its own
11
+ * comment records the trade and its expiry: *"the image **is** the config, so a
12
+ * revision fully describes what it serves and rollback is a revision switch.
13
+ * The cost is a rebuild per config change, which is the tradeoff docs/detailed/init.md
14
+ * accepts until it becomes annoying."*
15
+ *
16
+ * It became annoying. A policy change should not be a Docker build, and because
17
+ * those paths are gitignored the image cannot be built from a clean checkout at
18
+ * all — which is what stops there ever being one image that serves any
19
+ * workspace.
20
+ *
21
+ * So the root may be a `gs://` URL, and everything that reads config goes
22
+ * through a `BlobStore` rather than through `Bun.file`. There is no bootstrap
23
+ * problem: opening a GCS store needs only the URL and whatever identity is
24
+ * already present, and config carries secret *references* rather than values
25
+ * (`./secret-detection.ts` enforces that), so nothing has to be decrypted
26
+ * before the config that says how to decrypt things can be read.
27
+ *
28
+ * See ADR-023 for what this gives up — a Cloud Run revision no longer fully
29
+ * describes what it serves — and how the guarantee it kept is kept.
30
+ */
31
+
32
+ const GCS_SCHEME = 'gs://';
33
+
34
+ /** Whether a workspace root names a bucket rather than a directory. */
35
+ export function isRemoteWorkspace(root: string): boolean {
36
+ return root.startsWith(GCS_SCHEME);
37
+ }
38
+
39
+ /**
40
+ * The workspace's files.
41
+ *
42
+ * Rooted at the workspace, so every caller addresses `lanes-link.yaml` and
43
+ * `profiles/<name>.yaml` by the same relative key whichever backing it has.
44
+ */
45
+ export function workspaceFiles(root: string): BlobStore {
46
+ if (!isRemoteWorkspace(root)) return createFilesystemBlobStore({ root });
47
+
48
+ const withoutScheme = root.slice(GCS_SCHEME.length);
49
+ const slash = withoutScheme.indexOf('/');
50
+ const bucket = slash === -1 ? withoutScheme : withoutScheme.slice(0, slash);
51
+ const prefix = slash === -1 ? '' : withoutScheme.slice(slash + 1);
52
+
53
+ if (bucket.length === 0) {
54
+ throw new Error(`LANES_LINK_HOME is "${root}", which names no bucket. Expected gs://<bucket>[/prefix].`);
55
+ }
56
+ return createGcsBlobStore({ bucket, ...(prefix ? { prefix } : {}) });
57
+ }
58
+
59
+ /** Read a UTF-8 document from the workspace, or null when it is not there. */
60
+ export async function readWorkspaceFile(files: BlobStore, key: string): Promise<string | null> {
61
+ const bytes = await files.get(key);
62
+ return bytes === null ? null : new TextDecoder().decode(bytes);
63
+ }
64
+
65
+ export async function writeWorkspaceFile(
66
+ files: BlobStore,
67
+ key: string,
68
+ text: string,
69
+ ): Promise<void> {
70
+ await files.put(key, new TextEncoder().encode(text), { contentType: 'application/yaml' });
71
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * A profile: its file, its schema, and where it lives on disk.
3
+ *
4
+ * **One profile = one config = one database = one credential store.** Profiles
5
+ * share an endpoint and its token (ADR-009); they never share state. This
6
+ * component owns everything about what a profile *is* — the YAML contract, the
7
+ * loader that validates it, and the workspace resolution that finds it.
8
+ *
9
+ * What it deliberately does not own: which providers exist (`#registry`) and
10
+ * how a call runs (`#dispatch`). Those were all one package called `core`,
11
+ * which was a name for "the rest of it" rather than for anything.
12
+ */
13
+
14
+ export {
15
+ SUPPORTED_CONTRACT,
16
+ configSchema,
17
+ workspaceSchema,
18
+ type AuthorizationConfig,
19
+ type Config,
20
+ type ConnectionConfig,
21
+ type DeployConfig,
22
+ type PolicyRuleConfig,
23
+ type TargetConfig,
24
+ type WorkspaceConfig,
25
+ } from './schema.ts';
26
+
27
+ export {
28
+ ConfigError,
29
+ loadConfigFile,
30
+ parseConfig,
31
+ validateConfig,
32
+ type LoadedConfig,
33
+ } from './load.ts';
34
+
35
+ export {
36
+ findSecrets,
37
+ formatSecretFindings,
38
+ shannonEntropy,
39
+ type SecretFinding,
40
+ } from './secret-detection.ts';
41
+
42
+ export {
43
+ WORKSPACE_FILE,
44
+ describeSelection,
45
+ installRoot,
46
+ listProfiles,
47
+ loadProfileConfig,
48
+ profilePath,
49
+ readWorkspace,
50
+ resolveSelection,
51
+ resolveWorkspaceRoot,
52
+ workspacePath,
53
+ } from './workspace.ts';
54
+ export {
55
+ TARGET_ENV,
56
+ askedTarget,
57
+ resolveDeployTarget,
58
+ resolveTarget,
59
+ undeclaredTarget,
60
+ } from './targets.ts';
61
+ export {
62
+ isRemoteWorkspace,
63
+ readWorkspaceFile,
64
+ workspaceFiles,
65
+ writeWorkspaceFile,
66
+ } from './files.ts';
67
+ export {
68
+ type Resolution,
69
+ type ResolveOptions,
70
+ } from './workspace.ts';
71
+
72
+ export {
73
+ DATA_DIR,
74
+ layout,
75
+ profileDir,
76
+ } from './layout.ts';
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Where a profile's data lives, in one place.
3
+ *
4
+ * ```
5
+ * ~/.lanes-link/
6
+ * ├── lanes-link.yaml which profiles exist, which is default
7
+ * ├── profiles/<name>.yaml each profile's declared config
8
+ * └── data/
9
+ * └── <profile>/ everything one profile owns
10
+ * ├── state.kv/ state, connections, cursors
11
+ * ├── audit.log/ one object per event
12
+ * ├── credentials.enc system credentials, and its .key
13
+ * ├── vault.enc the owner's items, its own key
14
+ * ├── skills.d/ procedures, one <name>/SKILL.md each
15
+ * ├── providers.d/ the operator's own provider manifests
16
+ * └── <provider>/<connection>/… whatever that provider stores
17
+ * ```
18
+ *
19
+ * **One profile, one directory.** Before this, `data/` held
20
+ * `personal.db`, `personal.credentials.enc`, `personal.credentials.enc.key`,
21
+ * `work.db`, and a `personal/` directory, all in one flat listing — three
22
+ * profiles' worth of state interleaved, and no single thing to copy, back up, or
23
+ * delete. Now `rm -r data/work` is exactly "remove the work profile's data" and
24
+ * nothing else.
25
+ *
26
+ * **The blob root is the profile directory itself.** It used to be a `files/`
27
+ * subdirectory, which bought nothing and cost a level: a memory entry landed at
28
+ * `data/personal/files/memory/memory/entry/<id>.md`. The provider name and the
29
+ * connection name are the isolation boundary (`scopeBlobStore` namespaces
30
+ * `<provider>/<connection>`) and they are enough on their own, so an entry is
31
+ * now `data/personal/memory/main/<id>.md`. There is no collision risk with the
32
+ * files beside it: a provider id is `[a-z][a-z0-9_]*` and every reserved name
33
+ * here contains a dot.
34
+ *
35
+ * **Skills and provider manifests are the profile's too**, which reverses where
36
+ * both used to sit. They were at the workspace root, shared by every profile, on
37
+ * the reasoning that a procedure is not private to a profile the way its
38
+ * knowledge is. That was wrong twice over. A skill is instructions an agent will
39
+ * be handed, and ADR-014 §1 already treats authoring one as a grant worth
40
+ * governing — an odd thing to say about a document every profile reads anyway.
41
+ * And a procedure written for work names work's accounts, its people, and its
42
+ * conventions, so it is exactly as private as the knowledge it operates on.
43
+ * ADR-030 has the argument; ADR-009's "profiles share nothing" is what it
44
+ * restores.
45
+ *
46
+ * The `.d` suffix is the dot rule above rather than decoration. A plain
47
+ * `data/<profile>/skills/` is precisely the namespace the skills provider's own
48
+ * blobs scope into, so the one name that could not be used is the obvious one.
49
+ *
50
+ * These are **defaults**. A profile that declares its own paths keeps them.
51
+ *
52
+ * There is no migration from the layout this replaced, deliberately: a
53
+ * workspace is profiles, credentials, and whatever the owner has stored, and
54
+ * re-creating one is `lanes link profile add` plus `lanes link connect` per account. Machinery
55
+ * to move an old one would be more code than the thing it moves, and it would
56
+ * have to keep working forever to be worth having. Skills and manifests left at
57
+ * the old workspace-root paths are the same case: they stop loading, and moving
58
+ * them is one `mv` per profile that should see them.
59
+ */
60
+
61
+ /** The directory under the workspace root that holds every profile's data. */
62
+ export const DATA_DIR = 'data';
63
+
64
+ /**
65
+ * Everything one profile owns, relative to the workspace root.
66
+ *
67
+ * No leading `./`. It used to carry one, which `path.resolve` discards and an
68
+ * object key does not: a bucket read `./data/personal/state.kv/x` as a
69
+ * directory literally named `.`, so every deployed key landed one level away
70
+ * from where the config said it did. The visible cost was that the conditioned
71
+ * IAM binding `deployments/gcp/provision.ts` writes — which grants writes under
72
+ * `objects/data/` — matched nothing, and the first revision 403'd on its boot
73
+ * reconcile. A relative path is relative without being spelled that way.
74
+ */
75
+ export function profileDir(profile: string): string {
76
+ return `${DATA_DIR}/${profile}`;
77
+ }
78
+
79
+ export const layout = {
80
+ /**
81
+ * Connections, provider state, and cursors: one object per key.
82
+ *
83
+ * The dot is load-bearing, exactly as it is for `audit` below — a provider
84
+ * is namespaced `<provider>/<connection>` under `blobs`, and a provider id
85
+ * is `[a-z][a-z0-9_]*`, so a name carrying a dot is one no provider can be
86
+ * scoped into.
87
+ */
88
+ state: (profile: string): string => `${profileDir(profile)}/state.kv`,
89
+ /** System credentials — OAuth tokens, the profile token. Never reachable from MCP. */
90
+ credentials: (profile: string): string => `${profileDir(profile)}/credentials.enc`,
91
+ /** The owner's own items, under their own key. */
92
+ vault: (profile: string): string => `${profileDir(profile)}/vault.enc`,
93
+ /**
94
+ * This profile's skills — `<name>.md` or `<name>/SKILL.md`, either layout.
95
+ *
96
+ * The dot carries the same weight it does for `state` and `audit`, and here
97
+ * it is not hypothetical: `skills` is a real provider id, so `skills/` under
98
+ * the blob root is the prefix its own connection blobs would scope into.
99
+ */
100
+ skills: (profile: string): string => `${profileDir(profile)}/skills.d`,
101
+ /**
102
+ * The provider manifests this profile declares.
103
+ *
104
+ * Per profile for the same reason a connection is. A manifest names a host,
105
+ * an OpenAPI document, and the credential refs that reach them — which is a
106
+ * description of somebody's infrastructure, and work's is not personal's to
107
+ * read.
108
+ */
109
+ providers: (profile: string): string => `${profileDir(profile)}/providers.d`,
110
+ /** The blob root every provider is namespaced under. */
111
+ blobs: (profile: string): string => profileDir(profile),
112
+ /**
113
+ * The audit log: one object per event, under the profile's blob root.
114
+ *
115
+ * The dot is doing real work. A provider is namespaced to
116
+ * `<provider>/<connection>` under `blobs` above, and a provider id is
117
+ * `[a-z][a-z0-9_]*` — so a name carrying a dot is one no provider can be
118
+ * scoped to. Without it, a provider called `audit` would be handed a store
119
+ * rooted inside the log, which is a hole in ADR-007's wall rather than an
120
+ * untidy filename.
121
+ */
122
+ audit: (profile: string): string => `${profileDir(profile)}/audit.log`,
123
+ } as const;