@lanes-sh/link 0.3.2 → 0.4.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 (68) hide show
  1. package/README.md +11 -3
  2. package/instructions/agents/lanes-link-scout.md +2 -2
  3. package/instructions/skills/lanes-link/SKILL.md +135 -11
  4. package/package.json +3 -1
  5. package/src/cli/argv.ts +52 -0
  6. package/src/cli/commands/connect/authorise.ts +5 -0
  7. package/src/cli/commands/connect/custom/ask.ts +167 -0
  8. package/src/cli/commands/connect/custom/credential.ts +143 -0
  9. package/src/cli/commands/connect/custom/derive.ts +229 -0
  10. package/src/cli/commands/connect/custom/index.ts +285 -0
  11. package/src/cli/commands/connect/custom/prompts.ts +160 -0
  12. package/src/cli/commands/connect/custom/spec.ts +293 -0
  13. package/src/cli/commands/connect/custom/values.ts +53 -0
  14. package/src/cli/commands/connect/custom/write.ts +166 -0
  15. package/src/cli/commands/connect/grant.ts +27 -0
  16. package/src/cli/commands/connect/index.ts +24 -27
  17. package/src/cli/commands/connect/outcome.ts +3 -1
  18. package/src/cli/commands/connect/requirements.ts +11 -1
  19. package/src/cli/commands/connect/settle.ts +17 -0
  20. package/src/cli/commands/connect/setup.ts +9 -1
  21. package/src/cli/commands/connect/strategy.ts +87 -0
  22. package/src/cli/commands/connect/unknown.ts +41 -0
  23. package/src/cli/identity.ts +29 -5
  24. package/src/cli/main.ts +37 -13
  25. package/src/cli/oauth.ts +89 -36
  26. package/src/cli/runtime/open.ts +13 -1
  27. package/src/cli/runtime/registry.ts +12 -0
  28. package/src/cli/selection.ts +12 -0
  29. package/src/cli/usage.ts +9 -1
  30. package/src/connectivity/auth/README.md +8 -1
  31. package/src/connectivity/auth/strategy/index.ts +128 -4
  32. package/src/connectivity/connector.ts +11 -0
  33. package/src/connectivity/index.ts +11 -1
  34. package/src/connectivity/manifest/auth.ts +19 -0
  35. package/src/connectivity/manifest/connector.ts +21 -0
  36. package/src/connectivity/manifest/primitives.ts +5 -1
  37. package/src/connectivity/manifest/provider.ts +30 -12
  38. package/src/connectivity/provider.ts +55 -0
  39. package/src/connectivity/transports/factory.ts +1 -0
  40. package/src/connectivity/transports/http/index.ts +73 -2
  41. package/src/dispatch/dispatch.ts +44 -5
  42. package/src/providers/bunq/hints.ts +43 -0
  43. package/src/providers/bunq/index.ts +87 -0
  44. package/src/providers/bunq/redact.ts +64 -0
  45. package/src/providers/bunq/specs/bunq.v1.json +864 -0
  46. package/src/providers/bunq/specs/vendor.ts +338 -0
  47. package/src/providers/bunq/strategy/handshake.ts +211 -0
  48. package/src/providers/bunq/strategy/index.ts +298 -0
  49. package/src/providers/bunq/strategy/keys.ts +72 -0
  50. package/src/providers/custom/index.ts +1 -6
  51. package/src/providers/custom/load.ts +56 -14
  52. package/src/providers/custom/template.ts +1 -1
  53. package/src/providers/discord/hints.ts +195 -0
  54. package/src/providers/discord/index.ts +121 -0
  55. package/src/providers/discord/redact.ts +99 -0
  56. package/src/providers/discord/specs/discord.v10.json +2333 -0
  57. package/src/providers/discord/specs/vendor.ts +164 -0
  58. package/src/providers/google/specs/vendor.ts +32 -317
  59. package/src/providers/index.ts +9 -0
  60. package/src/providers/reddit/index.ts +113 -0
  61. package/src/providers/reddit/oauth.ts +77 -0
  62. package/src/providers/reddit/redact.ts +33 -0
  63. package/src/providers/reddit/scopes.ts +27 -0
  64. package/src/providers/reddit/specs/reddit.v1.json +700 -0
  65. package/src/providers/scopes.ts +2 -0
  66. package/src/providers/shared/openapi.ts +155 -0
  67. package/src/providers/shared/vendor-operations.ts +98 -0
  68. package/src/providers/shared/vendor-spec.ts +309 -0
