@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,206 @@
1
+ import { hashToken } from './oauth/store.ts';
2
+
3
+ /**
4
+ * Verifying a token an issuer you already run handed out.
5
+ *
6
+ * The alternative to this endpoint issuing its own. Named for the protocol and
7
+ * not for any vendor, on the same reasoning that makes the database adapter
8
+ * `s3` and not `supabase` (ADR-013): the issuer is a URL, so pointing this
9
+ * at a different provider is an edit to a config file rather than a branch in
10
+ * the code.
11
+ *
12
+ * Three checks, and dropping any of them makes the other two decorative:
13
+ *
14
+ * - **Audience.** The token must name this application. Without it, a token the
15
+ * same issuer minted for any *other* application it serves would open this
16
+ * endpoint — the confused-deputy case the MCP authorization spec calls out.
17
+ * Every large issuer serves many applications, so this is the check that
18
+ * matters most and the one most easily left out, because everything works
19
+ * without it.
20
+ * - **Expiry**, so a token that has been revoked by lapsing actually stops.
21
+ * - **Subject**, against an allowlist. The issuer will happily vouch for every
22
+ * account it has; which of them is *you* is not something it knows.
23
+ */
24
+
25
+ export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
26
+
27
+ export interface OidcVerifierOptions {
28
+ readonly issuer: string;
29
+ /** The audience a token must name — this application's client id at the issuer. */
30
+ readonly audience: string;
31
+ readonly allowedSubjects: readonly string[];
32
+ /**
33
+ * Where to ask about a token, when the issuer's metadata does not say.
34
+ *
35
+ * Access tokens are frequently opaque, so validating one means asking the
36
+ * issuer. RFC 7662 standardised that as `introspection_endpoint` in the
37
+ * discovery document — and several large issuers ship an equivalent without
38
+ * advertising one, so this is the escape hatch for those. A URL in config, not
39
+ * a case in a switch.
40
+ */
41
+ readonly introspectionEndpoint?: string | undefined;
42
+ /**
43
+ * Narrower than `typeof fetch` on purpose.
44
+ *
45
+ * The runtime's own type carries extensions — `preconnect` among them — that
46
+ * nothing here calls, and requiring them would mean every test stub had to
47
+ * implement them to be assignable.
48
+ */
49
+ readonly fetch?: FetchLike;
50
+ readonly now?: () => number;
51
+ /** How long a positive answer is reused. Zero disables the cache. */
52
+ readonly cacheTtlMs?: number;
53
+ }
54
+
55
+ export interface VerifiedSubject {
56
+ readonly subject: string;
57
+ readonly expiresAt: number;
58
+ }
59
+
60
+ interface Introspection {
61
+ readonly active: boolean;
62
+ readonly audiences: readonly string[];
63
+ readonly subjects: readonly string[];
64
+ readonly expiresAt: number | null;
65
+ }
66
+
67
+ /** A minute. Long enough to spare a round trip per call, short enough to notice. */
68
+ const DEFAULT_CACHE_TTL_MS = 60_000;
69
+
70
+ export class OidcVerifier {
71
+ readonly #options: OidcVerifierOptions;
72
+ readonly #fetch: FetchLike;
73
+ readonly #now: () => number;
74
+ readonly #cache = new Map<string, VerifiedSubject>();
75
+ #discovered: Promise<Record<string, unknown>> | null = null;
76
+
77
+ constructor(options: OidcVerifierOptions) {
78
+ this.#options = options;
79
+ this.#fetch = options.fetch ?? fetch;
80
+ this.#now = options.now ?? Date.now;
81
+ }
82
+
83
+ /** The subject this token belongs to, or null if it does not open this endpoint. */
84
+ async verify(token: string): Promise<VerifiedSubject | null> {
85
+ const key = hashToken(token);
86
+ const cached = this.#cache.get(key);
87
+ if (cached && cached.expiresAt > this.#now()) return cached;
88
+ this.#cache.delete(key);
89
+
90
+ const introspection = await this.#introspect(token);
91
+ if (!introspection?.active) return null;
92
+
93
+ if (!introspection.audiences.includes(this.#options.audience)) return null;
94
+ if (introspection.expiresAt !== null && introspection.expiresAt <= this.#now()) return null;
95
+
96
+ const subject = introspection.subjects.find((candidate) =>
97
+ this.#options.allowedSubjects.includes(candidate),
98
+ );
99
+ if (!subject) return null;
100
+
101
+ const ttl = this.#options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
102
+ const verified: VerifiedSubject = {
103
+ subject,
104
+ // Never cached past the token's own expiry, however generous the TTL.
105
+ expiresAt: Math.min(this.#now() + ttl, introspection.expiresAt ?? Infinity),
106
+ };
107
+ if (ttl > 0) this.#cache.set(key, verified);
108
+ return verified;
109
+ }
110
+
111
+ async #endpoint(): Promise<string | null> {
112
+ if (this.#options.introspectionEndpoint) return this.#options.introspectionEndpoint;
113
+
114
+ this.#discovered ??= this.#fetch(
115
+ `${this.#options.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`,
116
+ { signal: AbortSignal.timeout(5_000) },
117
+ )
118
+ .then((response) => (response.ok ? response.json() : {}))
119
+ .catch(() => ({}));
120
+
121
+ const metadata = await this.#discovered;
122
+ const endpoint = metadata['introspection_endpoint'];
123
+ return typeof endpoint === 'string' ? endpoint : null;
124
+ }
125
+
126
+ /**
127
+ * Two request shapes, because two are in the wild.
128
+ *
129
+ * RFC 7662 is a form POST of `token`. The other common spelling is a GET with
130
+ * the token in the query string, which several issuers ship instead. Trying
131
+ * the standard one first and the other on failure covers both without either
132
+ * being named in a conditional.
133
+ */
134
+ async #introspect(token: string): Promise<Introspection | null> {
135
+ const endpoint = await this.#endpoint();
136
+ if (!endpoint) {
137
+ // Fail closed and say why. Falling back to a check that cannot see the
138
+ // audience would leave the confused-deputy hole open while looking like
139
+ // it verified something.
140
+ throw new Error(
141
+ `The issuer ${this.#options.issuer} publishes no introspection_endpoint. ` +
142
+ 'Set auth.authorization.introspection_endpoint to the URL that answers ' +
143
+ 'questions about a token, or this endpoint cannot check who a token was issued to.',
144
+ );
145
+ }
146
+
147
+ const posted = await this.#ask(endpoint, {
148
+ method: 'POST',
149
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
150
+ body: new URLSearchParams({ token }).toString(),
151
+ });
152
+ if (posted) return posted;
153
+
154
+ const url = new URL(endpoint);
155
+ url.searchParams.set('access_token', token);
156
+ return this.#ask(url.toString(), { method: 'GET' });
157
+ }
158
+
159
+ async #ask(url: string, init: RequestInit): Promise<Introspection | null> {
160
+ try {
161
+ const response = await this.#fetch(url, { ...init, signal: AbortSignal.timeout(5_000) });
162
+ if (!response.ok) return null;
163
+ return normalise((await response.json()) as Record<string, unknown>, this.#now());
164
+ } catch {
165
+ return null;
166
+ }
167
+ }
168
+ }
169
+
170
+ /**
171
+ * One shape out of several spellings of the same three facts.
172
+ *
173
+ * `aud` may be a string or an array, and some issuers put the client id in
174
+ * `azp` instead. Expiry arrives as an absolute `exp` in seconds or as a relative
175
+ * `expires_in`. Identity is `sub`, and an issuer that also asserts a *verified*
176
+ * email is asserting something an operator would rather write in an allowlist
177
+ * than a numeric id — so both are offered, and an unverified one never is.
178
+ */
179
+ function normalise(body: Record<string, unknown>, now: number): Introspection {
180
+ const strings = (value: unknown): string[] =>
181
+ typeof value === 'string' ? [value] : Array.isArray(value) ? value.filter((v) => typeof v === 'string') : [];
182
+
183
+ const expiresAt =
184
+ typeof body['exp'] === 'number'
185
+ ? body['exp'] * 1000
186
+ : typeof body['expires_in'] === 'number'
187
+ ? now + body['expires_in'] * 1000
188
+ : typeof body['expires_in'] === 'string' && /^\d+$/.test(body['expires_in'])
189
+ ? now + Number(body['expires_in']) * 1000
190
+ : null;
191
+
192
+ const emailVerified = body['email_verified'];
193
+ const verified = emailVerified === true || emailVerified === 'true';
194
+
195
+ return {
196
+ // RFC 7662 says `active`. A 200 from an endpoint that does not use the field
197
+ // is itself the answer, so its absence is not treated as inactive.
198
+ active: body['active'] !== false,
199
+ audiences: [...strings(body['aud']), ...strings(body['azp'])],
200
+ subjects: [
201
+ ...strings(body['sub']),
202
+ ...(verified ? strings(body['email']) : []),
203
+ ],
204
+ expiresAt,
205
+ };
206
+ }
@@ -0,0 +1,72 @@
1
+ import { ownerPrincipal, parseBearer, type AuthOutcome, type Authenticator } from './index.ts';
2
+ import type { OAuthStore } from './oauth/store.ts';
3
+ import type { OidcVerifier } from './oidc.ts';
4
+
5
+ /**
6
+ * The two ways a remote client's token becomes a principal.
7
+ *
8
+ * Both resolve to the **owner** principal, and that is a deliberate limit rather
9
+ * than an omission. There is one person behind this endpoint; what a caller may
10
+ * do is decided by the profile's policy per capability per call, not by which
11
+ * credential opened the door. Delegated principals are additive later — the
12
+ * dispatch path already takes a principal rather than assuming the owner — and
13
+ * inventing a second kind now would mean inventing the policy axis to go with
14
+ * it before anything needed one.
15
+ *
16
+ * Neither of these ever reports `missing` for a credential it simply does not
17
+ * recognise. That is what the chain's ranking is for: a token this link cannot
18
+ * place is `invalid` from its point of view and `missing` only if no link saw
19
+ * anything at all.
20
+ */
21
+
22
+ /** A token this endpoint issued through its own authorization flow. */
23
+ export class IssuedTokenAuthenticator implements Authenticator {
24
+ readonly #store: OAuthStore;
25
+ readonly #profile: string;
26
+
27
+ constructor(store: OAuthStore, profile: string) {
28
+ this.#store = store;
29
+ this.#profile = profile;
30
+ }
31
+
32
+ async authenticate(header: string | null | undefined): Promise<AuthOutcome> {
33
+ const presented = parseBearer(header);
34
+ if (presented === null) return { ok: false, reason: header ? 'malformed' : 'missing' };
35
+
36
+ const record = await this.#store.token(presented);
37
+ // `kind` matters: a refresh token is a credential for the token endpoint and
38
+ // must not open the resource. They are indistinguishable as strings, so the
39
+ // check is the only thing separating them.
40
+ if (!record || record.kind !== 'access') return { ok: false, reason: 'invalid' };
41
+
42
+ return { ok: true, principal: ownerPrincipal(this.#profile) };
43
+ }
44
+ }
45
+
46
+ /** A token an external issuer minted, verified against that issuer. */
47
+ export class OidcAuthenticator implements Authenticator {
48
+ readonly #verifier: OidcVerifier;
49
+ readonly #profile: string;
50
+
51
+ constructor(verifier: OidcVerifier, profile: string) {
52
+ this.#verifier = verifier;
53
+ this.#profile = profile;
54
+ }
55
+
56
+ async authenticate(header: string | null | undefined): Promise<AuthOutcome> {
57
+ const presented = parseBearer(header);
58
+ if (presented === null) return { ok: false, reason: header ? 'malformed' : 'missing' };
59
+
60
+ try {
61
+ const verified = await this.#verifier.verify(presented);
62
+ return verified
63
+ ? { ok: true, principal: ownerPrincipal(this.#profile) }
64
+ : { ok: false, reason: 'invalid' };
65
+ } catch {
66
+ // The issuer being unreachable, or misconfigured, is not an authorisation.
67
+ // Failing closed here means an outage at the identity provider closes the
68
+ // endpoint rather than opening it.
69
+ return { ok: false, reason: 'invalid' };
70
+ }
71
+ }
72
+ }
@@ -0,0 +1,94 @@
1
+ import type { GlobalFlags } from './runtime.ts';
2
+ import type { OwnerFlags } from './commands/owner.ts';
3
+
4
+ /**
5
+ * Turning argv into a command path and a flag bag.
6
+ *
7
+ * Hand-rolled rather than delegated to a framework: the grammar is small, and a
8
+ * dependency that parses argv in a process that also holds credential-store
9
+ * keys is not worth the convenience.
10
+ *
11
+ * Separate from `main.ts` so it can be tested. While the parser lived inside
12
+ * the bin entry it was unreachable — importing the module ran the CLI.
13
+ */
14
+
15
+ export type Flags = Record<string, string | boolean>;
16
+
17
+ export interface Parsed {
18
+ readonly command: string[];
19
+ readonly flags: Flags;
20
+ }
21
+
22
+ export function parseArgv(argv: readonly string[]): Parsed {
23
+ const command: string[] = [];
24
+ const flags: Flags = {};
25
+
26
+ for (let index = 0; index < argv.length; index++) {
27
+ const argument = argv[index]!;
28
+
29
+ if (!argument.startsWith('--')) {
30
+ command.push(argument);
31
+ continue;
32
+ }
33
+
34
+ const body = argument.slice(2);
35
+ const equals = body.indexOf('=');
36
+
37
+ if (equals !== -1) {
38
+ flags[body.slice(0, equals)] = body.slice(equals + 1);
39
+ continue;
40
+ }
41
+
42
+ const next = argv[index + 1];
43
+ if (next !== undefined && !next.startsWith('--')) {
44
+ flags[body] = next;
45
+ index++;
46
+ } else {
47
+ flags[body] = true;
48
+ }
49
+ }
50
+
51
+ return { command, flags };
52
+ }
53
+
54
+ /**
55
+ * A flag's value, when it was given one.
56
+ *
57
+ * `--profile` with nothing after it parses to `true`, which is not a profile
58
+ * name — so every string-valued flag has to check, and this is that check
59
+ * written once.
60
+ */
61
+ export function text(flags: Flags, name: string): string | undefined {
62
+ const value = flags[name];
63
+ return typeof value === 'string' ? value : undefined;
64
+ }
65
+
66
+ /** The flags every command accepts. */
67
+ export function globalFlags(flags: Flags): GlobalFlags {
68
+ return {
69
+ profile: text(flags, 'profile'),
70
+ target: text(flags, 'target'),
71
+ quiet: flags['quiet'] === true,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * The owner layer's flags — the global set plus what `memory`, `skills` and
77
+ * `vault` add.
78
+ *
79
+ * The kebab-case spellings live here and nowhere else, so the one place that
80
+ * knows `--display-name` is the one place that parses argv.
81
+ */
82
+ export function ownerFlags(flags: Flags): OwnerFlags {
83
+ return {
84
+ ...globalFlags(flags),
85
+ show: flags['show'] === true,
86
+ raw: flags['raw'] === true,
87
+ connection: text(flags, 'connection'),
88
+ title: text(flags, 'title'),
89
+ tag: text(flags, 'tag'),
90
+ description: text(flags, 'description'),
91
+ file: text(flags, 'file'),
92
+ yes: flags['yes'] === true,
93
+ };
94
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * The page the browser lands on when a connect flow ends.
3
+ *
4
+ * It is the only HTML this repository serves to a human, and for a few seconds
5
+ * it is the whole product — so it is the product's own page rather than a
6
+ * bespoke one. The design is lifted from the pages the Lanes API serves after
7
+ * someone authorises something (the workspace invite and the email
8
+ * verification): an outline-only card, a Lora heading, native text softened
9
+ * with opacity, and the shared one-row footer.
10
+ *
11
+ * Two decisions are worth keeping:
12
+ *
13
+ * - **Nothing is painted.** `color-scheme: light dark` plus a transparent
14
+ * background means the browser supplies its own canvas, so the page is
15
+ * correct in either mode without a theme switch, a media query per colour, or
16
+ * a preference to read. It replaced a hardcoded `#0d1117`, which was a black
17
+ * rectangle on a light machine.
18
+ * - **Colour is reserved for status.** Not for emphasis, and not for the
19
+ * heading — a green *word* said nothing the word "Connected" did not, and
20
+ * that is why it went. A check is different: it is the glyph the Lanes app
21
+ * already puts against a connected integration, so a reader arriving from the
22
+ * app meets the mark they left it with. Both outcomes carry one now, the
23
+ * check on success and the red heading on failure, and nothing else does.
24
+ *
25
+ * Lora is fetched from Google Fonts, as the API pages do; the fallback stack
26
+ * carries the page on a machine with no network, which is the state a failed
27
+ * authorisation is sometimes in.
28
+ */
29
+
30
+ export interface CallbackPage {
31
+ /** The focal line, set in Lora. A provider name, or the outcome itself. */
32
+ readonly heading: string;
33
+ /** The small line above it — "Connected" over the name of what was. */
34
+ readonly label?: string;
35
+ /** What the reader should do next. */
36
+ readonly detail: string;
37
+ readonly ok: boolean;
38
+ }
39
+
40
+ /**
41
+ * Escape text for an HTML body.
42
+ *
43
+ * `escapeXml` in `connectivity/transports/dav/xml.ts` would do, and `cli` is
44
+ * allowed to import `connectivity` — but a page renderer reaching into a DAV
45
+ * transport for a string function is a dependency nobody would defend. Five
46
+ * lines is cheaper than the coupling.
47
+ *
48
+ * This is load-bearing: `heading` carries a provider's `manifest.name`, and a
49
+ * custom provider supplies that from a YAML file of its own.
50
+ */
51
+ function escapeHtml(value: string): string {
52
+ return value
53
+ .replace(/&/g, '&amp;')
54
+ .replace(/</g, '&lt;')
55
+ .replace(/>/g, '&gt;')
56
+ .replace(/"/g, '&quot;')
57
+ .replace(/'/g, '&#39;');
58
+ }
59
+
60
+ /**
61
+ * Harmonised with the Lanes API's public pages, which are themselves harmonised
62
+ * with the transactional emails — same dashed card, same Lora, same footer, so
63
+ * the end of a connect flow reads as the same product as the invite that
64
+ * preceded it.
65
+ */
66
+ const STYLE = `
67
+ :root { color-scheme: light dark; }
68
+ * { box-sizing: border-box; }
69
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
70
+ display: flex; align-items: center; justify-content: center;
71
+ min-height: 100vh; margin: 0; background: transparent; padding: 24px; }
72
+ .wrap { width: 100%; max-width: 460px; text-align: center; }
73
+ .card { background: transparent; border: 1px dashed rgba(120,113,108,0.35); border-radius: 10px;
74
+ padding: 48px 36px; }
75
+ .label { margin: 0 0 6px; font-size: 15px; font-weight: 500; letter-spacing: 0.01em; opacity: 0.6; }
76
+ h1 { font-family: "Lora", Georgia, "Times New Roman", serif; font-size: 28px; font-weight: 500;
77
+ letter-spacing: -0.01em; line-height: 1.3; margin: 0 0 18px; }
78
+ .detail { margin: 0; font-size: 15px; line-height: 1.6; opacity: 0.75; }
79
+ .icon { display: block; margin: 0 auto 18px; color: #059669; }
80
+ .footer { margin: 24px 0 0; }
81
+ .footer p { margin: 0; font-size: 12px; line-height: 1.5; opacity: 0.5; }
82
+ .footer a { color: inherit; text-decoration: underline; }
83
+ .err h1 { color: #A06060; }
84
+ @media (prefers-color-scheme: dark) {
85
+ .err h1 { color: #C08080; }
86
+ .icon { color: #34D399; }
87
+ }
88
+ `.trim();
89
+
90
+ /** The one-row footer below the card, identical to the one the API pages carry. */
91
+ const FOOTER =
92
+ '<div class="footer"><p>Your unfair advantage in parallel AI coding. ' +
93
+ '<a href="https://lanes.sh/">lanes.sh</a></p></div>';
94
+
95
+ /**
96
+ * The success mark, above the label.
97
+ *
98
+ * The glyph and the emerald are the Lanes app's, so a connected provider looks
99
+ * the same wherever it is reported — lucide's `check` at #059669, lightened to
100
+ * #34D399 on a dark canvas because the darker green does not survive it.
101
+ *
102
+ * `aria-hidden` because the word underneath is the announcement; a screen
103
+ * reader hearing "Connected" does not also need to hear about a tick.
104
+ */
105
+ const ICON =
106
+ '<svg class="icon" xmlns="http://www.w3.org/2000/svg" width="28" height="28" ' +
107
+ 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
108
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
109
+ '<path d="M20 6 9 17l-5-5"/></svg>';
110
+
111
+ export interface ApprovalPage {
112
+ /** What is asking. A client's self-reported name, or a stand-in. */
113
+ readonly client: string;
114
+ /** Where the code would be sent. The part of the request that cannot be faked. */
115
+ readonly redirectHost: string;
116
+ /** Hidden fields carrying the authorization request through the POST. */
117
+ readonly fields: Readonly<Record<string, string>>;
118
+ readonly action: string;
119
+ /** A previous attempt presented the wrong token. */
120
+ readonly retry: boolean;
121
+ /** The target this endpoint runs as, so the hint below names the right store. */
122
+ readonly target: string;
123
+ }
124
+
125
+ /**
126
+ * The one screen a remote client's authorization stops at.
127
+ *
128
+ * Same card as the page above, because it is the same product and the reader
129
+ * arrived here from a connector rather than from a terminal. What it asks for is
130
+ * the endpoint token — the string `lanes link outputs` prints — because that is
131
+ * already the proof of being the owner and inventing a second one would mean
132
+ * inventing a password to go with it.
133
+ *
134
+ * The hint names the target rather than leaving it out. Credentials are
135
+ * per-target, and the reader runs that command in a shell resolving a target of
136
+ * its own — `local` by default, which is the one store a deployed endpoint's
137
+ * token is never in. Omitting it sends them to fetch the wrong secret, and
138
+ * `outputs` mints a fresh one when that store is empty rather than saying so.
139
+ *
140
+ * `autocomplete="off"` and `type="password"` are not theatre: this form is
141
+ * submitted in whatever browser the phone opened, and a token remembered by a
142
+ * shared browser is a token in the hands of whoever borrows the phone.
143
+ */
144
+ export function approvalPage(page: ApprovalPage): Response {
145
+ const hidden = Object.entries(page.fields)
146
+ .map(([name, value]) => `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`)
147
+ .join('\n');
148
+
149
+ // The name is self-reported and the host is not, so the host is what the
150
+ // reader is asked to recognise. Registration is open by design, which means
151
+ // anything can call itself anything — but an impostor still has to nominate
152
+ // somewhere for the code to go, and that is on the screen.
153
+ const body = `
154
+ ${page.retry ? '<p class="label err-text">That token was not accepted.</p>\n' : ''}<h1>Authorise ${escapeHtml(page.client)}?</h1>
155
+ <p class="detail">It will be sent back to <strong>${escapeHtml(page.redirectHost)}</strong>, and will be able to reach every profile this endpoint serves, within the policy each one declares.</p>
156
+ <form method="post" action="${escapeHtml(page.action)}">
157
+ ${hidden}
158
+ <input class="field" type="password" name="token" placeholder="Endpoint token" autocomplete="off" autofocus required>
159
+ <button class="go" type="submit">Approve</button>
160
+ </form>
161
+ <p class="detail small">Printed by <code>lanes link outputs --show --target ${escapeHtml(page.target)}</code>.</p>`;
162
+
163
+ return page.retry ? shell(body, 'Authorise', 401) : shell(body, 'Authorise', 200);
164
+ }
165
+
166
+ function shell(inner: string, title: string, status: number): Response {
167
+ return new Response(
168
+ `<!doctype html>
169
+ <html lang="en">
170
+ <head>
171
+ <meta charset="utf-8">
172
+ <meta name="viewport" content="width=device-width, initial-scale=1">
173
+ <link rel="preconnect" href="https://fonts.googleapis.com">
174
+ <link href="https://fonts.googleapis.com/css2?family=Lora:wght@400;500&amp;display=swap" rel="stylesheet">
175
+ <title>${escapeHtml(title)}</title>
176
+ <style>
177
+ ${STYLE}
178
+ ${FORM_STYLE}
179
+ </style>
180
+ </head>
181
+ <body>
182
+ <div class="wrap">
183
+ <div class="card">
184
+ ${inner}
185
+ </div>
186
+ ${FOOTER}
187
+ </div>
188
+ </body>
189
+ </html>`,
190
+ {
191
+ status,
192
+ headers: {
193
+ 'content-type': 'text/html; charset=utf-8',
194
+ // One of these pages asks for the endpoint token in a password field.
195
+ // Framing it is the cheap half of a UI-redress attack, and nothing here
196
+ // is ever meant to be embedded — so refuse it in both spellings, since
197
+ // `frame-ancestors` is the one that is actually specified and
198
+ // `X-Frame-Options` is the one older browsers obey.
199
+ 'content-security-policy': "frame-ancestors 'none'",
200
+ 'x-frame-options': 'DENY',
201
+ // The URL carries a `client_id` and a `redirect_uri`; neither belongs in
202
+ // a Referer sent to whatever the page links out to.
203
+ 'referrer-policy': 'no-referrer',
204
+ },
205
+ },
206
+ );
207
+ }
208
+
209
+ const FORM_STYLE = `
210
+ .field { width: 100%; margin: 20px 0 12px; padding: 11px 13px; font: inherit; font-size: 15px;
211
+ color: inherit; background: transparent; border: 1px solid rgba(120,113,108,0.45);
212
+ border-radius: 7px; }
213
+ .field:focus { outline: none; border-color: rgba(120,113,108,0.9); }
214
+ .go { width: 100%; padding: 11px 13px; font: inherit; font-size: 15px; font-weight: 500;
215
+ color: inherit; background: transparent; border: 1px solid rgba(120,113,108,0.55);
216
+ border-radius: 7px; cursor: pointer; }
217
+ .go:hover { border-color: rgba(120,113,108,0.95); }
218
+ .small { margin-top: 14px; font-size: 12px; opacity: 0.55; }
219
+ .small code { font-size: 12px; }
220
+ .err-text { color: #A06060; opacity: 1; }
221
+ @media (prefers-color-scheme: dark) { .err-text { color: #C08080; } }
222
+ `.trim();
223
+
224
+ export function completionPage(page: CallbackPage): Response {
225
+ const { heading, label, detail, ok } = page;
226
+
227
+ // The tab strip truncates to a few characters, and the word that has to
228
+ // survive that is the outcome — not the name of what was connected.
229
+ const title = escapeHtml(label ?? heading);
230
+
231
+ return new Response(
232
+ `<!doctype html>
233
+ <html lang="en">
234
+ <head>
235
+ <meta charset="utf-8">
236
+ <meta name="viewport" content="width=device-width, initial-scale=1">
237
+ <link rel="preconnect" href="https://fonts.googleapis.com">
238
+ <link href="https://fonts.googleapis.com/css2?family=Lora:wght@400;500&amp;display=swap" rel="stylesheet">
239
+ <title>${title}</title>
240
+ <style>
241
+ ${STYLE}
242
+ </style>
243
+ </head>
244
+ <body>
245
+ <div class="wrap">
246
+ <div class="card${ok ? '' : ' err'}">
247
+ ${ok ? `${ICON}\n` : ''}${label ? `<p class="label">${escapeHtml(label)}</p>\n` : ''}<h1>${escapeHtml(heading)}</h1>
248
+ <p class="detail">${escapeHtml(detail)}</p>
249
+ </div>
250
+ ${FOOTER}
251
+ </div>
252
+ </body>
253
+ </html>`,
254
+ { status: ok ? 200 : 400, headers: { 'content-type': 'text/html; charset=utf-8' } },
255
+ );
256
+ }