@lanes-sh/link 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +144 -0
- package/bin/lanes +42 -0
- package/instructions/agents/lanes-link-scout.md +73 -0
- package/instructions/skills/lanes-link/SKILL.md +187 -0
- package/package.json +95 -0
- package/src/audit/chain.ts +278 -0
- package/src/audit/conformance.ts +374 -0
- package/src/audit/fanout.ts +97 -0
- package/src/audit/index.ts +218 -0
- package/src/audit/stdout.ts +60 -0
- package/src/auth/index.ts +220 -0
- package/src/auth/oauth/metadata.ts +75 -0
- package/src/auth/oauth/server.ts +385 -0
- package/src/auth/oauth/store.ts +215 -0
- package/src/auth/oidc.ts +206 -0
- package/src/auth/remote.ts +72 -0
- package/src/cli/argv.ts +94 -0
- package/src/cli/callback-page.ts +256 -0
- package/src/cli/commands/connect/accounts.ts +94 -0
- package/src/cli/commands/connect/authorise.ts +298 -0
- package/src/cli/commands/connect/client.ts +284 -0
- package/src/cli/commands/connect/index.ts +398 -0
- package/src/cli/commands/connect/outcome.ts +119 -0
- package/src/cli/commands/connect/requirements.ts +103 -0
- package/src/cli/commands/connect/scopes-gate.ts +146 -0
- package/src/cli/commands/connect/settle.ts +136 -0
- package/src/cli/commands/connect/setup.ts +276 -0
- package/src/cli/commands/mcp/assets.ts +189 -0
- package/src/cli/commands/mcp/harnesses.ts +143 -0
- package/src/cli/commands/mcp/list.ts +70 -0
- package/src/cli/commands/mcp/register.ts +199 -0
- package/src/cli/commands/mcp/stdio.ts +57 -0
- package/src/cli/commands/mcp.ts +22 -0
- package/src/cli/commands/operate/attach.ts +121 -0
- package/src/cli/commands/operate/audit.ts +119 -0
- package/src/cli/commands/operate/inspect.ts +355 -0
- package/src/cli/commands/operate/outputs.ts +206 -0
- package/src/cli/commands/operate/policy.ts +80 -0
- package/src/cli/commands/operate/serve.ts +55 -0
- package/src/cli/commands/operate/status.ts +133 -0
- package/src/cli/commands/operate/token.ts +70 -0
- package/src/cli/commands/operate.ts +27 -0
- package/src/cli/commands/owner/memory.ts +110 -0
- package/src/cli/commands/owner/shared.ts +125 -0
- package/src/cli/commands/owner/skills.ts +92 -0
- package/src/cli/commands/owner/vault.ts +140 -0
- package/src/cli/commands/owner.ts +46 -0
- package/src/cli/commands/profile/removal.ts +278 -0
- package/src/cli/commands/profile/remove.ts +315 -0
- package/src/cli/commands/profile.ts +156 -0
- package/src/cli/commands/secrets.ts +176 -0
- package/src/cli/commands/setup.ts +150 -0
- package/src/cli/commands/target.ts +310 -0
- package/src/cli/config-edit.ts +397 -0
- package/src/cli/endpoint-url.ts +77 -0
- package/src/cli/identity.ts +109 -0
- package/src/cli/lanes.ts +78 -0
- package/src/cli/main.ts +333 -0
- package/src/cli/oauth-error.ts +13 -0
- package/src/cli/oauth-exchange.ts +146 -0
- package/src/cli/oauth.ts +354 -0
- package/src/cli/output.ts +184 -0
- package/src/cli/prompt.ts +180 -0
- package/src/cli/publish.ts +185 -0
- package/src/cli/runtime/discovery.ts +123 -0
- package/src/cli/runtime/open.ts +342 -0
- package/src/cli/runtime/registry.ts +185 -0
- package/src/cli/runtime/select.ts +124 -0
- package/src/cli/runtime.ts +34 -0
- package/src/cli/scopes.ts +63 -0
- package/src/cli/usage.ts +111 -0
- package/src/cli/version.ts +25 -0
- package/src/connectivity/auth/README.md +36 -0
- package/src/connectivity/auth/api-key/index.ts +43 -0
- package/src/connectivity/auth/authorize.ts +49 -0
- package/src/connectivity/auth/basic/index.ts +68 -0
- package/src/connectivity/auth/bearer/index.ts +13 -0
- package/src/connectivity/auth/credential.ts +19 -0
- package/src/connectivity/auth/header/index.ts +18 -0
- package/src/connectivity/auth/index.ts +35 -0
- package/src/connectivity/auth/none/index.ts +12 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +261 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +64 -0
- package/src/connectivity/auth/oauth-authcode/provider.ts +279 -0
- package/src/connectivity/auth/oauth-authcode/refresh.ts +118 -0
- package/src/connectivity/auth/resolve.ts +61 -0
- package/src/connectivity/auth/strategy/index.ts +14 -0
- package/src/connectivity/capability.ts +164 -0
- package/src/connectivity/connector.ts +175 -0
- package/src/connectivity/context.ts +77 -0
- package/src/connectivity/index.ts +94 -0
- package/src/connectivity/mail/attachments.ts +368 -0
- package/src/connectivity/mail/compose.ts +73 -0
- package/src/connectivity/mail/index.ts +40 -0
- package/src/connectivity/mail/message.ts +82 -0
- package/src/connectivity/mail/nodemailer.d.ts +83 -0
- package/src/connectivity/mail/staging.ts +143 -0
- package/src/connectivity/mail/url.ts +301 -0
- package/src/connectivity/manifest/auth.ts +135 -0
- package/src/connectivity/manifest/bundles.ts +23 -0
- package/src/connectivity/manifest/connector.ts +168 -0
- package/src/connectivity/manifest/credential-ref.ts +73 -0
- package/src/connectivity/manifest/identity.ts +43 -0
- package/src/connectivity/manifest/index.ts +46 -0
- package/src/connectivity/manifest/primitives.ts +20 -0
- package/src/connectivity/manifest/provider.ts +208 -0
- package/src/connectivity/manifest/requirements.ts +146 -0
- package/src/connectivity/manifest/setup.ts +55 -0
- package/src/connectivity/provider.ts +163 -0
- package/src/connectivity/transports/README.md +33 -0
- package/src/connectivity/transports/composite/index.ts +68 -0
- package/src/connectivity/transports/dav/calendar.ts +217 -0
- package/src/connectivity/transports/dav/capabilities.ts +151 -0
- package/src/connectivity/transports/dav/client.ts +226 -0
- package/src/connectivity/transports/dav/contacts.ts +75 -0
- package/src/connectivity/transports/dav/ical.ts +412 -0
- package/src/connectivity/transports/dav/index.ts +143 -0
- package/src/connectivity/transports/dav/operations.ts +43 -0
- package/src/connectivity/transports/dav/request.ts +161 -0
- package/src/connectivity/transports/dav/xml.ts +123 -0
- package/src/connectivity/transports/factory.ts +181 -0
- package/src/connectivity/transports/fs/capabilities.ts +88 -0
- package/src/connectivity/transports/fs/commands.ts +258 -0
- package/src/connectivity/transports/fs/index.ts +121 -0
- package/src/connectivity/transports/fs/operations.ts +45 -0
- package/src/connectivity/transports/fs/paths.ts +120 -0
- package/src/connectivity/transports/fs/result.ts +12 -0
- package/src/connectivity/transports/http/index.ts +255 -0
- package/src/connectivity/transports/imap/attachment.ts +166 -0
- package/src/connectivity/transports/imap/capabilities.ts +158 -0
- package/src/connectivity/transports/imap/client.ts +398 -0
- package/src/connectivity/transports/imap/commands.ts +385 -0
- package/src/connectivity/transports/imap/index.ts +152 -0
- package/src/connectivity/transports/imap/operations.ts +64 -0
- package/src/connectivity/transports/imap/parse.ts +130 -0
- package/src/connectivity/transports/imap/parser.ts +272 -0
- package/src/connectivity/transports/imap/result.ts +15 -0
- package/src/connectivity/transports/imap/send.ts +92 -0
- package/src/connectivity/transports/imap/socket.ts +111 -0
- package/src/connectivity/transports/imap/utf7.ts +136 -0
- package/src/connectivity/transports/index.ts +20 -0
- package/src/connectivity/transports/local/index.ts +173 -0
- package/src/connectivity/transports/mcp/index.ts +215 -0
- package/src/deployments/README.md +63 -0
- package/src/deployments/adapters/audit-blob.ts +203 -0
- package/src/deployments/adapters/filesystem.ts +184 -0
- package/src/deployments/adapters/gcp-secret-manager.ts +492 -0
- package/src/deployments/adapters/gcs.ts +191 -0
- package/src/deployments/adapters/otlp.ts +128 -0
- package/src/deployments/adapters/s3.ts +195 -0
- package/src/deployments/azure/README.md +21 -0
- package/src/deployments/bootstrap.ts +177 -0
- package/src/deployments/deploy.ts +290 -0
- package/src/deployments/driver.ts +157 -0
- package/src/deployments/drivers.ts +35 -0
- package/src/deployments/gcp/Dockerfile +70 -0
- package/src/deployments/gcp/cloudbuild.yaml +31 -0
- package/src/deployments/gcp/driver.ts +175 -0
- package/src/deployments/gcp/gcloud.ts +178 -0
- package/src/deployments/gcp/provision.ts +290 -0
- package/src/deployments/gcp/survey.ts +319 -0
- package/src/deployments/local/README.md +12 -0
- package/src/deployments/prepare.ts +257 -0
- package/src/deployments/steps.ts +137 -0
- package/src/deployments/target.ts +295 -0
- package/src/deployments/upload.ts +207 -0
- package/src/dispatch/context.ts +195 -0
- package/src/dispatch/dispatch.ts +350 -0
- package/src/dispatch/index.ts +32 -0
- package/src/dispatch/staging.ts +102 -0
- package/src/policy/index.ts +179 -0
- package/src/policy/limits.ts +77 -0
- package/src/profile/authorization.ts +81 -0
- package/src/profile/files.ts +71 -0
- package/src/profile/index.ts +76 -0
- package/src/profile/layout.ts +123 -0
- package/src/profile/load.ts +199 -0
- package/src/profile/primitives.ts +45 -0
- package/src/profile/schema.ts +347 -0
- package/src/profile/secret-detection.ts +162 -0
- package/src/profile/targets.ts +152 -0
- package/src/profile/workspace.ts +262 -0
- package/src/providers/custom/index.ts +21 -0
- package/src/providers/custom/load.ts +115 -0
- package/src/providers/custom/template.ts +156 -0
- package/src/providers/example/provider.ts +207 -0
- package/src/providers/google/calendar/index.ts +66 -0
- package/src/providers/google/calendar/redact.ts +40 -0
- package/src/providers/google/contacts/index.ts +50 -0
- package/src/providers/google/contacts/redact.ts +21 -0
- package/src/providers/google/docs/index.ts +45 -0
- package/src/providers/google/drive/hints.ts +28 -0
- package/src/providers/google/drive/index.ts +34 -0
- package/src/providers/google/drive/redact.ts +39 -0
- package/src/providers/google/drive-mcp/index.ts +21 -0
- package/src/providers/google/gmail/api.ts +42 -0
- package/src/providers/google/gmail/attachment.ts +142 -0
- package/src/providers/google/gmail/hints.ts +55 -0
- package/src/providers/google/gmail/index.ts +112 -0
- package/src/providers/google/gmail/redact.ts +56 -0
- package/src/providers/google/gmail/send.ts +365 -0
- package/src/providers/google/gmail-mcp/index.ts +35 -0
- package/src/providers/google/index.ts +10 -0
- package/src/providers/google/shared/oauth.ts +122 -0
- package/src/providers/google/shared/scopes.ts +99 -0
- package/src/providers/google/shared/setup.ts +80 -0
- package/src/providers/google/sheets/hints.ts +45 -0
- package/src/providers/google/sheets/index.ts +70 -0
- package/src/providers/google/sheets/redact.ts +45 -0
- package/src/providers/google/specs/calendar.v3.json +1829 -0
- package/src/providers/google/specs/docs.v1.json +381 -0
- package/src/providers/google/specs/drive.v3.json +2208 -0
- package/src/providers/google/specs/gmail.v1.json +2578 -0
- package/src/providers/google/specs/people.v1.json +506 -0
- package/src/providers/google/specs/sheets.v4.json +1269 -0
- package/src/providers/google/specs/tasks.v1.json +840 -0
- package/src/providers/google/specs/vendor.ts +661 -0
- package/src/providers/google/tasks/index.ts +53 -0
- package/src/providers/google/tasks/redact.ts +34 -0
- package/src/providers/harness.ts +95 -0
- package/src/providers/icloud/calendar/index.ts +27 -0
- package/src/providers/icloud/contacts/index.ts +17 -0
- package/src/providers/icloud/drive/index.ts +47 -0
- package/src/providers/icloud/index.ts +8 -0
- package/src/providers/icloud/mail/index.ts +37 -0
- package/src/providers/icloud/shared/setup.ts +66 -0
- package/src/providers/index.ts +93 -0
- package/src/providers/linear/index.ts +11 -0
- package/src/providers/linear/scopes.ts +7 -0
- package/src/providers/memory/provider.ts +429 -0
- package/src/providers/notion/index.ts +19 -0
- package/src/providers/owner.ts +49 -0
- package/src/providers/scopes.ts +26 -0
- package/src/providers/setup/plan.ts +141 -0
- package/src/providers/setup/provider.ts +323 -0
- package/src/providers/shared/frontmatter.ts +119 -0
- package/src/providers/skills/provider.ts +283 -0
- package/src/providers/skills/store.ts +252 -0
- package/src/providers/vault/provider.ts +194 -0
- package/src/registry/index.ts +36 -0
- package/src/registry/policy-bridge.ts +32 -0
- package/src/registry/reconcile.ts +313 -0
- package/src/registry/registry.ts +240 -0
- package/src/secrets/document.ts +293 -0
- package/src/secrets/index.ts +154 -0
- package/src/secrets/system.ts +173 -0
- package/src/secrets/vault.ts +336 -0
- package/src/server/attachments.ts +197 -0
- package/src/server/container.ts +96 -0
- package/src/server/edge.ts +53 -0
- package/src/server/endpoint.ts +352 -0
- package/src/server/generations.ts +362 -0
- package/src/server/harness.ts +400 -0
- package/src/server/index.ts +331 -0
- package/src/server/logging.ts +41 -0
- package/src/server/mcp/build.ts +68 -0
- package/src/server/mcp/icon.ts +145 -0
- package/src/server/mcp/index.ts +32 -0
- package/src/server/mcp/instructions.ts +245 -0
- package/src/server/mcp/naming.ts +39 -0
- package/src/server/mcp/prompts.ts +78 -0
- package/src/server/mcp/resources.ts +106 -0
- package/src/server/mcp/routing.ts +117 -0
- package/src/server/mcp/schema.ts +78 -0
- package/src/server/mcp/tools.ts +186 -0
- package/src/server/mcp/visibility.ts +132 -0
- package/src/server/oauth.ts +222 -0
- package/src/server/rebinding.ts +53 -0
- package/src/server/stdio.ts +192 -0
- package/src/stores/blobs/conformance.ts +223 -0
- package/src/stores/blobs/index.ts +125 -0
- package/src/stores/blobs/testing.ts +49 -0
- package/src/stores/state/index.ts +247 -0
- package/src/stores/state/keys.ts +68 -0
- package/src/stores/state/testing.ts +41 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { SecretStore } from '#secrets';
|
|
2
|
+
import type { Config, ConnectionConfig } from '#profile';
|
|
3
|
+
import type { ProviderManifest } from '#connectivity';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Which connections belong to the same vendor account, and moving a credential
|
|
7
|
+
* when a provisional id turns out to be the wrong one.
|
|
8
|
+
*
|
|
9
|
+
* All of it is about one problem: a vendor is not a provider. iCloud is three
|
|
10
|
+
* providers on one Apple Account, so "the connections like this one" cannot be
|
|
11
|
+
* answered by comparing provider ids.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The credential group a provider belongs to, when it shares one with siblings. */
|
|
15
|
+
export function credentialApp(manifest: ProviderManifest): string | undefined {
|
|
16
|
+
const auth = manifest.auth;
|
|
17
|
+
return auth.kind === 'bearer' ||
|
|
18
|
+
auth.kind === 'api_key' ||
|
|
19
|
+
auth.kind === 'header' ||
|
|
20
|
+
auth.kind === 'basic'
|
|
21
|
+
? auth.app
|
|
22
|
+
: undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Connections belonging to the same vendor account as this provider would.
|
|
27
|
+
*
|
|
28
|
+
* `icloud_calendar` is a different provider from `icloud_mail` but the same
|
|
29
|
+
* Apple Account, so its connection id has to match — that is what makes both
|
|
30
|
+
* derive `icloud/<id>` and share the one app-specific password. Filtering on
|
|
31
|
+
* provider id alone would miss it and ask for the password three times.
|
|
32
|
+
*/
|
|
33
|
+
export function accountSiblings(
|
|
34
|
+
manifest: ProviderManifest,
|
|
35
|
+
config: Config,
|
|
36
|
+
registry: { manifest(id: string): ProviderManifest | undefined },
|
|
37
|
+
): ConnectionConfig[] {
|
|
38
|
+
const app = credentialApp(manifest);
|
|
39
|
+
|
|
40
|
+
return config.connections.filter((connection) => {
|
|
41
|
+
if (connection.provider === manifest.id) return true;
|
|
42
|
+
if (!app) return false;
|
|
43
|
+
const sibling = registry.manifest(connection.provider);
|
|
44
|
+
return sibling ? credentialApp(sibling) === app : false;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The one account a sibling provider already holds, if there is exactly one.
|
|
50
|
+
*
|
|
51
|
+
* Adopting its id is what turns the second and third `connect` of a family into
|
|
52
|
+
* "already stored" rather than a third identical password prompt. With two
|
|
53
|
+
* accounts in play there is nothing to infer, so it declines rather than
|
|
54
|
+
* guessing — the operator is asked, or names one with `--id`.
|
|
55
|
+
*/
|
|
56
|
+
export function siblingAccountId(
|
|
57
|
+
manifest: ProviderManifest,
|
|
58
|
+
config: Config,
|
|
59
|
+
registry: { manifest(id: string): ProviderManifest | undefined },
|
|
60
|
+
): string | undefined {
|
|
61
|
+
if (!credentialApp(manifest)) return undefined;
|
|
62
|
+
|
|
63
|
+
// Asked of the registry, not inferred from the id: `app` is a manifest field,
|
|
64
|
+
// and a provider is free to declare `app: icloud` under any name it likes.
|
|
65
|
+
const ids = new Set(
|
|
66
|
+
accountSiblings(manifest, config, registry)
|
|
67
|
+
.filter((connection) => connection.provider !== manifest.id)
|
|
68
|
+
.map((connection) => connection.id),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return ids.size === 1 ? [...ids][0] : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Whether an existing allow rule already covers the one we were about to add. */
|
|
75
|
+
export function matchesRule(existing: string, wanted: string): boolean {
|
|
76
|
+
return existing === '*' || existing === wanted;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Move a credential when a provisional id turns out to be the wrong one.
|
|
81
|
+
*
|
|
82
|
+
* Copy before delete: a crash between the two leaves the credential readable
|
|
83
|
+
* under the old name, where `doctor` will flag it, rather than gone.
|
|
84
|
+
*/
|
|
85
|
+
export async function moveCredential(
|
|
86
|
+
credentials: SecretStore,
|
|
87
|
+
from: string,
|
|
88
|
+
to: string,
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
const value = await credentials.get(from);
|
|
91
|
+
if (value === null) return;
|
|
92
|
+
await credentials.set(to, value);
|
|
93
|
+
await credentials.delete(from);
|
|
94
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { auth, discoverOAuthProtectedResourceMetadata } from '@modelcontextprotocol/client';
|
|
2
|
+
import { CredentialOAuthProvider } from '#connectivity/auth/index.ts';
|
|
3
|
+
import type { SecretStore } from '#secrets';
|
|
4
|
+
import type { ProviderManifest } from '#connectivity';
|
|
5
|
+
import { ConfigDocument } from '../../config-edit.ts';
|
|
6
|
+
import { captureOAuthCallback, defaultOpenBrowser, runOAuthFlow } from '../../oauth.ts';
|
|
7
|
+
import { ok, progress, style, warn } from '../../output.ts';
|
|
8
|
+
import { terminalPrompter, type Prompter } from '../../prompt.ts';
|
|
9
|
+
import { describeScopes, shortScope } from '../../scopes.ts';
|
|
10
|
+
import { BROKERED, BrokerError } from '#connectivity/auth/index.ts';
|
|
11
|
+
import { brokerExchangeVia } from '../../oauth-exchange.ts';
|
|
12
|
+
import { brokeredScopes, hostedClientRefusal, resolveOAuthClient } from './client.ts';
|
|
13
|
+
import { confirmScopes } from './scopes-gate.ts';
|
|
14
|
+
import { ensureOAuthApp } from './setup.ts';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Getting a token, and saying what it will be able to do first.
|
|
18
|
+
*
|
|
19
|
+
* Two paths, because two kinds of upstream: an MCP server publishes metadata
|
|
20
|
+
* worth discovering and the SDK drives it, while a plain REST API announces
|
|
21
|
+
* nothing and the manifest has to name its endpoints.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export function oauthProviderFor(
|
|
25
|
+
manifest: ProviderManifest,
|
|
26
|
+
connectionId: string,
|
|
27
|
+
credentials: SecretStore,
|
|
28
|
+
redirectUrl?: string,
|
|
29
|
+
openBrowser?: (url: URL) => void,
|
|
30
|
+
state?: string,
|
|
31
|
+
): CredentialOAuthProvider {
|
|
32
|
+
return new CredentialOAuthProvider({
|
|
33
|
+
manifest,
|
|
34
|
+
connectionId,
|
|
35
|
+
credentials,
|
|
36
|
+
scopes: manifest.auth.kind === 'oauth' ? manifest.auth.scopes : [],
|
|
37
|
+
...(redirectUrl ? { redirectUrl } : {}),
|
|
38
|
+
...(openBrowser ? { openBrowser } : {}),
|
|
39
|
+
...(state ? { state } : {}),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Drive the SDK's OAuth flow over a loopback callback.
|
|
45
|
+
*
|
|
46
|
+
* The SDK does discovery, registration, PKCE, and the exchange. We supply
|
|
47
|
+
* storage and a browser — and, for a manual registration, the client the vendor
|
|
48
|
+
* insists the operator supplies.
|
|
49
|
+
*/
|
|
50
|
+
export async function authorise(input: {
|
|
51
|
+
manifest: ProviderManifest;
|
|
52
|
+
connectionId: string;
|
|
53
|
+
credentials: SecretStore;
|
|
54
|
+
document: ConfigDocument;
|
|
55
|
+
changes: string[];
|
|
56
|
+
/** No connection of this provider exists yet, so its console setup is undone. */
|
|
57
|
+
firstForProvider: boolean;
|
|
58
|
+
/** How the operator spelled the target, so a refusal names a command they typed. */
|
|
59
|
+
target?: string;
|
|
60
|
+
profile: string;
|
|
61
|
+
/** `--own-client`: register a client rather than using the one a broker runs. */
|
|
62
|
+
ownClient?: boolean;
|
|
63
|
+
prompter?: Prompter;
|
|
64
|
+
/** The operator has already said yes to scopes broader than the provider needs. */
|
|
65
|
+
acceptBroadScopes?: boolean;
|
|
66
|
+
/** Injected for tests. The broker is the only thing `connect` fetches. */
|
|
67
|
+
fetch?: typeof globalThis.fetch;
|
|
68
|
+
}): Promise<void> {
|
|
69
|
+
const { manifest, connectionId, credentials, document, changes } = input;
|
|
70
|
+
const prompter = input.prompter ?? terminalPrompter;
|
|
71
|
+
const acceptBroadScopes = input.acceptBroadScopes === true;
|
|
72
|
+
if (manifest.auth.kind !== 'oauth') return;
|
|
73
|
+
|
|
74
|
+
// `authoriseDirect` owns the client decision now: a brokered provider has
|
|
75
|
+
// nothing to prompt for, and asking first would ask for what it does not need.
|
|
76
|
+
//
|
|
77
|
+
// A non-MCP connector has nothing to discover auth from: a REST API is a base
|
|
78
|
+
// URL, and where its authorization server lives is not something it
|
|
79
|
+
// announces. So the manifest names the endpoints and we run the flow
|
|
80
|
+
// directly — the same loopback listener, PKCE, and exchange, minus the
|
|
81
|
+
// discovery the SDK would otherwise do for us.
|
|
82
|
+
if (manifest.connector.kind !== 'mcp') {
|
|
83
|
+
await authoriseDirect(input);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// An MCP provider is always bring-your-own: `defineProvider` refuses a broker
|
|
88
|
+
// on one, because the SDK owns the exchange and there is no seam to route it.
|
|
89
|
+
if (manifest.auth.registration === 'manual') {
|
|
90
|
+
await ensureOAuthApp(input);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const serverUrl = manifest.connector.endpoint;
|
|
94
|
+
|
|
95
|
+
const callback = captureOAuthCallback({ label: manifest.name });
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const provider = oauthProviderFor(
|
|
99
|
+
manifest,
|
|
100
|
+
connectionId,
|
|
101
|
+
credentials,
|
|
102
|
+
callback.redirectUri,
|
|
103
|
+
(url) => {
|
|
104
|
+
progress(style.dim('Opening your browser to authorise…'));
|
|
105
|
+
progress(style.dim(`If it did not open: ${url.href}`));
|
|
106
|
+
defaultOpenBrowser(url.href);
|
|
107
|
+
},
|
|
108
|
+
callback.state,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// Request exactly the scopes the manifest declares, and say what they are.
|
|
112
|
+
//
|
|
113
|
+
// Pinning the set matters: left alone the SDK asks for the union of
|
|
114
|
+
// everything the resource advertises, so a vendor could widen a grant by
|
|
115
|
+
// editing its own metadata. But pinning is not the same as narrowing —
|
|
116
|
+
// Google's servers reject a subset of what they advertise, which is why
|
|
117
|
+
// the Gmail and Drive manifests carry the full set rather than the two
|
|
118
|
+
// scopes their docs name. Where we cannot reduce the grant we can at least
|
|
119
|
+
// refuse to make it quietly.
|
|
120
|
+
if (!(await confirmScopes(manifest, serverUrl, prompter, acceptBroadScopes))) {
|
|
121
|
+
throw new Error('Cancelled — nothing was authorised.');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const scope = manifest.auth.scopes.length > 0 ? manifest.auth.scopes.join(' ') : undefined;
|
|
125
|
+
|
|
126
|
+
const first = await auth(provider as never, {
|
|
127
|
+
serverUrl,
|
|
128
|
+
...(scope ? { scope } : {}),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (first === 'AUTHORIZED') {
|
|
132
|
+
progress(ok('already authorised'));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const { code, iss } = await callback.wait();
|
|
137
|
+
|
|
138
|
+
// `iss` is forwarded for RFC 9207 issuer validation: it is what lets the
|
|
139
|
+
// SDK detect an authorization-server mix-up before redeeming the code.
|
|
140
|
+
// Capturing it and dropping it would silently give up that defence.
|
|
141
|
+
await auth(provider as never, {
|
|
142
|
+
serverUrl,
|
|
143
|
+
authorizationCode: code,
|
|
144
|
+
...(scope ? { scope } : {}),
|
|
145
|
+
...(iss ? { iss } : {}),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (manifest.auth.registration === 'dynamic') {
|
|
149
|
+
// Worth saying out loud: this is the setup that did not happen.
|
|
150
|
+
changes.push(`registered with ${new URL(serverUrl).host} automatically`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
progress(ok('authorised'));
|
|
154
|
+
} finally {
|
|
155
|
+
await callback.close();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
void document;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* OAuth against a plain REST API, where there is nothing to discover.
|
|
163
|
+
*
|
|
164
|
+
* `runOAuthFlow` is the M1 path, kept because it turns out to be exactly what a
|
|
165
|
+
* non-MCP provider needs: give it endpoints, a client, and scopes, and it does
|
|
166
|
+
* the loopback listener, PKCE, and the exchange. The MCP branch above uses the
|
|
167
|
+
* SDK instead only because MCP servers publish metadata worth discovering.
|
|
168
|
+
*
|
|
169
|
+
* The token blob is written in the same shape `CredentialOAuthProvider` reads,
|
|
170
|
+
* so refresh, expiry, and staleness reporting all work the same afterwards —
|
|
171
|
+
* a connection should not behave differently at runtime because of how it was
|
|
172
|
+
* authorised.
|
|
173
|
+
*/
|
|
174
|
+
async function authoriseDirect(input: {
|
|
175
|
+
manifest: ProviderManifest;
|
|
176
|
+
connectionId: string;
|
|
177
|
+
credentials: SecretStore;
|
|
178
|
+
document: ConfigDocument;
|
|
179
|
+
changes: string[];
|
|
180
|
+
firstForProvider: boolean;
|
|
181
|
+
target?: string;
|
|
182
|
+
profile: string;
|
|
183
|
+
ownClient?: boolean;
|
|
184
|
+
prompter?: Prompter;
|
|
185
|
+
acceptBroadScopes?: boolean;
|
|
186
|
+
fetch?: typeof globalThis.fetch;
|
|
187
|
+
}): Promise<void> {
|
|
188
|
+
const { manifest, connectionId, credentials } = input;
|
|
189
|
+
const prompter = input.prompter ?? terminalPrompter;
|
|
190
|
+
if (manifest.auth.kind !== 'oauth') return;
|
|
191
|
+
|
|
192
|
+
const { authorize_url: authorizeUrl, token_url: tokenUrl, app } = manifest.auth;
|
|
193
|
+
if (!authorizeUrl || !tokenUrl) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`Provider "${manifest.id}" uses a ${manifest.connector.kind} connector, which cannot discover ` +
|
|
196
|
+
'its authorization server. Its manifest must set auth.authorize_url and auth.token_url.',
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const client = await resolveOAuthClient({
|
|
201
|
+
manifest,
|
|
202
|
+
credentials,
|
|
203
|
+
document: input.document,
|
|
204
|
+
changes: input.changes,
|
|
205
|
+
firstForProvider: input.firstForProvider,
|
|
206
|
+
ownClient: input.ownClient === true,
|
|
207
|
+
target: input.target ?? manifest.id,
|
|
208
|
+
profile: input.profile,
|
|
209
|
+
...(input.prompter ? { prompter: input.prompter } : {}),
|
|
210
|
+
...(input.fetch ? { fetch: input.fetch } : {}),
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const scopes =
|
|
214
|
+
client.kind === 'brokered'
|
|
215
|
+
? brokeredScopes(manifest.auth.scopes, client.config).scopes
|
|
216
|
+
: manifest.auth.scopes;
|
|
217
|
+
|
|
218
|
+
if (
|
|
219
|
+
!(await confirmScopes(
|
|
220
|
+
manifest,
|
|
221
|
+
authorizeUrl,
|
|
222
|
+
prompter,
|
|
223
|
+
input.acceptBroadScopes === true,
|
|
224
|
+
scopes,
|
|
225
|
+
))
|
|
226
|
+
) {
|
|
227
|
+
throw new Error('Cancelled — nothing was authorised.');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let tokens;
|
|
231
|
+
try {
|
|
232
|
+
tokens = await runOAuthFlow({
|
|
233
|
+
authorizeUrl,
|
|
234
|
+
clientId: client.kind === 'own' ? client.clientId : client.config.clientId,
|
|
235
|
+
...(client.kind === 'own'
|
|
236
|
+
? { tokenUrl, clientSecret: client.clientSecret }
|
|
237
|
+
: {
|
|
238
|
+
exchange: brokerExchangeVia({
|
|
239
|
+
url: client.url,
|
|
240
|
+
...(input.fetch ? { fetch: input.fetch } : {}),
|
|
241
|
+
}),
|
|
242
|
+
}),
|
|
243
|
+
scopes,
|
|
244
|
+
connectionLabel: manifest.name,
|
|
245
|
+
...(manifest.auth.authorize_params
|
|
246
|
+
? { authorizeParams: manifest.auth.authorize_params }
|
|
247
|
+
: {}),
|
|
248
|
+
onPrompt: (url) => {
|
|
249
|
+
progress(style.dim('Opening your browser to authorise…'));
|
|
250
|
+
progress(style.dim(`If it did not open: ${url}`));
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
} catch (cause) {
|
|
254
|
+
// A broker refusal after consent is worth its own sentence: the operator
|
|
255
|
+
// has already approved the screen, and "nothing was stored" is the fact
|
|
256
|
+
// they need before they decide whether to try again.
|
|
257
|
+
if (cause instanceof BrokerError && client.kind === 'brokered') {
|
|
258
|
+
throw hostedClientRefusal({
|
|
259
|
+
manifest,
|
|
260
|
+
target: input.target ?? manifest.id,
|
|
261
|
+
profile: input.profile,
|
|
262
|
+
operator: manifest.auth.broker?.operator ?? 'this project',
|
|
263
|
+
cause: cause.message,
|
|
264
|
+
...(cause.notice ? { notice: cause.notice } : {}),
|
|
265
|
+
...(cause.docsUrl ? { docsUrl: cause.docsUrl } : {}),
|
|
266
|
+
afterConsent: true,
|
|
267
|
+
// A replayed code is not solved by an hour in a console, and the broker
|
|
268
|
+
// is the one that knows which of its refusals are.
|
|
269
|
+
ownClient: cause.ownClient,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
throw cause;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
await credentials.set(
|
|
276
|
+
`${manifest.id}/${connectionId}`,
|
|
277
|
+
JSON.stringify({
|
|
278
|
+
access_token: tokens.accessToken,
|
|
279
|
+
refresh_token: tokens.refreshToken,
|
|
280
|
+
token_type: 'Bearer',
|
|
281
|
+
expires_in: tokens.expiresIn,
|
|
282
|
+
expires_at: Date.now() + tokens.expiresIn * 1000,
|
|
283
|
+
scope: tokens.scope,
|
|
284
|
+
issuer: new URL(authorizeUrl).origin,
|
|
285
|
+
...(tokens.idToken ? { id_token: tokens.idToken } : {}),
|
|
286
|
+
// Which client issued this, stamped once at connect.
|
|
287
|
+
//
|
|
288
|
+
// A property of the token, not of the profile: a refresh token minted by
|
|
289
|
+
// one client is refused by another, so an operator who registers a client
|
|
290
|
+
// of their own six months from now must not drag existing connections
|
|
291
|
+
// onto it. Absent means "the profile's own", which every credential
|
|
292
|
+
// written before this feature already is.
|
|
293
|
+
...(client.kind === 'brokered' ? { authorized_via: BROKERED } : {}),
|
|
294
|
+
}),
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
progress(ok('authorised'));
|
|
298
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BROKER_ORIGIN_ENV,
|
|
3
|
+
BrokerError,
|
|
4
|
+
brokerConfig,
|
|
5
|
+
brokerOriginOverride,
|
|
6
|
+
type BrokerConfig,
|
|
7
|
+
} from '#connectivity/auth/index.ts';
|
|
8
|
+
import type { ProviderManifest } from '#connectivity';
|
|
9
|
+
import type { SecretStore } from '#secrets';
|
|
10
|
+
import { ConfigDocument } from '../../config-edit.ts';
|
|
11
|
+
import { progress, style, warn } from '../../output.ts';
|
|
12
|
+
import type { Prompter } from '../../prompt.ts';
|
|
13
|
+
import { shortScope } from '../../scopes.ts';
|
|
14
|
+
import { declareOwnClient, ensureOAuthApp } from './setup.ts';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Which OAuth client this authorisation uses, and whether it can be used.
|
|
18
|
+
*
|
|
19
|
+
* Two arrangements, and the manifest does not pick between them — the profile
|
|
20
|
+
* does. Declaring an `oauth_apps` entry means "I registered a client, use it";
|
|
21
|
+
* leaving it out means "use the one the broker operates". So a profile that has
|
|
22
|
+
* already taken the trouble to register one is never moved off it, and everyone
|
|
23
|
+
* else gets the path with no console in it.
|
|
24
|
+
*
|
|
25
|
+
* Everything here happens *before the browser opens*. A refusal after consent
|
|
26
|
+
* has been given is a refusal the operator has already paid for, so the
|
|
27
|
+
* checks that can be made early are made early.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export type OAuthClient =
|
|
31
|
+
| { readonly kind: 'own'; readonly clientId: string; readonly clientSecret: string }
|
|
32
|
+
| { readonly kind: 'brokered'; readonly url: string; readonly config: BrokerConfig };
|
|
33
|
+
|
|
34
|
+
export interface ClientChoice {
|
|
35
|
+
readonly manifest: ProviderManifest;
|
|
36
|
+
readonly credentials: SecretStore;
|
|
37
|
+
readonly document: ConfigDocument;
|
|
38
|
+
readonly changes: string[];
|
|
39
|
+
/** No connection of this provider exists yet, so its console setup is undone. */
|
|
40
|
+
readonly firstForProvider: boolean;
|
|
41
|
+
/** `--own-client`: register one rather than using the client the broker runs. */
|
|
42
|
+
readonly ownClient: boolean;
|
|
43
|
+
/** How the operator spelled the target, so a refusal names a command they typed. */
|
|
44
|
+
readonly target: string;
|
|
45
|
+
readonly profile: string;
|
|
46
|
+
readonly prompter?: Prompter | undefined;
|
|
47
|
+
readonly fetch?: typeof globalThis.fetch | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function resolveOAuthClient(input: ClientChoice): Promise<OAuthClient> {
|
|
51
|
+
const { manifest, credentials, document } = input;
|
|
52
|
+
if (manifest.auth.kind !== 'oauth') {
|
|
53
|
+
throw new Error(`Provider "${manifest.id}" does not use OAuth.`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const { app, broker } = manifest.auth;
|
|
57
|
+
|
|
58
|
+
if (!broker || (await profileHasOwnClient(app, document, credentials)) || input.ownClient) {
|
|
59
|
+
if (input.ownClient && broker && !hasClientPrompts(manifest)) {
|
|
60
|
+
// `defineProvider` permits a broker with no prompts — a provider with no
|
|
61
|
+
// bring-your-own path is a legal thing to be. This is where that absence
|
|
62
|
+
// becomes a sentence rather than a prompt for a value nothing collects.
|
|
63
|
+
throw new Error(
|
|
64
|
+
`${manifest.name} has no bring-your-own client path: it does not describe what to ` +
|
|
65
|
+
`register. Drop --own-client to authorise against the client ${broker.operator} operates.`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
await ensureOAuthApp({
|
|
69
|
+
manifest,
|
|
70
|
+
credentials,
|
|
71
|
+
document,
|
|
72
|
+
changes: input.changes,
|
|
73
|
+
firstForProvider: input.firstForProvider,
|
|
74
|
+
...(input.prompter ? { prompter: input.prompter } : {}),
|
|
75
|
+
});
|
|
76
|
+
if (input.ownClient) declareOwnClient(document, manifest, input.changes);
|
|
77
|
+
|
|
78
|
+
const [clientId, clientSecret] = app
|
|
79
|
+
? await Promise.all([
|
|
80
|
+
credentials.get(`${app}/client_id`),
|
|
81
|
+
credentials.get(`${app}/client_secret`),
|
|
82
|
+
])
|
|
83
|
+
: [null, null];
|
|
84
|
+
|
|
85
|
+
if (!clientId || !clientSecret) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`No OAuth client stored for "${app}". Run: lanes link connect ${manifest.id}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return { kind: 'own', clientId, clientSecret };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Before the fetch, so it is said even when the broker cannot be reached.
|
|
94
|
+
// The danger is not someone who set this deliberately; it is the variable
|
|
95
|
+
// still exported in a shell three days later, quietly sending a real
|
|
96
|
+
// authorization code somewhere other than where the operator believes.
|
|
97
|
+
const overridden = brokerOriginOverride();
|
|
98
|
+
if (overridden) {
|
|
99
|
+
progress(
|
|
100
|
+
warn(
|
|
101
|
+
`${BROKER_ORIGIN_ENV} is set — the authorization code will be exchanged at ${overridden}, ` +
|
|
102
|
+
`not by ${broker.operator}.`,
|
|
103
|
+
),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let config: BrokerConfig;
|
|
108
|
+
try {
|
|
109
|
+
config = await brokerConfig(broker.url, input.fetch ?? globalThis.fetch);
|
|
110
|
+
} catch (cause) {
|
|
111
|
+
throw hostedClientRefusal({
|
|
112
|
+
manifest,
|
|
113
|
+
target: input.target,
|
|
114
|
+
profile: input.profile,
|
|
115
|
+
operator: broker.operator,
|
|
116
|
+
cause: cause instanceof Error ? cause.message : String(cause),
|
|
117
|
+
...(cause instanceof BrokerError && cause.notice ? { notice: cause.notice } : {}),
|
|
118
|
+
docsUrl: broker.docs_url,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!config.open) {
|
|
123
|
+
throw hostedClientRefusal({
|
|
124
|
+
manifest,
|
|
125
|
+
target: input.target,
|
|
126
|
+
profile: input.profile,
|
|
127
|
+
operator: broker.operator,
|
|
128
|
+
cause: 'it is not accepting new connections.',
|
|
129
|
+
...(config.notice ? { notice: config.notice } : {}),
|
|
130
|
+
docsUrl: config.docsUrl ?? broker.docs_url,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const { unsupported } = brokeredScopes(manifest.auth.scopes, config);
|
|
135
|
+
if (unsupported.length > 0) {
|
|
136
|
+
throw hostedClientRefusal({
|
|
137
|
+
manifest,
|
|
138
|
+
target: input.target,
|
|
139
|
+
profile: input.profile,
|
|
140
|
+
operator: broker.operator,
|
|
141
|
+
cause:
|
|
142
|
+
`it is not registered for ${unsupported.length === 1 ? 'a scope' : `${unsupported.length} scopes`} ` +
|
|
143
|
+
`${manifest.name} needs:\n ${unsupported.map(shortScope).join('\n ')}`,
|
|
144
|
+
docsUrl: config.docsUrl ?? broker.docs_url,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (config.capacity && config.capacity.cap > 0) {
|
|
149
|
+
const left = config.capacity.cap - config.capacity.accounts;
|
|
150
|
+
// Advisory, never a refusal. The vendor is the authority on its own cap and
|
|
151
|
+
// still admits accounts that have already granted once, so refusing here
|
|
152
|
+
// would lock out exactly the people it would still let through.
|
|
153
|
+
if (left <= 10) {
|
|
154
|
+
// `warn` formats; `progress` is what puts it on the stream. Called bare it
|
|
155
|
+
// builds the sentence and drops it, which is how this one went unsaid.
|
|
156
|
+
progress(
|
|
157
|
+
warn(
|
|
158
|
+
`The ${broker.operator} client is near capacity (${config.capacity.accounts} of ${config.capacity.cap} accounts).`,
|
|
159
|
+
),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
progress(
|
|
165
|
+
style.dim(
|
|
166
|
+
`Authorising against the OAuth client ${broker.operator} operates — nothing to register.`,
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
return { kind: 'brokered', url: broker.url, config };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Whether this profile holds a client of its own.
|
|
175
|
+
*
|
|
176
|
+
* The config entry is the declaration, but the store is consulted too: someone
|
|
177
|
+
* who placed the two values by hand and whose config lost the block keeps their
|
|
178
|
+
* client rather than being quietly moved onto a different one, where every
|
|
179
|
+
* existing refresh token would stop working.
|
|
180
|
+
*/
|
|
181
|
+
async function profileHasOwnClient(
|
|
182
|
+
app: string | undefined,
|
|
183
|
+
document: ConfigDocument,
|
|
184
|
+
credentials: SecretStore,
|
|
185
|
+
): Promise<boolean> {
|
|
186
|
+
if (!app) return false;
|
|
187
|
+
if (document.getIn(['oauth_apps', app]) !== undefined) return true;
|
|
188
|
+
|
|
189
|
+
const [id, secret] = await Promise.all([
|
|
190
|
+
credentials.get(`${app}/client_id`),
|
|
191
|
+
credentials.get(`${app}/client_secret`),
|
|
192
|
+
]);
|
|
193
|
+
return Boolean(id && secret);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function hasClientPrompts(manifest: ProviderManifest): boolean {
|
|
197
|
+
return (manifest.setup?.prompts ?? []).some((prompt) => prompt.scope === 'shared');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* What is actually asked for, and what the broker cannot grant.
|
|
202
|
+
*
|
|
203
|
+
* The identity scopes are appended rather than assumed: they are what lets the
|
|
204
|
+
* broker tell one caller's refresh from another's, and the broker says which
|
|
205
|
+
* ones it wants so it can change them without a new CLI. They are added *only*
|
|
206
|
+
* on this path — for a client the operator registered there is nobody to
|
|
207
|
+
* identify to, and asking would cost them a re-consent and two extra lines in
|
|
208
|
+
* their own console for no benefit at all.
|
|
209
|
+
*/
|
|
210
|
+
export function brokeredScopes(
|
|
211
|
+
wanted: readonly string[],
|
|
212
|
+
config: BrokerConfig,
|
|
213
|
+
): { readonly scopes: readonly string[]; readonly unsupported: readonly string[] } {
|
|
214
|
+
// An empty `scopes_supported` means the broker did not say, not that it
|
|
215
|
+
// supports nothing. Treating silence as refusal would break every connection
|
|
216
|
+
// against a broker that simply does not advertise.
|
|
217
|
+
const unsupported =
|
|
218
|
+
config.scopesSupported.length > 0
|
|
219
|
+
? wanted.filter((scope) => !config.scopesSupported.includes(scope))
|
|
220
|
+
: [];
|
|
221
|
+
|
|
222
|
+
const scopes = [...wanted];
|
|
223
|
+
for (const scope of config.identityScopes) if (!scopes.includes(scope)) scopes.push(scope);
|
|
224
|
+
|
|
225
|
+
return { scopes, unsupported };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The refusal that names the way out, with the command already filled in. */
|
|
229
|
+
export function hostedClientRefusal(input: {
|
|
230
|
+
manifest: ProviderManifest;
|
|
231
|
+
target: string;
|
|
232
|
+
profile: string;
|
|
233
|
+
operator: string;
|
|
234
|
+
cause: string;
|
|
235
|
+
notice?: string | undefined;
|
|
236
|
+
docsUrl?: string | undefined;
|
|
237
|
+
/** Consent has already been given, so say so before saying nothing was kept. */
|
|
238
|
+
afterConsent?: boolean | undefined;
|
|
239
|
+
/**
|
|
240
|
+
* Whether registering a client of your own is the way past this.
|
|
241
|
+
*
|
|
242
|
+
* The broker knows and this does not: a spent capacity and a replayed
|
|
243
|
+
* authorization code are both a 4xx, and offering an hour in a cloud console
|
|
244
|
+
* as the fix for the second is worse than offering nothing. Defaults to true
|
|
245
|
+
* because the refusals raised before the browser opens are all of the first
|
|
246
|
+
* kind — a broker that is closed, unreachable, or short a scope.
|
|
247
|
+
*/
|
|
248
|
+
ownClient?: boolean | undefined;
|
|
249
|
+
}): Error {
|
|
250
|
+
const lines = [
|
|
251
|
+
`${input.manifest.name} could not be authorised against the OAuth client ${input.operator} operates.`,
|
|
252
|
+
'',
|
|
253
|
+
];
|
|
254
|
+
|
|
255
|
+
if (input.afterConsent) {
|
|
256
|
+
lines.push(
|
|
257
|
+
' You approved the consent screen, but the token exchange was refused, so nothing',
|
|
258
|
+
' was stored.',
|
|
259
|
+
'',
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// The reason is the broker's to word, so a spent capacity, a suspension and a
|
|
264
|
+
// maintenance window can read differently without shipping a new CLI.
|
|
265
|
+
lines.push(` ${input.notice ?? input.cause}`, '');
|
|
266
|
+
if (input.notice) lines.push(` (${input.cause})`, '');
|
|
267
|
+
|
|
268
|
+
lines.push(' Nothing was written and no account was connected.');
|
|
269
|
+
|
|
270
|
+
if (input.ownClient !== false) {
|
|
271
|
+
lines.push(
|
|
272
|
+
'',
|
|
273
|
+
' Register your own OAuth client instead:',
|
|
274
|
+
` lanes link connect ${input.target} --profile ${input.profile} --own-client`,
|
|
275
|
+
'',
|
|
276
|
+
' That walks through the vendor’s console once and then covers every account on',
|
|
277
|
+
' this profile.',
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (input.docsUrl) lines.push(` ${input.docsUrl}`);
|
|
282
|
+
|
|
283
|
+
return new Error(lines.join('\n'));
|
|
284
|
+
}
|