@@ -27,6 +27,7 @@ export async function settleIdentity(input: {
27
27
  credentials: SecretStore;
28
28
  registry: { manifest(id: string): ProviderManifest | undefined };
29
29
  connectorFor(providerId: string, connectionId: string): AnyConnector | undefined;
30
+ authorizeRequest(providerId: string, connectionId: string, request: Request): Promise<Request>;
30
31
  };
31
32
  prompter?: Prompter;
32
33
  }): Promise<{ connectionId: string; account: string }> {
@@ -51,8 +52,24 @@ export async function settleIdentity(input: {
51
52
  if (!account) {
52
53
  const token = () => bearerTokenAsStored(manifest, provisionalId, runtime.credentials);
53
54
 
55
+ // Only where `accessToken` cannot answer: `requestAuthorizer` goes through
56
+ // `credentialResolver`, which may *refresh* an OAuth token, and
57
+ // `bearerTokenAsStored` is deliberately the connect-time variant that does
58
+ // not. Leaving oauth and bearer on the token keeps refresh behaviour
59
+ // exactly as it was; these three had no working path at all.
60
+ const carriesItsOwnHeader =
61
+ manifest.auth.kind === 'api_key' ||
62
+ manifest.auth.kind === 'header' ||
63
+ manifest.auth.kind === 'basic';
64
+
54
65
  account = await resolveAccount(manifest, {
55
66
  accessToken: token,
67
+ ...(carriesItsOwnHeader
68
+ ? {
69
+ authorize: (request: Request) =>
70
+ runtime.authorizeRequest(manifest.id, provisionalId, request),
71
+ }
72
+ : {}),
56
73
  // A protocol that authenticates by username has nothing to GET and no
57
74
  // tool to call — it knows, once the server has accepted the login.
58
75
  identify: async () =>
@@ -145,7 +145,15 @@ export async function ensureStaticCredential(input: {
145
145
  const { manifest, connectionId, credentials, replace, provisional } = input;
146
146
  const prompter = input.prompter ?? terminalPrompter;
147
147
  const auth = manifest.auth;
148
- if (auth.kind === 'none' || auth.kind === 'oauth' || auth.kind === 'strategy') return;
148
+ // `strategy` used to be excluded here, from when no strategy existed and the
149
+ // kind was unreachable. It is exactly the wrong exclusion: a strategy
150
+ // provider's credential is *more* of a pasted value than most — bunq's API
151
+ // key is the input its handshake runs on — and skipping the prompt left
152
+ // `lanes link connect bunq` storing nothing and then failing inside the
153
+ // handshake with "no API key was stored", on the interactive path the setup
154
+ // documentation describes. Everything below is generic: the ref derives, the
155
+ // prompts come from the manifest.
156
+ if (auth.kind === 'none' || auth.kind === 'oauth') return;
149
157
 
150
158
  const ref = credentialRefForConnection(manifest, connectionId)!;
151
159
  const stored = await credentials.has(ref);
@@ -0,0 +1,87 @@
1
+ import { credentialRefForConnection, strategyContextFrom, strategyFor } from '#connectivity';
2
+ import type { ProviderManifest } from '#connectivity';
3
+ import { createScopedStore, scopeNamespace } from '#dispatch';
4
+ import { scopeSecrets } from '#secrets';
5
+ import type { SecretStore } from '#secrets';
6
+ import type { RuntimeState } from '#stores/state';
7
+ import type { ProviderRegistry } from '#registry';
8
+ import { progress, style } from '../../output.ts';
9
+
10
+ /**
11
+ * The handshake a strategy provider needs before it can be called at all.
12
+ *
13
+ * Ordinary providers finish authenticating the moment the operator pastes
14
+ * something: the value *is* the credential. A strategy provider has only half
15
+ * of one at that point — bunq's API key is the input to an installation, not a
16
+ * token — so this runs the vendor's own setup between storing what was pasted
17
+ * and asking whose account it is.
18
+ *
19
+ * **Why it sits where it does in `connect`.** After the credential is stored,
20
+ * because the handshake's input is what was pasted. Before identity is settled
21
+ * and anything is written to the config, because a key the vendor rejects
22
+ * should fail here — with the vendor's own message — rather than leaving a
23
+ * connection row describing an account that cannot be reached. It runs under
24
+ * the *provisional* connection id for the same reason every other step does:
25
+ * the real one is not known yet, and `connect` moves the credential when it is.
26
+ *
27
+ * Here rather than in `index.ts` because that file is at its size budget, and
28
+ * because this is one self-contained step: everything it needs is a manifest
29
+ * and a runtime, and everything it produces is in the credential store.
30
+ */
31
+ export async function runStrategySetup(
32
+ manifest: ProviderManifest,
33
+ /** The provisional id. `connect` renames the connection afterwards and the credential follows. */
34
+ connectionId: string,
35
+ /** Structurally the CLI `Runtime`, named as the parts this actually reaches. */
36
+ runtime: {
37
+ readonly registry: ProviderRegistry;
38
+ readonly credentials: SecretStore;
39
+ readonly state: RuntimeState;
40
+ readonly resolution: { readonly profile: string };
41
+ },
42
+ ): Promise<void> {
43
+ const { registry, credentials, state } = runtime;
44
+ const profile = runtime.resolution.profile;
45
+ if (manifest.auth.kind !== 'strategy') return;
46
+
47
+ const strategy = strategyFor(manifest, registry);
48
+ if (!strategy.setup) return;
49
+
50
+ // The one ref this connection may read, which is also the one the pasted
51
+ // value landed in and the one the handshake will rewrite.
52
+ const ref = credentialRefForConnection(manifest, connectionId)!;
53
+
54
+ const context = strategyContextFrom({
55
+ source: {
56
+ credentials: scopeSecrets(credentials, [ref]),
57
+ state: createScopedStore(state, scopeNamespace(manifest.id, connectionId)),
58
+ log: {
59
+ debug: () => {},
60
+ info: (message) => progress(style.dim(` ${message}`)),
61
+ warn: (message) => progress(style.dim(` ${message}`)),
62
+ error: (message) => progress(style.dim(` ${message}`)),
63
+ },
64
+ },
65
+ manifest,
66
+ connectionId,
67
+ profile,
68
+ // Writable *only* here — the dispatch path passes nothing, so a per-request
69
+ // handshake cannot persist anything.
70
+ //
71
+ // Scoped to the same single ref the reads are. Handing over the raw store
72
+ // would leave the write side of this boundary open while the read side is
73
+ // shut, which is the asymmetry that makes a boundary decorative: a strategy
74
+ // could not *read* `google/main` and could quietly overwrite it.
75
+ write: async (reference, value) => {
76
+ if (reference !== ref) {
77
+ throw new Error(
78
+ `The ${manifest.name} strategy tried to write ${reference}, which is not its connection's credential (${ref}).`,
79
+ );
80
+ }
81
+ await credentials.set(reference, value);
82
+ },
83
+ });
84
+
85
+ progress(style.dim(` Registering this device with ${manifest.name}…`));
86
+ await strategy.setup(context);
87
+ }
@@ -0,0 +1,41 @@
1
+ import { layout } from '#profile';
2
+ import type { ProviderRegistry } from '#registry';
3
+ import { PROGRAM } from '../../usage.ts';
4
+
5
+ /**
6
+ * What to say when a name resolves to no provider.
7
+ *
8
+ * The one message that has to teach something, because a misspelling and a
9
+ * service nobody has integrated arrive here identically and want opposite
10
+ * answers. So it prints both lists — what is shipped, and what this profile has
11
+ * added — and then the command that adds another.
12
+ *
13
+ * It named `<root>/providers/` for a long time, which is not where manifests
14
+ * live and never was. The path comes from `layout` now, like every other
15
+ * profile-owned path, and the "add your own" line is printed whether or not the
16
+ * operator already has one: showing it only to somebody with none meant hiding
17
+ * it from everybody except the one person who could not yet know it existed.
18
+ */
19
+ export function unknownProvider(input: {
20
+ readonly providerId: string;
21
+ readonly registry: ProviderRegistry;
22
+ readonly workspaceRoot: string;
23
+ readonly profile: string;
24
+ readonly target: string;
25
+ }): Error {
26
+ const available = input.registry.list();
27
+ const builtin = available.filter((c) => c.origin === 'builtin').map((c) => c.manifest.id);
28
+ const yours = available.filter((c) => c.origin === 'workspace').map((c) => c.manifest.id);
29
+
30
+ const selection = `--profile ${input.profile} --target ${input.target}`;
31
+ const directory = `${input.workspaceRoot}/${layout.providers(input.profile)}`;
32
+
33
+ return new Error(
34
+ `Unknown provider "${input.providerId}".\n` +
35
+ ` built in: ${builtin.join(', ')}\n` +
36
+ (yours.length > 0 ? ` yours: ${yours.join(', ')}\n` : '') +
37
+ ` add your own: ${PROGRAM} connect custom ${input.providerId}` +
38
+ ` --connector <kind> --auth <method> ${selection}\n` +
39
+ ` or a manifest in ${directory}\n`,
40
+ );
41
+ }
@@ -29,9 +29,29 @@ export function pluck(value: unknown, path: string): string | null {
29
29
  return typeof current === 'string' && current.length > 0 ? current : null;
30
30
  }
31
31
 
32
+ /** The header an OAuth or bearer provider's probe carries. */
33
+ async function bearerHeaders(probe: IdentityProbe): Promise<RequestInit> {
34
+ const token = await probe.accessToken();
35
+ return { headers: token ? { authorization: `Bearer ${token}` } : {} };
36
+ }
37
+
32
38
  export interface IdentityProbe {
33
39
  /** A valid upstream access token, if the provider authenticates. */
34
40
  readonly accessToken: () => Promise<string | null>;
41
+ /**
42
+ * Put this connection's credential on a request, whatever method it is.
43
+ *
44
+ * Supplied where `accessToken` cannot answer. A stored `api_key`, `header` or
45
+ * `basic` credential is not a bearer token and `bearerToken` throws for all
46
+ * three — under a comment calling the branch unreachable, which it is on an
47
+ * mcp connector and is not on an http one. The throw was caught by the
48
+ * catch-all below, so an `identity: { kind: http }` block on the commonest
49
+ * custom shape there is — a REST API behind an API key — never worked and
50
+ * never said so: the operator was asked to name the account by hand on every
51
+ * reconnect, and a different answer each time is a new row rather than a
52
+ * repair.
53
+ */
54
+ readonly authorize?: (request: Request) => Promise<Request>;
35
55
  /** Call a capability on the upstream MCP server. */
36
56
  readonly callTool?: (name: string, args: Record<string, unknown>) => Promise<unknown>;
37
57
  /** Ask the connector, for a protocol whose identity is not a URL away. */
@@ -52,11 +72,15 @@ export async function resolveAccount(
52
72
  }
53
73
 
54
74
  if (identity.kind === 'http') {
55
- const token = await probe.accessToken();
56
- const request = probe.fetch ?? globalThis.fetch;
57
- const response = await request(identity.url, {
58
- headers: token ? { authorization: `Bearer ${token}` } : {},
59
- });
75
+ const send = probe.fetch ?? globalThis.fetch;
76
+
77
+ // `authorize` where the caller supplied one, because it is the same
78
+ // switch the dispatch path uses and knows every method. The token is the
79
+ // fallback rather than the other way round only because the OAuth
80
+ // providers reached here first; both end at the same header for them.
81
+ const response = probe.authorize
82
+ ? await send(await probe.authorize(new Request(identity.url)))
83
+ : await send(identity.url, await bearerHeaders(probe));
60
84
  if (!response.ok) return null;
61
85
 
62
86
  const body = await response.json();
package/src/cli/main.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { connect } from './commands/connect/index.ts';
2
+ import { connectCustom } from './commands/connect/custom/index.ts';
2
3
  import {
3
4
  attachFile,
4
5
  auditTail,
@@ -29,7 +30,7 @@ import { secretsList, secretsPush, secretsSet } from './commands/secrets.ts';
29
30
  import { knowledgeShow, knowledgeUse } from './commands/knowledge.ts';
30
31
  import { dispatchOwner } from './dispatch-owner.ts';
31
32
  import { update } from './commands/update.ts';
32
- import { all, globalFlags, knowledgeFlags, ownerFlags, parseArgv, text } from './argv.ts';
33
+ import { all, customFlags, globalFlags, knowledgeFlags, ownerFlags, parseArgv, text } from './argv.ts';
33
34
  import { assertKnownFlags, requireSelection } from './selection.ts';
34
35
  import { PROGRAM, USAGE } from './usage.ts';
35
36
  import { version } from './version.ts';
@@ -75,18 +76,41 @@ export async function run(argv: readonly string[]): Promise<void> {
75
76
 
76
77
  switch (first) {
77
78
  case 'connect':
78
- if (!second) throw new Error(`Usage: ${PROGRAM} connect <provider>`);
79
- return connect(second, {
80
- ...global,
81
- id: text(flags, 'id'),
82
- displayName: text(flags, 'display-name'),
83
- replace: flags['replace'] === true,
84
- nonInteractive: flags['non-interactive'] === true,
85
- acceptBroadScopes: flags['accept-broad-scopes'] === true,
86
- ownClient: flags['own-client'] === true,
87
- auth: text(flags, 'auth'),
88
- json,
89
- });
79
+ // A nested switch rather than an `if`, so `selection.test.ts` sees
80
+ // `connect custom` when it reads this file for `case` labels: it matches
81
+ // on eight-space indentation, and a command invisible to that check is a
82
+ // command that can default quietly.
83
+ switch (second) {
84
+ case 'custom':
85
+ return connectCustom(rest[0], {
86
+ ...global,
87
+ ...customFlags(flags, argv),
88
+ id: text(flags, 'id'),
89
+ displayName: text(flags, 'display-name'),
90
+ replace: flags['replace'] === true,
91
+ nonInteractive: flags['non-interactive'] === true,
92
+ acceptBroadScopes: flags['accept-broad-scopes'] === true,
93
+ json,
94
+ });
95
+
96
+ // Not a `case` label, so it adds no row to `SELECTION`: there is no
97
+ // command here to classify, only a usage error.
98
+ case undefined:
99
+ throw new Error(`Usage: ${PROGRAM} connect <provider>`);
100
+
101
+ default:
102
+ return connect(second, {
103
+ ...global,
104
+ id: text(flags, 'id'),
105
+ displayName: text(flags, 'display-name'),
106
+ replace: flags['replace'] === true,
107
+ nonInteractive: flags['non-interactive'] === true,
108
+ acceptBroadScopes: flags['accept-broad-scopes'] === true,
109
+ ownClient: flags['own-client'] === true,
110
+ auth: text(flags, 'auth'),
111
+ json,
112
+ });
113
+ }
90
114
 
91
115
  case 'setup':
92
116
  if (second !== 'plan' && second !== undefined) {
package/src/cli/oauth.ts CHANGED
@@ -66,6 +66,27 @@ export interface OAuthFlowOptions {
66
66
  * on the way back. What changes is only the address the vendor is told.
67
67
  */
68
68
  readonly relayRedirect?: string;
69
+ /**
70
+ * A redirect this machine listens on, named to the vendor exactly as written.
71
+ *
72
+ * The third answer to "where does the browser come back to", and the one the
73
+ * comment above did not anticipate. `relayRedirect` exists for a vendor that
74
+ * will take no loopback address at all; this is for a vendor that takes one
75
+ * happily but matches it *exactly*, port included — so the kernel-chosen port
76
+ * below can never match a URL registered in a console months earlier.
77
+ *
78
+ * That is not a rare shape. It is the reason `providers/github/index.ts`
79
+ * gives for using a pasted token rather than OAuth: "an OAuth App matches its
80
+ * callback URL exactly, including the port, and `connect` listens on a port
81
+ * the kernel picks."
82
+ *
83
+ * The whole URL rather than just a port, because the two must be identical
84
+ * strings and only one of them is ours to choose. A console that accepts
85
+ * `localhost` but not `127.0.0.1` — or insists on a trailing path — decides
86
+ * the spelling, and a manifest that could only name a number would have to
87
+ * guess the rest right.
88
+ */
89
+ readonly fixedRedirect?: string;
69
90
  /** What the completion page names as connected. A provider's display name. */
70
91
  readonly connectionLabel?: string;
71
92
  /** How long to wait for the operator to finish in the browser. */
@@ -130,48 +151,80 @@ export async function runOAuthFlow(options: OAuthFlowOptions): Promise<OAuthToke
130
151
  rejectCode = reject;
131
152
  });
132
153
 
133
- const server = Bun.serve({
134
- hostname: '127.0.0.1',
135
- port: 0, // let the OS choose; nothing outside this machine names this port
136
- fetch(request) {
137
- const url = new URL(request.url);
138
- if (url.pathname !== '/callback') return new Response('Not found', { status: 404 });
139
-
140
- const error = url.searchParams.get('error');
141
- if (error) {
142
- rejectCode(
143
- new OAuthError(
144
- error === 'access_denied'
145
- ? 'Authorization was declined in the browser.'
146
- : `Authorization failed: ${error}`,
147
- ),
148
- );
149
- return failedPage('You can close this tab.');
150
- }
154
+ // A fixed redirect is only fixed if we listen where it says. Parsed rather
155
+ // than configured separately so the two cannot disagree: the port the vendor
156
+ // was told is by construction the port this binds.
157
+ const fixed = options.fixedRedirect ? new URL(options.fixedRedirect) : undefined;
158
+ const fixedPort = fixed ? Number(fixed.port) : undefined;
151
159
 
152
- const returnedState = url.searchParams.get('state');
153
- if (!returnedState || !stateMatches(state, returnedState)) {
154
- // A mismatched state means this callback did not come from the
155
- // request we started. Refuse it rather than redeeming whatever code
156
- // it carries.
157
- rejectCode(new OAuthError('State mismatch — ignoring an unexpected callback.'));
158
- return failedPage('Unexpected callback.');
159
- }
160
+ if (fixed && !fixedPort) {
161
+ throw new OAuthError(
162
+ `The redirect "${options.fixedRedirect}" names no port, so there is nothing for this machine to listen on. Register a redirect with an explicit port, such as http://127.0.0.1:8765/callback.`,
163
+ );
164
+ }
160
165
 
161
- const code = url.searchParams.get('code');
162
- if (!code) {
163
- rejectCode(new OAuthError('The callback carried no authorization code.'));
164
- return failedPage('No code returned.');
165
- }
166
+ // Bun.serve throws synchronously when a port is taken. With a kernel-chosen
167
+ // port that cannot happen; with a fixed one it is the ordinary failure — a
168
+ // previous run still holding the socket, or another program on the port the
169
+ // vendor was told about. The raw EADDRINUSE names neither the provider nor
170
+ // the redirect, so it reads as a crash rather than as something to fix.
171
+ const server = ((): ReturnType<typeof Bun.serve> => {
172
+ try {
173
+ return Bun.serve({
174
+ hostname: '127.0.0.1',
175
+ // Zero lets the OS choose, which is right whenever nothing outside this
176
+ // machine names the port. A fixed redirect is exactly the case where
177
+ // something does.
178
+ port: fixedPort ?? 0,
179
+ fetch(request) {
180
+ const url = new URL(request.url);
181
+ if (url.pathname !== '/callback') return new Response('Not found', { status: 404 });
166
182
 
167
- resolveCode(code);
168
- return connectedPage(options.connectionLabel);
169
- },
170
- });
183
+ const error = url.searchParams.get('error');
184
+ if (error) {
185
+ rejectCode(
186
+ new OAuthError(
187
+ error === 'access_denied'
188
+ ? 'Authorization was declined in the browser.'
189
+ : `Authorization failed: ${error}`,
190
+ ),
191
+ );
192
+ return failedPage('You can close this tab.');
193
+ }
194
+
195
+ const returnedState = url.searchParams.get('state');
196
+ if (!returnedState || !stateMatches(state, returnedState)) {
197
+ // A mismatched state means this callback did not come from the
198
+ // request we started. Refuse it rather than redeeming whatever code
199
+ // it carries.
200
+ rejectCode(new OAuthError('State mismatch — ignoring an unexpected callback.'));
201
+ return failedPage('Unexpected callback.');
202
+ }
203
+
204
+ const code = url.searchParams.get('code');
205
+ if (!code) {
206
+ rejectCode(new OAuthError('The callback carried no authorization code.'));
207
+ return failedPage('No code returned.');
208
+ }
209
+
210
+ resolveCode(code);
211
+ return connectedPage(options.connectionLabel);
212
+ },
213
+ });
214
+ } catch (cause) {
215
+ if (fixedPort) {
216
+ throw new OAuthError(
217
+ `Port ${fixedPort} is already in use, so the callback for this connection cannot be served. The redirect registered with the provider names that port exactly, so it cannot simply be moved — free the port and try again.`,
218
+ );
219
+ }
220
+ throw cause;
221
+ }
222
+ })();
171
223
 
172
224
  // Where the vendor is told to send the browser. The relay bounces it back
173
225
  // here; without one it comes here directly, which is every other provider.
174
- const redirectUri = options.relayRedirect ?? `http://127.0.0.1:${server.port}/callback`;
226
+ const redirectUri =
227
+ options.relayRedirect ?? options.fixedRedirect ?? `http://127.0.0.1:${server.port}/callback`;
175
228
 
176
229
  /**
177
230
  * The CSRF binding, and — behind a relay — the only way home.
@@ -79,6 +79,16 @@ export interface Runtime {
79
79
  readonly authenticator: BearerAuthenticator;
80
80
  /** Same factory the dispatcher uses, exposed for commands that probe upstream. */
81
81
  connectorFor(providerId: string, connectionId: string): AnyConnector | undefined;
82
+ /**
83
+ * Same authorizer the dispatcher uses, for the same reason `connectorFor` is here.
84
+ *
85
+ * A command that probes upstream needs the credential on the request, and it
86
+ * must not learn how to put it there — that is one switch on the resolved
87
+ * shape (`connectivity/auth/authorize.ts`) and a second copy would be a
88
+ * second answer. `settleIdentity` is the caller: it asks a provider whose
89
+ * account was just authorised, over whatever method that provider declares.
90
+ */
91
+ authorizeRequest(providerId: string, connectionId: string, request: Request): Promise<Request>;
82
92
  /** A provider's manifest, so an omitted `credential_ref` can be derived from it. */
83
93
  manifestFor(providerId: string): ProviderManifest | undefined;
84
94
  close(): Promise<void>;
@@ -309,13 +319,14 @@ export async function openRuntime(
309
319
  // the *same instance* whichever side asks for it, or a held session is held
310
320
  // twice.
311
321
  const connectorFor = connectorFactory({ registry, credentials });
322
+ const authorizeRequest = requestAuthorizer(registry, credentials);
312
323
  let closed = false;
313
324
 
314
325
  const dispatcher = new Dispatcher({
315
326
  config,
316
327
  registry,
317
328
  connectorFor,
318
- authorizeRequest: requestAuthorizer(registry, credentials),
329
+ authorizeRequest,
319
330
  policy,
320
331
  state,
321
332
  audit: auditSink,
@@ -345,6 +356,7 @@ export async function openRuntime(
345
356
  credentials,
346
357
  }),
347
358
  connectorFor,
359
+ authorizeRequest,
348
360
  manifestFor: (providerId: string) => registry.manifest(providerId),
349
361
  // Sessions first, then the state: a connector may still want to log out
350
362
  // cleanly, and LOGOUT is worth more than the microsecond it costs.
@@ -2,6 +2,7 @@ import type { BlobStore } from '#stores/blobs';
2
2
  import type { ProviderDefinition } from '#connectivity';
3
3
  import { ConfigError } from '#profile';
4
4
  import { ProviderRegistry } from '#registry';
5
+ import { RESERVED_BY_GRAMMAR } from '../commands/connect/custom/spec.ts';
5
6
  import { loadProfileProviders } from '#providers/custom/index.ts';
6
7
  import { loadProfileSkills, type LoadedSkill } from '#providers/skills/store.ts';
7
8
  import { exampleProvider } from '#providers/example/provider.ts';
@@ -144,6 +145,17 @@ export async function buildRegistryWithWorkspace(
144
145
  `${path}: provider "${manifest.id}" is already built in. Rename it, or remove the file to use the built-in.`,
145
146
  );
146
147
  }
148
+ // An id the CLI's own grammar has taken. `connect custom` refuses to create
149
+ // one, and this is the other half: a file written by hand — or before that
150
+ // command existed — would otherwise register cleanly, be unreachable
151
+ // forever, and say nothing about why.
152
+ if ((RESERVED_BY_GRAMMAR as readonly string[]).includes(manifest.id)) {
153
+ throw new ConfigError(
154
+ `${path}: provider "${manifest.id}" cannot be reached — "${manifest.id}" is the second word ` +
155
+ `of \`lanes link connect ${manifest.id}\`, which is the command that declares one. ` +
156
+ 'Rename it.',
157
+ );
158
+ }
147
159
  registry.register(manifest, 'workspace');
148
160
  }
149
161
 
@@ -10,6 +10,7 @@ import {
10
10
  targetsByName,
11
11
  } from '#profile';
12
12
  import type { Flags } from './argv.ts';
13
+ import { CONNECT_CUSTOM_FLAGS } from './commands/connect/custom/spec.ts';
13
14
  import { nearest } from './nearest.ts';
14
15
 
15
16
  /**
@@ -110,6 +111,12 @@ export const SELECTION: Record<string, Requires> = {
110
111
  'identity list': 'profile',
111
112
 
112
113
  connect: 'profile+target',
114
+ // Its own row rather than an inheritance from `connect`. Both need the same
115
+ // two things, but the row is what makes `selectionKey` return the two-word
116
+ // key — and that is what keeps thirty declaration flags off
117
+ // `connect <provider>`, where a mistyped one would otherwise be accepted and
118
+ // ignored, which is the defect this whole file exists for.
119
+ 'connect custom': 'profile+target',
113
120
  setup: 'profile+target',
114
121
  token: 'profile+target',
115
122
  audit: 'profile+target',
@@ -280,6 +287,11 @@ const UNIVERSAL = ['help', 'json', 'quiet'];
280
287
  * universal set plus whatever `SELECTION` says it must be told.
281
288
  */
282
289
  const ACCEPTS: Record<string, readonly string[]> = {
290
+ // Imported rather than written out. Thirty-odd entries here would take this
291
+ // file past the size budget for a data literal, and the command's own
292
+ // `spec.ts` already derives most of them from the per-kind field tables — so
293
+ // a flag added there cannot be forgotten here.
294
+ 'connect custom': CONNECT_CUSTOM_FLAGS,
283
295
  // `own-client` is the older spelling of one of the routes `auth` names, kept
284
296
  // because it is in scripts and a year of documentation (ADR-038).
285
297
  connect: [
package/src/cli/usage.ts CHANGED
@@ -26,6 +26,10 @@ ${style.bold('Everyday')}
26
26
  ${PROGRAM} connect <...> --replace ask for the stored password or key again
27
27
  ${PROGRAM} connect <...> --auth <method> pick how, where there is a choice
28
28
  ${PROGRAM} connect <...> --non-interactive [--json]
29
+ ${PROGRAM} connect custom <id> --connector <kind> --auth <method>
30
+ declare a service that is not built in, and connect it.
31
+ kinds: mcp, http, imap, dav, fs. Omit a value and it is
32
+ asked for; the manifest it writes is yours to edit
29
33
  answer nothing from a terminal: take every value
30
34
  from the credential store, or say what is missing
31
35
  ${PROGRAM} start [--only] reconcile and serve every profile on one endpoint
@@ -137,7 +141,11 @@ ${style.bold('Other flags')}
137
141
  one this project operates (connect only)
138
142
  --auth <method> which way in, where a provider offers two (connect
139
143
  only). "oauth" is the browser; the other is named
140
- in the choice connect prints
144
+ in the choice connect prints. On connect custom it
145
+ names the credential type instead: none, bearer,
146
+ api-key, header, basic, oauth, strategy
147
+ --replace-manifest rewrite a declaration that already exists and differs
148
+ (connect custom only — --replace is about the credential)
141
149
  --port <n> override the configured port (start only)
142
150
 
143
151
  Every command prints the profile and target it is acting on, before it acts.
@@ -15,7 +15,7 @@ transport asks when it has a token to send and no request to attach it to.
15
15
  | `basic/` | `basic` | `username:password`, RFC 7617's own encoding |
16
16
  | `oauth-authcode/` | `oauth` | a refresh token, exchanged on every use |
17
17
  | `oauth-jwt/` | `oauth` + `assertion` | a private key, signed into an assertion per exchange |
18
- | `strategy/` | `strategy` | the escape hatch — per-vendor code, none registered |
18
+ | `strategy/` | `strategy` | the escape hatch — the seam only; the code is the provider's |
19
19
 
20
20
  ## Adding one
21
21
 
@@ -23,6 +23,13 @@ A folder, a member of `authSchema` in `../manifest/auth.ts`, and a case in
23
23
  `resolve.ts` (plus `authorize.ts` if it touches the request). Nothing else in
24
24
  the codebase learns about it — that is the point of the split.
25
25
 
26
+ `strategy/` is the other shape, and holds no vendor code at all. It resolves the
27
+ strategy a manifest names from the `ProviderDefinition` beside it and refuses
28
+ when the two disagree; the implementation lives with its provider, because a
29
+ folder of vendor code under `connectivity/` is precisely what the vendor-name
30
+ rule in `architecture.test.ts` exists to prevent. `providers/bunq/strategy/` is
31
+ the one there is. See [ADR-046](../../../docs/detailed/adr/046-an-auth-strategy-belongs-to-its-provider.md).
32
+
26
33
  `oauth-jwt/` is the exception that proves the shape rather than breaking it. It
27
34
  is not a `kind`, because it is a second way into a provider that already has
28
35
  one, so it hangs off the OAuth block as `auth.assertion` and is selected by the