@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.
- package/README.md +11 -3
- package/instructions/agents/lanes-link-scout.md +2 -2
- package/instructions/skills/lanes-link/SKILL.md +135 -11
- package/package.json +3 -1
- package/src/cli/argv.ts +52 -0
- package/src/cli/commands/connect/authorise.ts +5 -0
- package/src/cli/commands/connect/custom/ask.ts +167 -0
- package/src/cli/commands/connect/custom/credential.ts +143 -0
- package/src/cli/commands/connect/custom/derive.ts +229 -0
- package/src/cli/commands/connect/custom/index.ts +285 -0
- package/src/cli/commands/connect/custom/prompts.ts +160 -0
- package/src/cli/commands/connect/custom/spec.ts +293 -0
- package/src/cli/commands/connect/custom/values.ts +53 -0
- package/src/cli/commands/connect/custom/write.ts +166 -0
- package/src/cli/commands/connect/grant.ts +27 -0
- package/src/cli/commands/connect/index.ts +24 -27
- package/src/cli/commands/connect/outcome.ts +3 -1
- package/src/cli/commands/connect/requirements.ts +11 -1
- package/src/cli/commands/connect/settle.ts +17 -0
- package/src/cli/commands/connect/setup.ts +9 -1
- package/src/cli/commands/connect/strategy.ts +87 -0
- package/src/cli/commands/connect/unknown.ts +41 -0
- package/src/cli/identity.ts +29 -5
- package/src/cli/main.ts +37 -13
- package/src/cli/oauth.ts +89 -36
- package/src/cli/runtime/open.ts +13 -1
- package/src/cli/runtime/registry.ts +12 -0
- package/src/cli/selection.ts +12 -0
- package/src/cli/usage.ts +9 -1
- package/src/connectivity/auth/README.md +8 -1
- package/src/connectivity/auth/strategy/index.ts +128 -4
- package/src/connectivity/connector.ts +11 -0
- package/src/connectivity/index.ts +11 -1
- package/src/connectivity/manifest/auth.ts +19 -0
- package/src/connectivity/manifest/connector.ts +21 -0
- package/src/connectivity/manifest/primitives.ts +5 -1
- package/src/connectivity/manifest/provider.ts +30 -12
- package/src/connectivity/provider.ts +55 -0
- package/src/connectivity/transports/factory.ts +1 -0
- package/src/connectivity/transports/http/index.ts +73 -2
- package/src/dispatch/dispatch.ts +44 -5
- package/src/providers/bunq/hints.ts +43 -0
- package/src/providers/bunq/index.ts +87 -0
- package/src/providers/bunq/redact.ts +64 -0
- package/src/providers/bunq/specs/bunq.v1.json +864 -0
- package/src/providers/bunq/specs/vendor.ts +338 -0
- package/src/providers/bunq/strategy/handshake.ts +211 -0
- package/src/providers/bunq/strategy/index.ts +298 -0
- package/src/providers/bunq/strategy/keys.ts +72 -0
- package/src/providers/custom/index.ts +1 -6
- package/src/providers/custom/load.ts +56 -14
- package/src/providers/custom/template.ts +1 -1
- package/src/providers/discord/hints.ts +195 -0
- package/src/providers/discord/index.ts +121 -0
- package/src/providers/discord/redact.ts +99 -0
- package/src/providers/discord/specs/discord.v10.json +2333 -0
- package/src/providers/discord/specs/vendor.ts +164 -0
- package/src/providers/google/specs/vendor.ts +32 -317
- package/src/providers/index.ts +9 -0
- package/src/providers/reddit/index.ts +113 -0
- package/src/providers/reddit/oauth.ts +77 -0
- package/src/providers/reddit/redact.ts +33 -0
- package/src/providers/reddit/scopes.ts +27 -0
- package/src/providers/reddit/specs/reddit.v1.json +700 -0
- package/src/providers/scopes.ts +2 -0
- package/src/providers/shared/openapi.ts +155 -0
- package/src/providers/shared/vendor-operations.ts +98 -0
- 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
|
-
|
|
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
|
+
}
|
package/src/cli/identity.ts
CHANGED
|
@@ -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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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 =
|
|
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.
|
package/src/cli/runtime/open.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/src/cli/selection.ts
CHANGED
|
@@ -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 —
|
|
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
|