@lanes-sh/link 0.3.2 → 0.4.1
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/commands/mcp/list.ts +123 -29
- package/src/cli/identity.ts +29 -5
- package/src/cli/main.ts +42 -14
- 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 +45 -0
- package/src/providers/bunq/index.ts +87 -0
- package/src/providers/bunq/redact.ts +75 -0
- package/src/providers/bunq/specs/bunq.v1.json +883 -0
- package/src/providers/bunq/specs/vendor.ts +396 -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 +179 -0
- package/src/providers/shared/vendor-spec.ts +309 -0
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
|
|
@@ -1,10 +1,134 @@
|
|
|
1
|
+
import type { AuthStrategy, AuthStrategyContext } from '../../connector.ts';
|
|
2
|
+
import type { ProviderContext } from '../../context.ts';
|
|
3
|
+
import type { ProviderDefinition } from '../../provider.ts';
|
|
4
|
+
import type { ProviderManifest } from '../../manifest/index.ts';
|
|
5
|
+
|
|
1
6
|
/**
|
|
2
7
|
* The escape hatch: auth no declarative form should try to express.
|
|
3
8
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
9
|
+
* A strategy is the one place per-vendor code is permitted outside `local`
|
|
10
|
+
* providers, and it stays *auth* — a request in, an authorised request out.
|
|
11
|
+
* The moment one starts translating endpoints, the problem ADR-008 removed has
|
|
12
|
+
* come back.
|
|
13
|
+
*
|
|
14
|
+
* There is no global registry here, and that is deliberate. A strategy is not a
|
|
15
|
+
* plugin the runtime discovers; it is a property of the provider that needs it,
|
|
16
|
+
* so it travels on that provider's `ProviderDefinition` the same way an authored
|
|
17
|
+
* capability does. Two things follow, both of them the point:
|
|
18
|
+
*
|
|
19
|
+
* - this component stays free of vendor names, which is the rule
|
|
20
|
+
* `architecture.test.ts` holds over everything a request passes through
|
|
21
|
+
* - adding one is still a folder under `providers/` and a line in its index,
|
|
22
|
+
* rather than a folder here plus a registration somewhere else
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What `strategyFor` needs from the registry, and no more.
|
|
27
|
+
*
|
|
28
|
+
* Structural rather than the real `ProviderRegistry`, so this file states its
|
|
29
|
+
* dependency as two methods instead of importing a component to name a type.
|
|
30
|
+
*/
|
|
31
|
+
export interface StrategySource {
|
|
32
|
+
get(id: string): { readonly definition?: ProviderDefinition | undefined } | undefined;
|
|
33
|
+
list(): readonly { readonly definition?: ProviderDefinition | undefined }[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The strategy a manifest declares.
|
|
38
|
+
*
|
|
39
|
+
* Its own definition first, which is the built-in case: `providers/bunq`
|
|
40
|
+
* carries the code for the manifest beside it, and the two must agree about
|
|
41
|
+
* which strategy that is. A manifest naming `strategy: acme` while its
|
|
42
|
+
* definition carries something else is a wiring mistake that would otherwise
|
|
43
|
+
* surface as a signature the vendor rejects — a long way from its cause.
|
|
44
|
+
*
|
|
45
|
+
* Then any other registered provider that supplies one by that name, which is
|
|
46
|
+
* the case a declaration-only manifest needs. A workspace YAML in
|
|
47
|
+
* `providers.d/` is parsed into a `ProviderManifest` and has no definition at
|
|
48
|
+
* all, so without this it could name a strategy and never reach it — and naming
|
|
49
|
+
* one is exactly what a manifest is for. It is also the only way to point a
|
|
50
|
+
* connection at a vendor's sandbox, since a built-in manifest's `options` are
|
|
51
|
+
* not the operator's to edit.
|
|
52
|
+
*
|
|
53
|
+
* Borrowing the *code* is not borrowing the *credential*: the ref still derives
|
|
54
|
+
* from the manifest's own id, so `bunq_sandbox` reads `bunq_sandbox/<id>` and
|
|
55
|
+
* cannot reach what `bunq` holds.
|
|
56
|
+
*/
|
|
57
|
+
export function strategyFor(
|
|
58
|
+
manifest: ProviderManifest,
|
|
59
|
+
source: StrategySource,
|
|
60
|
+
): AuthStrategy {
|
|
61
|
+
if (manifest.auth.kind !== 'strategy') {
|
|
62
|
+
throw new Error(`Provider "${manifest.id}" does not declare a strategy.`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const named = manifest.auth.strategy;
|
|
66
|
+
const own = source.get(manifest.id)?.definition?.authStrategy;
|
|
67
|
+
|
|
68
|
+
if (own) {
|
|
69
|
+
if (own.id !== named) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Provider "${manifest.id}" declares auth strategy "${named}" but carries "${own.id}".`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return own;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const borrowed = source
|
|
78
|
+
.list()
|
|
79
|
+
.map((entry) => entry.definition?.authStrategy)
|
|
80
|
+
.find((strategy) => strategy?.id === named);
|
|
81
|
+
|
|
82
|
+
if (!borrowed) refuseStrategy(named);
|
|
83
|
+
return borrowed;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* What a strategy is given, derived from what the provider is given.
|
|
88
|
+
*
|
|
89
|
+
* Narrower than a `ProviderContext` on purpose: the connection's own
|
|
90
|
+
* credentials read-only, its own scoped state, and its logger. Nothing else,
|
|
91
|
+
* and in particular no storage, no audit logger, and no signal — a strategy
|
|
92
|
+
* authenticates a request and has no business doing anything a provider does.
|
|
93
|
+
*
|
|
94
|
+
* `write` is the one part that varies, and the variation *is* the rule.
|
|
95
|
+
* Persisting a credential is a setup-time act, so `connect` passes a writer and
|
|
96
|
+
* the dispatch path does not. Per-request code then cannot store a credential
|
|
97
|
+
* because there is nothing on the object to store one with, rather than because
|
|
98
|
+
* somebody remembered not to.
|
|
99
|
+
*
|
|
100
|
+
* Takes the three fields it reads rather than a whole `ProviderContext`, so the
|
|
101
|
+
* two callers can be what they are: dispatch hands over the context it already
|
|
102
|
+
* built, and `connect` — which has no provider context, because the connection
|
|
103
|
+
* does not exist yet — assembles the same three from the runtime.
|
|
104
|
+
*/
|
|
105
|
+
export function strategyContextFrom(input: {
|
|
106
|
+
/** Dispatch hands over the `ProviderContext` it built; `connect` assembles the three itself. */
|
|
107
|
+
readonly source: Pick<ProviderContext, 'credentials' | 'state' | 'log'>;
|
|
108
|
+
readonly manifest: ProviderManifest;
|
|
109
|
+
readonly connectionId: string;
|
|
110
|
+
readonly profile: string;
|
|
111
|
+
readonly write?: ((ref: string, value: string) => Promise<void>) | undefined;
|
|
112
|
+
}): AuthStrategyContext {
|
|
113
|
+
const { source, manifest, connectionId, profile, write } = input;
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
manifest,
|
|
117
|
+
connectionId,
|
|
118
|
+
profile,
|
|
119
|
+
credentials: source.credentials,
|
|
120
|
+
state: source.state,
|
|
121
|
+
log: source.log,
|
|
122
|
+
options: manifest.auth.kind === 'strategy' ? (manifest.auth.options ?? {}) : {},
|
|
123
|
+
...(write ? { write } : {}),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A manifest asked for a strategy nothing supplies.
|
|
129
|
+
*
|
|
130
|
+
* Fails loudly rather than sending an unauthenticated request, which would fail
|
|
131
|
+
* upstream with an error about the vendor rather than about the wiring.
|
|
8
132
|
*/
|
|
9
133
|
export function refuseStrategy(strategy: string): never {
|
|
10
134
|
throw new Error(
|
|
@@ -159,6 +159,17 @@ export interface AuthStrategy {
|
|
|
159
159
|
export interface AuthStrategyContext {
|
|
160
160
|
readonly manifest: ProviderManifest;
|
|
161
161
|
readonly connectionId: string;
|
|
162
|
+
/**
|
|
163
|
+
* Which profile this is acting for.
|
|
164
|
+
*
|
|
165
|
+
* Nothing about authenticating one request needs it. What needs it is any
|
|
166
|
+
* strategy that keeps something in process memory: one endpoint serves every
|
|
167
|
+
* profile in the workspace from one process, so `<provider>.<connection>` is
|
|
168
|
+
* not a unique key — two profiles each holding a bunq connection called
|
|
169
|
+
* `main` would share whatever it named. `state` and `credentials` are already
|
|
170
|
+
* scoped per profile and a cache in front of them must be too.
|
|
171
|
+
*/
|
|
172
|
+
readonly profile: string;
|
|
162
173
|
/** Read-only, scoped to this connection — the same boundary providers get. */
|
|
163
174
|
readonly credentials: ProviderContext['credentials'];
|
|
164
175
|
/**
|
|
@@ -48,7 +48,17 @@ export {
|
|
|
48
48
|
* Everything else is a manifest.
|
|
49
49
|
*/
|
|
50
50
|
export type { AuthRequirement, ProviderDefinition } from './provider.ts';
|
|
51
|
-
export {
|
|
51
|
+
export {
|
|
52
|
+
defineLocalProvider,
|
|
53
|
+
defineProviderWithCapabilities,
|
|
54
|
+
defineProviderWithStrategy,
|
|
55
|
+
} from './provider.ts';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The strategy seam. Vendor-free by construction: these three know that a
|
|
59
|
+
* strategy exists and never which one.
|
|
60
|
+
*/
|
|
61
|
+
export { refuseStrategy, strategyContextFrom, strategyFor } from './auth/strategy/index.ts';
|
|
52
62
|
|
|
53
63
|
export type {
|
|
54
64
|
ProviderManifest,
|
|
@@ -164,6 +164,25 @@ export const authOAuthSchema = z.object({
|
|
|
164
164
|
*/
|
|
165
165
|
authorize_url: z.url().optional(),
|
|
166
166
|
token_url: z.url().optional(),
|
|
167
|
+
/**
|
|
168
|
+
* A loopback redirect to name to the vendor verbatim, for one that matches it
|
|
169
|
+
* exactly rather than by prefix.
|
|
170
|
+
*
|
|
171
|
+
* `connect` normally listens on a port the kernel picks, which works because
|
|
172
|
+
* most authorization servers accept any loopback port. One that pins the
|
|
173
|
+
* whole URL cannot: the port it was told in a console months ago is not the
|
|
174
|
+
* port this run happens to get, and the grant is refused with
|
|
175
|
+
* `redirect_uri_mismatch`.
|
|
176
|
+
*
|
|
177
|
+
* The full URL rather than a port, because the vendor's console decides the
|
|
178
|
+
* spelling — `localhost` and `127.0.0.1` are different strings to a matcher
|
|
179
|
+
* even when they are the same host — and the two must be identical.
|
|
180
|
+
*
|
|
181
|
+
* Mutually exclusive with `broker`, which answers the same question the other
|
|
182
|
+
* way: there the redirect is the broker's HTTPS origin and the loopback port
|
|
183
|
+
* travels in `state`.
|
|
184
|
+
*/
|
|
185
|
+
redirect_uri: z.url().optional(),
|
|
167
186
|
/**
|
|
168
187
|
* Extra parameters on the authorization request.
|
|
169
188
|
*
|
|
@@ -56,6 +56,27 @@ export const httpConnectorSchema = z.object({
|
|
|
56
56
|
exclude: z.array(z.string()).default([]),
|
|
57
57
|
})
|
|
58
58
|
.optional(),
|
|
59
|
+
/**
|
|
60
|
+
* Sent on every request this connector makes.
|
|
61
|
+
*
|
|
62
|
+
* The same field as `mcp`'s above and refused the same way for
|
|
63
|
+
* `Authorization` — but for the opposite reason. An mcp server chooses its
|
|
64
|
+
* own tool list and a header is the only way to ask it for less; a REST API
|
|
65
|
+
* asks for nothing, and this is for what the *vendor* requires of a client
|
|
66
|
+
* rather than what a caller wants from it.
|
|
67
|
+
*
|
|
68
|
+
* The case in hand is a `User-Agent`. Nothing else here sets one, so every
|
|
69
|
+
* install of this program looks identical to a host that rate-limits by
|
|
70
|
+
* client — and a service that asks callers to identify themselves throttles
|
|
71
|
+
* the default agent hardest, which reads as an outage rather than a refusal.
|
|
72
|
+
*
|
|
73
|
+
* Precedence is narrowest-wins: `accept` is a default this may override, a
|
|
74
|
+
* header parameter the operation itself declares overrides this, and
|
|
75
|
+
* `content-type` is derived from the document and overrides everything —
|
|
76
|
+
* a blanket header cannot silently change how one operation's body is
|
|
77
|
+
* encoded.
|
|
78
|
+
*/
|
|
79
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
59
80
|
});
|
|
60
81
|
|
|
61
82
|
/**
|
|
@@ -16,5 +16,9 @@ export const credentialRef = z
|
|
|
16
16
|
.string()
|
|
17
17
|
.regex(
|
|
18
18
|
/^[a-z0-9][a-z0-9_-]*(?:\/[a-z0-9][a-z0-9_-]*)+$/,
|
|
19
|
-
|
|
19
|
+
// A placeholder rather than a real provider id. The example teaches the
|
|
20
|
+
// shape either way, and this file is inside the scope
|
|
21
|
+
// `architecture.test.ts` keeps free of vendor names — an error message that
|
|
22
|
+
// names one is exactly the "message that assumes a vendor" the rule is for.
|
|
23
|
+
'must be a credential reference like "acme/api_key", not a literal value',
|
|
20
24
|
);
|
|
@@ -225,6 +225,36 @@ export function defineProvider(input: unknown): ProviderManifest {
|
|
|
225
225
|
);
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
+
// Two answers to "where does the browser come back to", and a manifest
|
|
229
|
+
// naming both leaves it to whichever the flow reads first. A broker's
|
|
230
|
+
// redirect is its own HTTPS origin, with the loopback port carried in
|
|
231
|
+
// `state`; a fixed redirect is this machine, named exactly. Neither is wrong,
|
|
232
|
+
// but they cannot both be in force.
|
|
233
|
+
if (manifest.auth.kind === 'oauth' && manifest.auth.broker && manifest.auth.redirect_uri) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`Provider "${manifest.id}": auth may declare "broker" or "redirect_uri", not both — a brokered flow redirects to the broker and carries the loopback port in state, so a fixed redirect would never be used.`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Connector headers are for what the *server* offers as configuration; the
|
|
240
|
+
// credential is the auth block's, and a manifest setting both would have one
|
|
241
|
+
// quietly overwrite the other depending on which the transport merged last.
|
|
242
|
+
//
|
|
243
|
+
// Checked for every connector that has the field rather than for `mcp` alone.
|
|
244
|
+
// It was written when `mcp` was the only one, and the reasoning never had
|
|
245
|
+
// anything to do with which transport carried the header — an `http`
|
|
246
|
+
// connector naming `Authorization` collides with `auth` in exactly the same
|
|
247
|
+
// way, and would have validated cleanly.
|
|
248
|
+
const connectorHeaders =
|
|
249
|
+
'headers' in manifest.connector ? (manifest.connector.headers ?? {}) : {};
|
|
250
|
+
for (const name of Object.keys(connectorHeaders)) {
|
|
251
|
+
if (name.toLowerCase() === 'authorization') {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`Provider "${manifest.id}": connector.headers may not set "${name}" — the credential comes from auth, and setting both would leave which one is sent up to merge order.`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
228
258
|
if (manifest.connector.kind === 'mcp') {
|
|
229
259
|
const auth = manifest.auth;
|
|
230
260
|
|
|
@@ -250,18 +280,6 @@ export function defineProvider(input: unknown): ProviderManifest {
|
|
|
250
280
|
`Provider "${manifest.id}": an mcp connector always sends its token as "Authorization: Bearer", so auth.header ("${auth.header}") cannot be honoured. Remove it, or reach this service with an http connector.`,
|
|
251
281
|
);
|
|
252
282
|
}
|
|
253
|
-
|
|
254
|
-
// The third spelling of the same collision. Connector headers are for what
|
|
255
|
-
// the *server* offers as configuration; the credential is the auth block's,
|
|
256
|
-
// and a manifest setting both would have one quietly overwrite the other
|
|
257
|
-
// depending on which the transport merged last.
|
|
258
|
-
for (const name of Object.keys(manifest.connector.headers ?? {})) {
|
|
259
|
-
if (name.toLowerCase() === 'authorization') {
|
|
260
|
-
throw new Error(
|
|
261
|
-
`Provider "${manifest.id}": connector.headers may not set "${name}" — the credential comes from auth, and setting both would leave which one is sent up to merge order.`,
|
|
262
|
-
);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
283
|
}
|
|
266
284
|
|
|
267
285
|
const names = new Set<string>();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { SecretRef } from '#secrets';
|
|
3
3
|
import type { Capability } from './capability.ts';
|
|
4
|
+
import type { AuthStrategy } from './connector.ts';
|
|
4
5
|
import type { ProviderContext } from './context.ts';
|
|
5
6
|
import { bundleSchema, defineProvider, type ProviderManifest } from './manifest/index.ts';
|
|
6
7
|
|
|
@@ -32,6 +33,16 @@ export interface ProviderDefinition<
|
|
|
32
33
|
|
|
33
34
|
readonly capabilities: readonly Capability[];
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Pluggable auth, for a vendor whose handshake no manifest field can describe.
|
|
38
|
+
*
|
|
39
|
+
* Carried here rather than in a registry the runtime searches, because a
|
|
40
|
+
* strategy belongs to exactly one provider and nothing else may use it. The
|
|
41
|
+
* manifest declares *that* there is one and names it; this is the code, and
|
|
42
|
+
* `strategyFor` checks the two agree.
|
|
43
|
+
*/
|
|
44
|
+
readonly authStrategy?: AuthStrategy;
|
|
45
|
+
|
|
35
46
|
/**
|
|
36
47
|
* Which credential refs a connection may read. Core turns this into the
|
|
37
48
|
* allowlist behind `ProviderContext.credentials`, so a provider cannot reach
|
|
@@ -161,3 +172,47 @@ export function defineProviderWithCapabilities(input: {
|
|
|
161
172
|
capabilities: input.capabilities,
|
|
162
173
|
};
|
|
163
174
|
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A manifest-based provider whose authentication is code rather than a field.
|
|
178
|
+
*
|
|
179
|
+
* The sibling of `defineProviderWithCapabilities`, and rarer. That one exists
|
|
180
|
+
* where a vendor's API can do something its *document* cannot express; this one
|
|
181
|
+
* where a vendor's **handshake** can. Everything else about the provider stays
|
|
182
|
+
* declared — the connector kind, the operations, the redaction — and the
|
|
183
|
+
* strategy only ever sees a request on its way out and a response on its way
|
|
184
|
+
* back.
|
|
185
|
+
*
|
|
186
|
+
* ADR-008 puts a number on how much this is allowed to be: roughly 150 lines,
|
|
187
|
+
* for auth, once. A strategy that grows a second job is the 612-line problem
|
|
188
|
+
* returning by a different door.
|
|
189
|
+
*/
|
|
190
|
+
export function defineProviderWithStrategy(input: {
|
|
191
|
+
readonly manifest: ProviderManifest;
|
|
192
|
+
readonly strategy: AuthStrategy;
|
|
193
|
+
readonly capabilities?: readonly Capability[];
|
|
194
|
+
}): ProviderDefinition {
|
|
195
|
+
const { manifest, strategy } = input;
|
|
196
|
+
|
|
197
|
+
if (manifest.auth.kind !== 'strategy') {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`Provider "${manifest.id}" carries an auth strategy but declares auth.kind "${manifest.auth.kind}". Declare { kind: 'strategy', strategy: '${strategy.id}' }.`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (manifest.auth.strategy !== strategy.id) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`Provider "${manifest.id}" declares auth strategy "${manifest.auth.strategy}" but carries "${strategy.id}".`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
manifest,
|
|
211
|
+
// Permissive for the same reason `defineProviderWithCapabilities` is: a
|
|
212
|
+
// manifest provider's connection is already described by its manifest.
|
|
213
|
+
configSchema: z.unknown(),
|
|
214
|
+
connectionSchema: z.unknown(),
|
|
215
|
+
capabilities: input.capabilities ?? [],
|
|
216
|
+
authStrategy: strategy,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
@@ -26,9 +26,70 @@ export interface HttpConnectorOptions {
|
|
|
26
26
|
readonly openapi: string;
|
|
27
27
|
readonly include?: readonly string[];
|
|
28
28
|
readonly exclude?: readonly string[];
|
|
29
|
+
/** Sent on every request. Never `Authorization` — the manifest refuses it. */
|
|
30
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
29
31
|
readonly fetch?: typeof globalThis.fetch;
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The one body encoding that is not JSON often enough to be worth knowing about.
|
|
36
|
+
*
|
|
37
|
+
* A form-encoded write is not a legacy curiosity: it is what a large share of
|
|
38
|
+
* APIs that predate JSON request bodies still require, and one of them refusing
|
|
39
|
+
* `application/json` is not a negotiation — the request fails outright, with an
|
|
40
|
+
* error about the parameters rather than about the encoding.
|
|
41
|
+
*/
|
|
42
|
+
const FORM_ENCODED = 'application/x-www-form-urlencoded';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What the *document* says the body is, rather than what we would prefer.
|
|
46
|
+
*
|
|
47
|
+
* `mcp-from-openapi` records the declared media type on each body entry of the
|
|
48
|
+
* mapper, so this needs nothing threaded through `target` — the mapper is
|
|
49
|
+
* already cached there, which is what lets a cold instance encode correctly
|
|
50
|
+
* without re-reading the spec.
|
|
51
|
+
*
|
|
52
|
+
* JSON is the default because an operation with no declared request body has no
|
|
53
|
+
* media type to read, and because it is what this connector always sent.
|
|
54
|
+
*/
|
|
55
|
+
function bodyContentType(mapper: readonly ParameterMapper[]): string {
|
|
56
|
+
for (const entry of mapper) {
|
|
57
|
+
if (entry.type !== 'body') continue;
|
|
58
|
+
const declared = entry.serialization?.contentType;
|
|
59
|
+
if (declared) return declared;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return 'application/json';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Arrays repeat rather than join, for the reason the query string does above.
|
|
67
|
+
*
|
|
68
|
+
* A nested object is JSON inside the field, which is the only thing a
|
|
69
|
+
* form-encoded body can do with one — there is no standard spelling of nesting
|
|
70
|
+
* here, and every API that accepts one accepts it as a string.
|
|
71
|
+
*/
|
|
72
|
+
function encodeBody(body: Readonly<Record<string, unknown>>, contentType: string): string {
|
|
73
|
+
if (!contentType.startsWith(FORM_ENCODED)) return JSON.stringify(body);
|
|
74
|
+
|
|
75
|
+
const form = new URLSearchParams();
|
|
76
|
+
|
|
77
|
+
for (const [key, value] of Object.entries(body)) {
|
|
78
|
+
if (value === undefined || value === null) continue;
|
|
79
|
+
|
|
80
|
+
if (Array.isArray(value)) {
|
|
81
|
+
for (const element of value) {
|
|
82
|
+
if (element !== undefined && element !== null) form.append(key, String(element));
|
|
83
|
+
}
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
form.set(key, typeof value === 'object' ? JSON.stringify(value) : String(value));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return form.toString();
|
|
91
|
+
}
|
|
92
|
+
|
|
32
93
|
/**
|
|
33
94
|
* `*` matches any run of characters; anything else is literal.
|
|
34
95
|
*
|
|
@@ -148,6 +209,12 @@ export function createHttpConnector(options: HttpConnectorOptions): Connector {
|
|
|
148
209
|
|
|
149
210
|
const url = new URL(options.baseUrl.replace(/\/$/, '') + buildPath(template, args, mapper));
|
|
150
211
|
const headers = new Headers({ accept: 'application/json' });
|
|
212
|
+
|
|
213
|
+
// After `accept`, which is a default worth overriding, and before the
|
|
214
|
+
// operation's own header parameters, which are narrower than a header
|
|
215
|
+
// declared once for the whole connector.
|
|
216
|
+
for (const [key, value] of Object.entries(options.headers ?? {})) headers.set(key, value);
|
|
217
|
+
|
|
151
218
|
const body = collect(args, mapper, 'body');
|
|
152
219
|
|
|
153
220
|
for (const [key, value] of Object.entries(collect(args, mapper, 'query'))) {
|
|
@@ -174,7 +241,11 @@ export function createHttpConnector(options: HttpConnectorOptions): Connector {
|
|
|
174
241
|
}
|
|
175
242
|
|
|
176
243
|
const hasBody = Object.keys(body).length > 0;
|
|
177
|
-
|
|
244
|
+
const contentType = bodyContentType(mapper);
|
|
245
|
+
// Last, so it beats a connector-wide header. Which encoding an operation
|
|
246
|
+
// uses is the document's to state, and a blanket `content-type` that
|
|
247
|
+
// silently changed it would be a bug nobody could see from the manifest.
|
|
248
|
+
if (hasBody) headers.set('content-type', contentType);
|
|
178
249
|
|
|
179
250
|
// Auth is attached by core, from the manifest's auth kind or its strategy.
|
|
180
251
|
// A connector never sees a raw credential.
|
|
@@ -182,7 +253,7 @@ export function createHttpConnector(options: HttpConnectorOptions): Connector {
|
|
|
182
253
|
new Request(url.href, {
|
|
183
254
|
method,
|
|
184
255
|
headers,
|
|
185
|
-
...(hasBody ? { body:
|
|
256
|
+
...(hasBody ? { body: encodeBody(body, contentType) } : {}),
|
|
186
257
|
signal: context.provider.signal,
|
|
187
258
|
}),
|
|
188
259
|
);
|