@lanes-sh/link 0.2.1 → 0.3.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 +21 -8
- package/instructions/skills/lanes-link/SKILL.md +59 -14
- package/package.json +1 -1
- package/src/auth/index.ts +3 -1
- package/src/auth/oauth/metadata.ts +83 -9
- package/src/auth/oauth/redirects.ts +70 -0
- package/src/auth/oauth/server.ts +49 -69
- package/src/auth/oauth/store.ts +19 -5
- package/src/cli/argv.ts +50 -0
- package/src/cli/brand.ts +178 -0
- package/src/cli/callback-page.ts +108 -128
- package/src/cli/commands/connect/accounts.ts +5 -0
- package/src/cli/commands/connect/assertion.ts +187 -0
- package/src/cli/commands/connect/authorise.ts +61 -17
- package/src/cli/commands/connect/client.ts +37 -9
- package/src/cli/commands/connect/discover.ts +94 -0
- package/src/cli/commands/connect/family.ts +72 -0
- package/src/cli/commands/connect/index.ts +113 -115
- package/src/cli/commands/connect/method.ts +237 -0
- package/src/cli/commands/connect/outcome.ts +42 -1
- package/src/cli/commands/connect/pasted-token.ts +66 -0
- package/src/cli/commands/connect/requirements.ts +60 -8
- package/src/cli/commands/connect/setup.ts +16 -5
- package/src/cli/commands/connect/target-note.ts +34 -0
- package/src/cli/commands/identity.ts +258 -0
- package/src/cli/commands/knowledge/index.ts +390 -0
- package/src/cli/commands/knowledge/migrate.ts +180 -0
- package/src/cli/commands/knowledge/setup.ts +144 -0
- package/src/cli/commands/knowledge.ts +10 -0
- package/src/cli/commands/mcp/harnesses.ts +16 -2
- package/src/cli/commands/mcp/register.ts +9 -1
- package/src/cli/commands/mcp/stdio.ts +21 -0
- package/src/cli/commands/operate/dashboard.ts +107 -0
- package/src/cli/commands/operate/findings.ts +151 -0
- package/src/cli/commands/operate/inspect.ts +56 -158
- package/src/cli/commands/operate/outputs.ts +38 -11
- package/src/cli/commands/operate/serve.ts +3 -0
- package/src/cli/commands/operate/token.ts +1 -1
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/profile/declare.ts +154 -0
- package/src/cli/commands/profile/removal.ts +17 -0
- package/src/cli/commands/profile.ts +83 -35
- package/src/cli/commands/setup.ts +22 -6
- package/src/cli/commands/target.ts +65 -83
- package/src/cli/config-edit.ts +48 -144
- package/src/cli/config-repair.ts +186 -0
- package/src/cli/dashboard-page.ts +284 -0
- package/src/cli/dashboard-shell.ts +125 -0
- package/src/cli/identity.ts +12 -1
- package/src/cli/main.ts +68 -4
- package/src/cli/oauth-callback.ts +187 -0
- package/src/cli/oauth-exchange.ts +57 -15
- package/src/cli/oauth.ts +67 -177
- package/src/cli/output.ts +21 -5
- package/src/cli/provider-marks.ts +45 -0
- package/src/cli/runtime/open.ts +67 -49
- package/src/cli/runtime/registry.ts +60 -2
- package/src/cli/runtime/select.ts +26 -13
- package/src/cli/runtime/vault.ts +61 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection.ts +357 -0
- package/src/cli/usage.ts +32 -9
- package/src/connectivity/auth/README.md +7 -1
- package/src/connectivity/auth/basic/index.ts +1 -1
- package/src/connectivity/auth/index.ts +14 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
- package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
- package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
- package/src/connectivity/auth/oauth-jwt/README.md +33 -0
- package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
- package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
- package/src/connectivity/auth/resolve.ts +1 -1
- package/src/connectivity/auth/token.ts +11 -0
- package/src/connectivity/index.ts +2 -0
- package/src/connectivity/manifest/auth.ts +99 -2
- package/src/connectivity/manifest/identity.ts +12 -0
- package/src/connectivity/manifest/index.ts +3 -1
- package/src/connectivity/manifest/provider.ts +37 -8
- package/src/connectivity/manifest/requirements.ts +109 -6
- package/src/deployments/adapters/filesystem.ts +10 -1
- package/src/deployments/adapters/github-api.ts +106 -0
- package/src/deployments/adapters/github-commit.ts +103 -0
- package/src/deployments/adapters/github-repo.ts +356 -0
- package/src/deployments/adapters/github-testing.ts +258 -0
- package/src/deployments/adapters/github.ts +125 -0
- package/src/deployments/deploy.ts +48 -19
- package/src/deployments/driver.ts +8 -1
- package/src/deployments/gcp/driver.ts +9 -1
- package/src/deployments/gcp/survey.ts +3 -0
- package/src/deployments/knowledge.ts +119 -0
- package/src/deployments/prepare.ts +2 -2
- package/src/deployments/servable.ts +81 -0
- package/src/deployments/target.ts +3 -2
- package/src/deployments/upload.ts +2 -1
- package/src/dispatch/dispatch.ts +1 -1
- package/src/profile/authorization.ts +13 -4
- package/src/profile/identity.ts +60 -0
- package/src/profile/index.ts +15 -5
- package/src/profile/knowledge.ts +124 -0
- package/src/profile/load.ts +17 -5
- package/src/profile/primitives.ts +24 -1
- package/src/profile/schema.ts +72 -4
- package/src/profile/targets.ts +74 -114
- package/src/profile/workspace.ts +71 -84
- package/src/providers/google/calendar/index.ts +2 -0
- package/src/providers/google/contacts/index.ts +2 -0
- package/src/providers/google/docs/index.ts +2 -0
- package/src/providers/google/drive/index.ts +2 -0
- package/src/providers/google/gmail/index.ts +2 -0
- package/src/providers/google/gmail-imap/index.ts +125 -0
- package/src/providers/google/index.ts +2 -1
- package/src/providers/google/shared/oauth.ts +18 -6
- package/src/providers/google/shared/service-account.ts +110 -0
- package/src/providers/google/shared/setup.ts +5 -2
- package/src/providers/google/sheets/index.ts +2 -0
- package/src/providers/google/tasks/index.ts +2 -0
- package/src/providers/identity/provider.ts +166 -0
- package/src/providers/index.ts +3 -0
- package/src/providers/owner.ts +10 -2
- package/src/providers/scopes.ts +2 -0
- package/src/providers/setup/plan.ts +31 -9
- package/src/providers/setup/provider.ts +23 -0
- package/src/providers/slack/index.ts +81 -33
- package/src/providers/slack/oauth.ts +103 -0
- package/src/providers/slack/scopes.ts +37 -0
- package/src/server/container.ts +18 -1
- package/src/server/cors.ts +252 -0
- package/src/server/dashboard.ts +208 -0
- package/src/server/endpoint.ts +57 -4
- package/src/server/generation.ts +1 -0
- package/src/server/generations.ts +13 -2
- package/src/server/harness.ts +20 -3
- package/src/server/index.ts +76 -12
- package/src/server/mcp/build.ts +1 -1
- package/src/server/mcp/index.ts +1 -0
- package/src/server/mcp/instructions.ts +62 -5
- package/src/server/mcp/visibility.ts +42 -0
- package/src/stores/blobs/route.ts +123 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
4
|
+
import { ASSERTION_GRANT, parseAssertionKey } from '#connectivity/auth/index.ts';
|
|
5
|
+
import { credentialRefForConnection, type AuthAssertion, type ProviderManifest } from '#connectivity';
|
|
6
|
+
import type { SecretStore } from '#secrets';
|
|
7
|
+
import { ok, progress, style } from '../../output.ts';
|
|
8
|
+
import { terminalPrompter, type Prompter } from '../../prompt.ts';
|
|
9
|
+
import { askForSetup, printSetup } from './setup.ts';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The counterpart to `authorise.ts`, for a provider authenticated by a key.
|
|
13
|
+
*
|
|
14
|
+
* The same conversation, minus the browser: show the console work, take what it
|
|
15
|
+
* produced, put it in the store. What differs is that there are two halves and
|
|
16
|
+
* they have different lifetimes — the key is one file covering every provider
|
|
17
|
+
* of a vendor, and the account it acts as is per connection. So the key is
|
|
18
|
+
* asked for once per profile and the subject once per account, and re-running
|
|
19
|
+
* `connect` for a second provider asks for neither.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Take a path, or the file's contents.
|
|
24
|
+
*
|
|
25
|
+
* A key arrives as a downloaded file, and a downloaded file is a path — asking
|
|
26
|
+
* someone to open it and paste several hundred characters of PEM into a
|
|
27
|
+
* terminal is asking for a truncated key and an error two steps later. Pasting
|
|
28
|
+
* still works for anyone who would rather, which is why this looks at the shape
|
|
29
|
+
* of the answer rather than at a flag.
|
|
30
|
+
*
|
|
31
|
+
* What is *stored* is always the contents. A path is a fact about one machine
|
|
32
|
+
* and this credential outlives it: the same profile is read by a deployed
|
|
33
|
+
* revision that has no such file, and a store holding a path would fail there
|
|
34
|
+
* with an error about the filesystem rather than about the credential.
|
|
35
|
+
*/
|
|
36
|
+
async function contentsOf(answer: string): Promise<string> {
|
|
37
|
+
if (answer.startsWith('{')) return answer;
|
|
38
|
+
|
|
39
|
+
const expanded = answer.startsWith('~/') ? resolve(homedir(), answer.slice(2)) : answer;
|
|
40
|
+
const path = isAbsolute(expanded) ? expanded : resolve(process.cwd(), expanded);
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
return await readFile(path, 'utf8');
|
|
44
|
+
} catch {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`No file at ${path}. Give the path to the key the console downloaded, or paste its contents.`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Ensure the profile holds the key, and this connection holds a pointer to it.
|
|
53
|
+
*
|
|
54
|
+
* Idempotent on the key for the same reason `ensureStaticCredential` is: seven
|
|
55
|
+
* providers share one file, and the second `connect` must find it already there
|
|
56
|
+
* and say so rather than asking again. The subject is not idempotent in the
|
|
57
|
+
* same way — it is per connection, and every connection is a first one.
|
|
58
|
+
*/
|
|
59
|
+
export async function authoriseWithKey(input: {
|
|
60
|
+
readonly manifest: ProviderManifest;
|
|
61
|
+
readonly assertion: AuthAssertion;
|
|
62
|
+
readonly connectionId: string;
|
|
63
|
+
readonly credentials: SecretStore;
|
|
64
|
+
readonly changes: string[];
|
|
65
|
+
/** The operator asked to be asked again — a rotated key, or the wrong account. */
|
|
66
|
+
readonly replace: boolean;
|
|
67
|
+
readonly prompter?: Prompter;
|
|
68
|
+
}): Promise<void> {
|
|
69
|
+
const { manifest, assertion, connectionId, credentials, changes, replace } = input;
|
|
70
|
+
const prompter = input.prompter ?? terminalPrompter;
|
|
71
|
+
|
|
72
|
+
const keyPrompt = assertion.setup.prompts.find((prompt) => prompt.scope === 'shared');
|
|
73
|
+
if (!keyPrompt) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Provider "${manifest.id}" declares auth.assertion with no shared prompt, so there is no key to ask for.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const stored = await credentials.has(assertion.key_ref);
|
|
80
|
+
|
|
81
|
+
// `stored && !replace`, and deliberately not `reuseStoredCredential` — which
|
|
82
|
+
// also asks whether the connection id is still provisional. That question is
|
|
83
|
+
// right for a per-connection credential, where a provisional id means an
|
|
84
|
+
// earlier connect stored something no server ever accepted. It is wrong here:
|
|
85
|
+
// this key is shared across the whole profile, so a *first* connect of a
|
|
86
|
+
// second provider is provisional by definition while the key it finds was
|
|
87
|
+
// stored deliberately and may already be in use. Asking again there made a
|
|
88
|
+
// stored key unusable — the preflight said it had everything it needed, and
|
|
89
|
+
// the run that followed immediately asked for it.
|
|
90
|
+
if (stored && !replace) {
|
|
91
|
+
// The walkthrough still prints. The key is shared and already held, but the
|
|
92
|
+
// *sharing* is per resource and per product: whoever connects Sheets after
|
|
93
|
+
// Drive has a key that works and a spreadsheet nobody has shared with it
|
|
94
|
+
// yet, and that failure looks exactly like a broken credential.
|
|
95
|
+
printSetup(
|
|
96
|
+
manifest,
|
|
97
|
+
`The key is already stored at ${assertion.key_ref}, so it is not asked for again — but what ` +
|
|
98
|
+
'it can reach is granted per resource, and this is the first connection of this one.',
|
|
99
|
+
assertion.setup,
|
|
100
|
+
);
|
|
101
|
+
progress(ok(`key already stored (${assertion.key_ref})`));
|
|
102
|
+
// Named because this is the only way past a key that is stored and wrong,
|
|
103
|
+
// and a well-formed key for the wrong project is refused by the token
|
|
104
|
+
// endpoint rather than here.
|
|
105
|
+
progress(style.dim(` To replace it: lanes link connect ${manifest.id} --replace`));
|
|
106
|
+
} else {
|
|
107
|
+
if (stored) {
|
|
108
|
+
progress(
|
|
109
|
+
style.dim(
|
|
110
|
+
`Replacing ${assertion.key_ref} — what is stored is overwritten only once you have entered a new one.`,
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const answers = await askForSetup(
|
|
116
|
+
manifest,
|
|
117
|
+
[keyPrompt],
|
|
118
|
+
`Stored at ${assertion.key_ref}, in the credential store — never in config.`,
|
|
119
|
+
prompter,
|
|
120
|
+
assertion.setup,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const contents = await contentsOf(answers.get(keyPrompt.key)!);
|
|
124
|
+
|
|
125
|
+
// Parsed before it is written, so the wrong file is caught here rather than
|
|
126
|
+
// at the token endpoint. The two candidates live on adjacent pages of the
|
|
127
|
+
// same console and `parseAssertionKey` knows how to tell them apart.
|
|
128
|
+
const key = parseAssertionKey(contents, assertion.key_ref);
|
|
129
|
+
|
|
130
|
+
await credentials.set(assertion.key_ref, contents);
|
|
131
|
+
changes.push(`${assertion.key_ref} stored`);
|
|
132
|
+
progress(ok(`key stored — it acts as ${key.client_email}`));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const subject = await askForSubject(assertion, prompter);
|
|
136
|
+
|
|
137
|
+
await credentials.set(
|
|
138
|
+
credentialRefForConnection(manifest, connectionId)!,
|
|
139
|
+
JSON.stringify({
|
|
140
|
+
grant: ASSERTION_GRANT,
|
|
141
|
+
key_ref: assertion.key_ref,
|
|
142
|
+
...(subject ? { subject } : {}),
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
progress(ok(subject ? `authenticated as ${subject}` : 'authenticated'));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Who the key acts as, where it acts as anyone.
|
|
151
|
+
*
|
|
152
|
+
* Blank is a real answer when delegation is `optional` — the key is then an
|
|
153
|
+
* identity in its own right. It is refused when delegation is `required`,
|
|
154
|
+
* because the alternative is a credential that authenticates perfectly and
|
|
155
|
+
* finds nothing: there is no mailbox or contact list belonging to a key, so
|
|
156
|
+
* every call would return an empty result rather than an error, which is the
|
|
157
|
+
* worst way for this to be wrong.
|
|
158
|
+
*/
|
|
159
|
+
async function askForSubject(
|
|
160
|
+
assertion: AuthAssertion,
|
|
161
|
+
prompter: Prompter,
|
|
162
|
+
): Promise<string | undefined> {
|
|
163
|
+
const optional = assertion.delegation === 'optional';
|
|
164
|
+
|
|
165
|
+
if (!prompter.interactive) {
|
|
166
|
+
if (optional) return undefined;
|
|
167
|
+
throw new Error(
|
|
168
|
+
`This provider can only reach an account by acting as someone, and this run is ` +
|
|
169
|
+
`non-interactive so there is nobody to ask who. Re-run in a terminal.`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
progress();
|
|
174
|
+
const answer = await prompter.ask(
|
|
175
|
+
` ${assertion.subject_label}${optional ? style.dim(' [none]') : ''}`,
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
if (answer.length > 0) return answer;
|
|
179
|
+
|
|
180
|
+
if (optional) return undefined;
|
|
181
|
+
|
|
182
|
+
throw new Error(
|
|
183
|
+
`${assertion.subject_label} is required here: this provider has nothing that belongs to a ` +
|
|
184
|
+
'key, so a connection that acts as nobody would authenticate and then find every ' +
|
|
185
|
+
'mailbox, list and calendar empty.',
|
|
186
|
+
);
|
|
187
|
+
}
|
|
@@ -16,9 +16,14 @@ import { ensureOAuthApp } from './setup.ts';
|
|
|
16
16
|
/**
|
|
17
17
|
* Getting a token, and saying what it will be able to do first.
|
|
18
18
|
*
|
|
19
|
-
* Two paths,
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* Two paths, and what chooses between them is not the kind of upstream but
|
|
20
|
+
* whether the manifest names its own endpoints. Without them there is nothing
|
|
21
|
+
* to authorise against but what the server advertises, so the SDK discovers it
|
|
22
|
+
* and drives the flow. With them there is nothing left to discover, and the
|
|
23
|
+
* flow is ours — which is the only arrangement in which a client somebody else
|
|
24
|
+
* holds can redeem the code. A REST API never announces an authorization
|
|
25
|
+
* server, so it is always on the second path; an MCP server may be on either.
|
|
26
|
+
* See ADR-040.
|
|
22
27
|
*/
|
|
23
28
|
|
|
24
29
|
/**
|
|
@@ -65,8 +70,8 @@ export async function authorise(input: {
|
|
|
65
70
|
/** How the operator spelled the target, so a refusal names a command they typed. */
|
|
66
71
|
target?: string;
|
|
67
72
|
profile: string;
|
|
68
|
-
/**
|
|
69
|
-
|
|
73
|
+
/** Which OAuth client, when the operator chose one. `undefined` keeps today's precedence. */
|
|
74
|
+
client?: 'own' | 'hosted' | undefined;
|
|
70
75
|
prompter?: Prompter;
|
|
71
76
|
/** The operator has already said yes to scopes broader than the provider needs. */
|
|
72
77
|
acceptBroadScopes?: boolean;
|
|
@@ -86,13 +91,24 @@ export async function authorise(input: {
|
|
|
86
91
|
// announces. So the manifest names the endpoints and we run the flow
|
|
87
92
|
// directly — the same loopback listener, PKCE, and exchange, minus the
|
|
88
93
|
// discovery the SDK would otherwise do for us.
|
|
89
|
-
|
|
94
|
+
//
|
|
95
|
+
// An MCP connector takes that path too when it names both endpoints, and that
|
|
96
|
+
// is the *only* thing declaring them means. The SDK's flow ends by posting to
|
|
97
|
+
// the token endpoint with whatever `clientInformation()` returned, which is
|
|
98
|
+
// fine for a client the operator holds and impossible for one held by a
|
|
99
|
+
// broker — so a provider whose client lives somewhere else opts out here
|
|
100
|
+
// rather than discovering it after consent. Nothing is lost by opting out:
|
|
101
|
+
// discovery is all the SDK was doing that this does not, and a manifest that
|
|
102
|
+
// names its endpoints has nothing left to discover. Notion, Linear, and
|
|
103
|
+
// Google's two MCP servers name neither and are untouched. See ADR-040.
|
|
104
|
+
if (
|
|
105
|
+
manifest.connector.kind !== 'mcp' ||
|
|
106
|
+
(manifest.auth.authorize_url !== undefined && manifest.auth.token_url !== undefined)
|
|
107
|
+
) {
|
|
90
108
|
await authoriseDirect(input);
|
|
91
109
|
return;
|
|
92
110
|
}
|
|
93
111
|
|
|
94
|
-
// An MCP provider is always bring-your-own: `defineProvider` refuses a broker
|
|
95
|
-
// on one, because the SDK owns the exchange and there is no seam to route it.
|
|
96
112
|
if (manifest.auth.registration === 'manual') {
|
|
97
113
|
await ensureOAuthApp(input);
|
|
98
114
|
}
|
|
@@ -187,7 +203,7 @@ async function authoriseDirect(input: {
|
|
|
187
203
|
firstForProvider: boolean;
|
|
188
204
|
target?: string;
|
|
189
205
|
profile: string;
|
|
190
|
-
|
|
206
|
+
client?: 'own' | 'hosted' | undefined;
|
|
191
207
|
prompter?: Prompter;
|
|
192
208
|
acceptBroadScopes?: boolean;
|
|
193
209
|
fetch?: typeof globalThis.fetch;
|
|
@@ -210,7 +226,7 @@ async function authoriseDirect(input: {
|
|
|
210
226
|
document: input.document,
|
|
211
227
|
changes: input.changes,
|
|
212
228
|
firstForProvider: input.firstForProvider,
|
|
213
|
-
|
|
229
|
+
client: input.client,
|
|
214
230
|
target: input.target ?? manifest.id,
|
|
215
231
|
profile: input.profile,
|
|
216
232
|
...(input.prompter ? { prompter: input.prompter } : {}),
|
|
@@ -234,19 +250,39 @@ async function authoriseDirect(input: {
|
|
|
234
250
|
throw new Error('Cancelled — nothing was authorised.');
|
|
235
251
|
}
|
|
236
252
|
|
|
253
|
+
// What a response carrying no refresh token means, which only the manifest
|
|
254
|
+
// knows: Google omitting one is a failure worth stopping for, Slack omitting
|
|
255
|
+
// one is the ordinary success. See `RefreshTokenPolicy`.
|
|
256
|
+
const refreshToken = {
|
|
257
|
+
required: manifest.auth.refresh_token === 'required',
|
|
258
|
+
vendor: manifest.name,
|
|
259
|
+
...(manifest.auth.revoke_url ? { revokeUrl: manifest.auth.revoke_url } : {}),
|
|
260
|
+
};
|
|
261
|
+
|
|
237
262
|
let tokens;
|
|
238
263
|
try {
|
|
239
264
|
tokens = await runOAuthFlow({
|
|
240
265
|
authorizeUrl,
|
|
241
|
-
clientId: client.kind === '
|
|
242
|
-
...(client.kind === '
|
|
243
|
-
? {
|
|
244
|
-
: {
|
|
266
|
+
clientId: client.kind === 'brokered' ? client.config.clientId : client.clientId,
|
|
267
|
+
...(client.kind === 'brokered'
|
|
268
|
+
? {
|
|
245
269
|
exchange: brokerExchangeVia({
|
|
246
270
|
url: client.url,
|
|
271
|
+
refreshToken,
|
|
247
272
|
...(input.fetch ? { fetch: input.fetch } : {}),
|
|
248
273
|
}),
|
|
274
|
+
}
|
|
275
|
+
: {
|
|
276
|
+
tokenUrl,
|
|
277
|
+
clientSecret: client.clientSecret,
|
|
249
278
|
}),
|
|
279
|
+
refreshToken,
|
|
280
|
+
// Only where the broker published one, which means only where the vendor
|
|
281
|
+
// refuses a loopback redirect. The broker owns the URL because the
|
|
282
|
+
// correct value depends on which deployment answered `/config`.
|
|
283
|
+
...(client.kind === 'brokered' && client.config.redirectUri
|
|
284
|
+
? { relayRedirect: client.config.redirectUri }
|
|
285
|
+
: {}),
|
|
250
286
|
scopes,
|
|
251
287
|
connectionLabel: manifest.name,
|
|
252
288
|
...(manifest.auth.authorize_params
|
|
@@ -283,10 +319,18 @@ async function authoriseDirect(input: {
|
|
|
283
319
|
`${manifest.id}/${connectionId}`,
|
|
284
320
|
JSON.stringify({
|
|
285
321
|
access_token: tokens.accessToken,
|
|
286
|
-
|
|
322
|
+
// Both omitted rather than defaulted where the vendor issues neither.
|
|
323
|
+
//
|
|
324
|
+
// A long-lived token has no refresh token and states no lifetime, and
|
|
325
|
+
// inventing an hour for it would have `doctor` reporting a healthy
|
|
326
|
+
// connection as stale forever while telling the operator to re-run a
|
|
327
|
+
// command that changes nothing. Absent is what `upstreamAccessToken`
|
|
328
|
+
// already reads as "hand back what is stored", which is correct here.
|
|
329
|
+
...(tokens.refreshToken ? { refresh_token: tokens.refreshToken } : {}),
|
|
287
330
|
token_type: 'Bearer',
|
|
288
|
-
|
|
289
|
-
|
|
331
|
+
...(tokens.expiresIn !== undefined
|
|
332
|
+
? { expires_in: tokens.expiresIn, expires_at: Date.now() + tokens.expiresIn * 1000 }
|
|
333
|
+
: {}),
|
|
290
334
|
scope: tokens.scope,
|
|
291
335
|
issuer: new URL(authorizeUrl).origin,
|
|
292
336
|
...(tokens.idToken ? { id_token: tokens.idToken } : {}),
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
brokerOriginOverride,
|
|
6
6
|
type BrokerConfig,
|
|
7
7
|
} from '#connectivity/auth/index.ts';
|
|
8
|
-
import type
|
|
8
|
+
import { hasOwnClientPath, type ProviderManifest } from '#connectivity';
|
|
9
9
|
import type { SecretStore } from '#secrets';
|
|
10
10
|
import { ConfigDocument } from '../../config-edit.ts';
|
|
11
11
|
import { progress, style, warn } from '../../output.ts';
|
|
@@ -38,8 +38,15 @@ export interface ClientChoice {
|
|
|
38
38
|
readonly changes: string[];
|
|
39
39
|
/** No connection of this provider exists yet, so its console setup is undone. */
|
|
40
40
|
readonly firstForProvider: boolean;
|
|
41
|
-
/**
|
|
42
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Which client, when the operator said which.
|
|
43
|
+
*
|
|
44
|
+
* `undefined` is the precedence this file has always applied and is what a
|
|
45
|
+
* provider with one browser route resolves to: a declared `oauth_apps` entry
|
|
46
|
+
* wins, otherwise the broker. The two explicit values come from the choice
|
|
47
|
+
* `connect` now prints, and they override that precedence in both directions.
|
|
48
|
+
*/
|
|
49
|
+
readonly client: 'own' | 'hosted' | undefined;
|
|
43
50
|
/** How the operator spelled the target, so a refusal names a command they typed. */
|
|
44
51
|
readonly target: string;
|
|
45
52
|
readonly profile: string;
|
|
@@ -54,9 +61,31 @@ export async function resolveOAuthClient(input: ClientChoice): Promise<OAuthClie
|
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
const { app, broker } = manifest.auth;
|
|
64
|
+
const ownClient = input.client === 'own';
|
|
65
|
+
// Hoisted: four of the branches below need the answer, and it reads the store.
|
|
66
|
+
const hasOwnClient = await profileHasOwnClient(app, document, credentials);
|
|
67
|
+
|
|
68
|
+
// Asked for outright, on a profile that registered a client of its own.
|
|
69
|
+
//
|
|
70
|
+
// Honoured rather than overruled, which is a change: the entry used to be
|
|
71
|
+
// final. It can be honoured safely because which client minted a token is
|
|
72
|
+
// stamped on the token — so this connection refreshes against the broker
|
|
73
|
+
// while every existing one keeps refreshing where it was issued. What it must
|
|
74
|
+
// not be is silent, because the profile's other connections do not move.
|
|
75
|
+
const insteadOfOwn = input.client === 'hosted' && broker !== undefined && hasOwnClient;
|
|
76
|
+
|
|
77
|
+
if (insteadOfOwn) {
|
|
78
|
+
progress(
|
|
79
|
+
style.dim(
|
|
80
|
+
`This profile has an OAuth client of its own, and this connection is being authorised ` +
|
|
81
|
+
`against ${broker.operator}'s instead. Existing connections are unaffected — they keep ` +
|
|
82
|
+
`refreshing against the client that issued them.`,
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
57
86
|
|
|
58
|
-
if (!broker || (
|
|
59
|
-
if (
|
|
87
|
+
if (!broker || (!insteadOfOwn && (hasOwnClient || ownClient))) {
|
|
88
|
+
if (ownClient && broker && !hasOwnClientPath(manifest)) {
|
|
60
89
|
// `defineProvider` permits a broker with no prompts — a provider with no
|
|
61
90
|
// bring-your-own path is a legal thing to be. This is where that absence
|
|
62
91
|
// becomes a sentence rather than a prompt for a value nothing collects.
|
|
@@ -73,7 +102,7 @@ export async function resolveOAuthClient(input: ClientChoice): Promise<OAuthClie
|
|
|
73
102
|
firstForProvider: input.firstForProvider,
|
|
74
103
|
...(input.prompter ? { prompter: input.prompter } : {}),
|
|
75
104
|
});
|
|
76
|
-
if (
|
|
105
|
+
if (ownClient) declareOwnClient(document, manifest, input.changes);
|
|
77
106
|
|
|
78
107
|
const [clientId, clientSecret] = app
|
|
79
108
|
? await Promise.all([
|
|
@@ -116,6 +145,7 @@ export async function resolveOAuthClient(input: ClientChoice): Promise<OAuthClie
|
|
|
116
145
|
cause: cause instanceof Error ? cause.message : String(cause),
|
|
117
146
|
...(cause instanceof BrokerError && cause.notice ? { notice: cause.notice } : {}),
|
|
118
147
|
docsUrl: broker.docs_url,
|
|
148
|
+
ownClient: hasOwnClientPath(manifest),
|
|
119
149
|
});
|
|
120
150
|
}
|
|
121
151
|
|
|
@@ -128,6 +158,7 @@ export async function resolveOAuthClient(input: ClientChoice): Promise<OAuthClie
|
|
|
128
158
|
cause: 'it is not accepting new connections.',
|
|
129
159
|
...(config.notice ? { notice: config.notice } : {}),
|
|
130
160
|
docsUrl: config.docsUrl ?? broker.docs_url,
|
|
161
|
+
ownClient: hasOwnClientPath(manifest),
|
|
131
162
|
});
|
|
132
163
|
}
|
|
133
164
|
|
|
@@ -193,9 +224,6 @@ async function profileHasOwnClient(
|
|
|
193
224
|
return Boolean(id && secret);
|
|
194
225
|
}
|
|
195
226
|
|
|
196
|
-
function hasClientPrompts(manifest: ProviderManifest): boolean {
|
|
197
|
-
return (manifest.setup?.prompts ?? []).some((prompt) => prompt.scope === 'shared');
|
|
198
|
-
}
|
|
199
227
|
|
|
200
228
|
/**
|
|
201
229
|
* What is actually asked for, and what the broker cannot grant.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { bearerTokenAsStored } from '#connectivity/auth/index.ts';
|
|
2
|
+
import type { AnyConnector, DiscoveredCapability, ProviderManifest } from '#connectivity';
|
|
3
|
+
import { createMcpConnector } from '#connectivity/transports';
|
|
4
|
+
import type { RegisteredProvider } from '#registry';
|
|
5
|
+
import type { SecretStore } from '#secrets';
|
|
6
|
+
import { progress, style } from '../../output.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Step three of `connect`: ask the upstream what it exposes.
|
|
10
|
+
*
|
|
11
|
+
* A manifest never declares capabilities for a proxied server — the server is
|
|
12
|
+
* the source of truth, and a declared list would go stale the moment the vendor
|
|
13
|
+
* ships. So this is the one part of connecting that talks to the thing being
|
|
14
|
+
* connected for a reason other than authentication.
|
|
15
|
+
*
|
|
16
|
+
* Its own file because the three cases have nothing to do with one another: an
|
|
17
|
+
* MCP server is asked over a session, an HTTP provider is read from a
|
|
18
|
+
* description on disk, and a local provider already knows. Interleaved with the
|
|
19
|
+
* config writing around them, that was three shapes wearing one `if`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* What discovery is actually doing, per kind, so the wait is explained.
|
|
24
|
+
*
|
|
25
|
+
* They differ enough to be worth saying: reading a local OpenAPI file is
|
|
26
|
+
* instant, while signing in to an IMAP server is a TLS handshake and a LOGIN
|
|
27
|
+
* against a host that sometimes takes its time.
|
|
28
|
+
*/
|
|
29
|
+
const DISCOVERY_NOTE: Record<string, string> = {
|
|
30
|
+
mcp: 'Discovering capabilities…',
|
|
31
|
+
http: 'Reading the API description…',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export async function discoverCapabilities(input: {
|
|
35
|
+
readonly entry: RegisteredProvider;
|
|
36
|
+
readonly manifest: ProviderManifest;
|
|
37
|
+
readonly connectionId: string;
|
|
38
|
+
readonly credentials: SecretStore;
|
|
39
|
+
readonly connectorFor: (providerId: string, connectionId: string) => AnyConnector | undefined;
|
|
40
|
+
/** Called with what was found, so the caller can cache and register it. */
|
|
41
|
+
readonly remember: (discovered: DiscoveredCapability[]) => Promise<void>;
|
|
42
|
+
}): Promise<DiscoveredCapability[]> {
|
|
43
|
+
const { entry, manifest, connectionId, credentials } = input;
|
|
44
|
+
|
|
45
|
+
if (manifest.connector.kind === 'local') return localCapabilities(entry, manifest);
|
|
46
|
+
|
|
47
|
+
progress(style.dim(DISCOVERY_NOTE[manifest.connector.kind] ?? 'Discovering capabilities…'));
|
|
48
|
+
|
|
49
|
+
// MCP is the one kind that does not use the runtime's connector here: it
|
|
50
|
+
// wants the token exactly as just written, without the refresh machinery that
|
|
51
|
+
// `bearerToken` wraps around it. Every other kind carries whatever credential
|
|
52
|
+
// it needs from the factory.
|
|
53
|
+
const connector =
|
|
54
|
+
manifest.connector.kind === 'mcp'
|
|
55
|
+
? createMcpConnector({
|
|
56
|
+
endpoint: manifest.connector.endpoint,
|
|
57
|
+
...(manifest.connector.headers ? { headers: manifest.connector.headers } : {}),
|
|
58
|
+
accessToken: () => bearerTokenAsStored(manifest, connectionId, credentials),
|
|
59
|
+
})
|
|
60
|
+
: input.connectorFor(manifest.id, connectionId);
|
|
61
|
+
|
|
62
|
+
if (!connector) return [];
|
|
63
|
+
|
|
64
|
+
// Discovery takes the manifest and nothing else. What a provider exposes is a
|
|
65
|
+
// property of the provider, not of an account — which is just as well,
|
|
66
|
+
// because the connection being created does not exist in config until the
|
|
67
|
+
// step after this one.
|
|
68
|
+
const discovered = await connector.discover({ manifest });
|
|
69
|
+
await input.remember(discovered);
|
|
70
|
+
return discovered;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Local capabilities carry their bundle from the manifest.
|
|
75
|
+
*
|
|
76
|
+
* Resources are included alongside tools: they need a policy grant too, and
|
|
77
|
+
* leaving them out would register a resource nothing is allowed to read.
|
|
78
|
+
*/
|
|
79
|
+
function localCapabilities(
|
|
80
|
+
entry: RegisteredProvider,
|
|
81
|
+
manifest: ProviderManifest,
|
|
82
|
+
): DiscoveredCapability[] {
|
|
83
|
+
if (!entry.definition) return [];
|
|
84
|
+
|
|
85
|
+
const bundleOf = (name: string): string | undefined =>
|
|
86
|
+
manifest.bundles?.find((candidate) => candidate.capabilities.includes(name))?.name;
|
|
87
|
+
|
|
88
|
+
return entry.definition.capabilities.map((capability) => ({
|
|
89
|
+
name: capability.name,
|
|
90
|
+
description: capability.description,
|
|
91
|
+
inputSchema: {},
|
|
92
|
+
...(bundleOf(capability.name) ? { bundle: bundleOf(capability.name)! } : {}),
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ProviderRegistry } from '#registry';
|
|
2
|
+
import { progress, style } from '../../output.ts';
|
|
3
|
+
import { credentialApp, familyNote } from './accounts.ts';
|
|
4
|
+
import { familyOutcome, type ConnectOutcome } from './outcome.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `lanes link connect icloud` — an account rather than a provider.
|
|
8
|
+
*
|
|
9
|
+
* Everyone models iCloud this way: Apple's own Settings, macOS Internet
|
|
10
|
+
* Accounts, Thunderbird, DAVx⁵. One authorisation, three services. It is three
|
|
11
|
+
* *providers* underneath because mail and calendars are different protocols,
|
|
12
|
+
* and because a policy line per provider is what lets someone allow
|
|
13
|
+
* `icloud_calendar.*` while never granting mail — but nobody should have to
|
|
14
|
+
* know that to connect their account.
|
|
15
|
+
*
|
|
16
|
+
* Its own file because it is its own subject: `runConnect` is five numbered
|
|
17
|
+
* steps that add one account, and this is the fan-out that turns one name into
|
|
18
|
+
* several of those. Keeping them together made the interesting half — how a
|
|
19
|
+
* partial failure is reported — read as a preamble to be scrolled past.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Which providers answer to this name as a shared account.
|
|
24
|
+
*
|
|
25
|
+
* Fewer than two is not a family. Asked of the registry rather than matched on
|
|
26
|
+
* the id, for the reason `siblingAccountId` gives: `app` is a manifest field,
|
|
27
|
+
* and a provider is free to declare `app: icloud` under any name it likes.
|
|
28
|
+
*/
|
|
29
|
+
export function familyMembers(registry: ProviderRegistry, name: string): readonly string[] {
|
|
30
|
+
return registry
|
|
31
|
+
.list()
|
|
32
|
+
.filter((candidate) => credentialApp(candidate.manifest) === name)
|
|
33
|
+
.map((candidate) => candidate.manifest.id);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Connect each member in turn, and report the account rather than the services.
|
|
38
|
+
*
|
|
39
|
+
* In sequence, and the order matters: the first settles the account id and
|
|
40
|
+
* stores the credential, and the rest find both already there.
|
|
41
|
+
*
|
|
42
|
+
* The id travels as a flag because the family members are addressed by their
|
|
43
|
+
* own names — `connect icloud.will` parses `will` off a target that is then
|
|
44
|
+
* thrown away, and recursing without it meant the command named an account and
|
|
45
|
+
* each member silently invented its own.
|
|
46
|
+
*
|
|
47
|
+
* `announced` is passed for the same reason and one axis over: the line naming
|
|
48
|
+
* the target belongs to the account, not to each service under it, so it is
|
|
49
|
+
* printed once here and suppressed in every member. Three copies of it is three
|
|
50
|
+
* times nothing new.
|
|
51
|
+
*/
|
|
52
|
+
export async function connectFamily<Options extends { readonly id?: string | undefined }>(input: {
|
|
53
|
+
readonly name: string;
|
|
54
|
+
readonly members: readonly string[];
|
|
55
|
+
readonly options: Options;
|
|
56
|
+
readonly namedId: string | undefined;
|
|
57
|
+
readonly connect: (
|
|
58
|
+
provider: string,
|
|
59
|
+
options: Options,
|
|
60
|
+
announced: boolean,
|
|
61
|
+
) => Promise<ConnectOutcome>;
|
|
62
|
+
}): Promise<ConnectOutcome> {
|
|
63
|
+
const { name, members, options, namedId, connect } = input;
|
|
64
|
+
|
|
65
|
+
progress(style.dim(familyNote(name, members)));
|
|
66
|
+
|
|
67
|
+
const inherited = { ...options, id: options.id ?? namedId };
|
|
68
|
+
const outcomes: ConnectOutcome[] = [];
|
|
69
|
+
for (const member of members) outcomes.push(await connect(member, inherited, true));
|
|
70
|
+
|
|
71
|
+
return familyOutcome(outcomes);
|
|
72
|
+
}
|