@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,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Talking to a service that holds an OAuth client secret this machine cannot.
|
|
3
|
+
*
|
|
4
|
+
* Three requests, one shape each, and no knowledge of whose client is on the
|
|
5
|
+
* other end — the URL arrives as declared manifest data. Both callers live in
|
|
6
|
+
* different components (the CLI performs the first exchange, the dispatcher
|
|
7
|
+
* refreshes while serving), and putting the wire format here is what stops the
|
|
8
|
+
* two drifting into disagreeing about it.
|
|
9
|
+
*
|
|
10
|
+
* Everything here is a plain `fetch` against an operator-declared origin. There
|
|
11
|
+
* is deliberately no retry: a broker that is down should surface as a refusal
|
|
12
|
+
* the operator can read, not as a command that hangs.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Stamped on a credential this broker issued, and read back at refresh.
|
|
17
|
+
*
|
|
18
|
+
* The one fact the refresh path cannot derive: which client minted the token it
|
|
19
|
+
* is holding. Config cannot answer it, because config can change afterwards.
|
|
20
|
+
*/
|
|
21
|
+
export const BROKERED = 'broker';
|
|
22
|
+
|
|
23
|
+
/** What the broker will authorise, and whether it is currently doing so. */
|
|
24
|
+
export interface BrokerConfig {
|
|
25
|
+
readonly clientId: string;
|
|
26
|
+
/** Every scope the client is registered for. Empty means "unstated". */
|
|
27
|
+
readonly scopesSupported: readonly string[];
|
|
28
|
+
/** Added to every request so the exchange returns an identity assertion. */
|
|
29
|
+
readonly identityScopes: readonly string[];
|
|
30
|
+
readonly open: boolean;
|
|
31
|
+
/** Why it is closed, or near capacity. The broker's words, printed verbatim. */
|
|
32
|
+
readonly notice: string | undefined;
|
|
33
|
+
readonly docsUrl: string | undefined;
|
|
34
|
+
/** How full the shared client is, when the broker says. Advisory. */
|
|
35
|
+
readonly capacity: { readonly accounts: number; readonly cap: number } | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A token response, in the vendor's own field names. */
|
|
39
|
+
export interface BrokerTokens {
|
|
40
|
+
readonly access_token?: string;
|
|
41
|
+
readonly refresh_token?: string;
|
|
42
|
+
readonly id_token?: string;
|
|
43
|
+
readonly expires_in?: number;
|
|
44
|
+
readonly scope?: string;
|
|
45
|
+
readonly token_type?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A refusal, carrying what the caller needs to explain it.
|
|
50
|
+
*
|
|
51
|
+
* `ownClient` is the broker saying "registering a client of your own is the way
|
|
52
|
+
* past this". It matters because the two failures look identical otherwise: a
|
|
53
|
+
* shared client at capacity and a replayed authorization code are both a 4xx,
|
|
54
|
+
* and only one of them is solved by an hour in a cloud console.
|
|
55
|
+
*/
|
|
56
|
+
export class BrokerError extends Error {
|
|
57
|
+
readonly status: number;
|
|
58
|
+
readonly code: string | undefined;
|
|
59
|
+
readonly notice: string | undefined;
|
|
60
|
+
readonly ownClient: boolean;
|
|
61
|
+
readonly docsUrl: string | undefined;
|
|
62
|
+
readonly retryAfterSeconds: number | undefined;
|
|
63
|
+
|
|
64
|
+
constructor(
|
|
65
|
+
message: string,
|
|
66
|
+
fields: {
|
|
67
|
+
status: number;
|
|
68
|
+
code?: string | undefined;
|
|
69
|
+
notice?: string | undefined;
|
|
70
|
+
ownClient?: boolean | undefined;
|
|
71
|
+
docsUrl?: string | undefined;
|
|
72
|
+
retryAfterSeconds?: number | undefined;
|
|
73
|
+
},
|
|
74
|
+
) {
|
|
75
|
+
super(message);
|
|
76
|
+
this.name = 'BrokerError';
|
|
77
|
+
this.status = fields.status;
|
|
78
|
+
this.code = fields.code;
|
|
79
|
+
this.notice = fields.notice;
|
|
80
|
+
this.ownClient = fields.ownClient === true;
|
|
81
|
+
this.docsUrl = fields.docsUrl;
|
|
82
|
+
this.retryAfterSeconds = fields.retryAfterSeconds;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type Fetch = typeof globalThis.fetch;
|
|
87
|
+
|
|
88
|
+
export const BROKER_ORIGIN_ENV = 'LANES_LINK_BROKER_ORIGIN';
|
|
89
|
+
|
|
90
|
+
/** Loopback, in the spellings a `URL` will hand back for one. */
|
|
91
|
+
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Aiming the exchange somewhere other than the origin the manifest declares.
|
|
95
|
+
*
|
|
96
|
+
* A broker running on this machine and a staging deployment are the same
|
|
97
|
+
* problem: everything about the flow is unchanged except which host holds the
|
|
98
|
+
* secret. An origin is the whole of the difference, so a provider keeps its own
|
|
99
|
+
* path and only the origin in front of it moves.
|
|
100
|
+
*
|
|
101
|
+
* It refuses rather than falls back, because the failure it would otherwise
|
|
102
|
+
* cause is the expensive kind — believing you are exercising a local broker
|
|
103
|
+
* while a real authorization code goes to production. A variable that is
|
|
104
|
+
* ignored when malformed is worse than one that stops the command.
|
|
105
|
+
*
|
|
106
|
+
* `http` is confined to loopback for the same reason the redirect is: off this
|
|
107
|
+
* machine it puts an authorization code on the wire in the clear.
|
|
108
|
+
*/
|
|
109
|
+
export function brokerOriginOverride(
|
|
110
|
+
env: Record<string, string | undefined> = process.env as Record<string, string | undefined>,
|
|
111
|
+
): string | undefined {
|
|
112
|
+
const raw = env[BROKER_ORIGIN_ENV]?.trim();
|
|
113
|
+
if (!raw) return undefined;
|
|
114
|
+
|
|
115
|
+
let url: URL;
|
|
116
|
+
try {
|
|
117
|
+
url = new URL(raw);
|
|
118
|
+
} catch {
|
|
119
|
+
throw new Error(`${BROKER_ORIGIN_ENV} is not a URL: ${raw}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
123
|
+
// `localhost:8080` is the mistake people make, and it *parses* — as a URL
|
|
124
|
+
// whose scheme is "localhost". Saying which scheme it read is what turns
|
|
125
|
+
// that from a baffling refusal into an obvious missing `http://`.
|
|
126
|
+
throw new Error(
|
|
127
|
+
`${BROKER_ORIGIN_ENV} must be an http or https URL. "${raw}" reads as scheme ` +
|
|
128
|
+
`"${url.protocol.replace(':', '')}" — a host and port with no scheme becomes one.`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
if (url.protocol === 'http:' && !LOOPBACK.has(url.hostname)) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`${BROKER_ORIGIN_ENV} may only be http for a loopback host. "${url.hostname}" over http ` +
|
|
134
|
+
`would put the authorization code on the wire in the clear — use https.`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return url.origin;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The `{success, data}` envelope, unwrapped, or thrown as a `BrokerError`. */
|
|
141
|
+
async function unwrap(response: Response, what: string): Promise<Record<string, unknown>> {
|
|
142
|
+
const text = await response.text();
|
|
143
|
+
let body: Record<string, unknown> = {};
|
|
144
|
+
try {
|
|
145
|
+
body = text ? (JSON.parse(text) as Record<string, unknown>) : {};
|
|
146
|
+
} catch {
|
|
147
|
+
// A proxy or a captive portal answering in HTML is a real failure mode, and
|
|
148
|
+
// "Unexpected token <" tells the operator nothing about what went wrong.
|
|
149
|
+
if (response.ok) {
|
|
150
|
+
throw new BrokerError(`${what} returned a response that was not JSON.`, {
|
|
151
|
+
status: response.status,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!response.ok || body['success'] === false) {
|
|
157
|
+
const retry = Number(response.headers.get('retry-after'));
|
|
158
|
+
throw new BrokerError(str(body['error']) ?? `${what} failed (${response.status}).`, {
|
|
159
|
+
status: response.status,
|
|
160
|
+
code: str(body['code']),
|
|
161
|
+
notice: str(body['notice']),
|
|
162
|
+
ownClient: body['own_client'] === true,
|
|
163
|
+
docsUrl: str(body['docs_url']),
|
|
164
|
+
retryAfterSeconds: Number.isFinite(retry) && retry > 0 ? retry : undefined,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const data = body['data'];
|
|
169
|
+
return data !== null && typeof data === 'object' ? (data as Record<string, unknown>) : body;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const str = (value: unknown): string | undefined =>
|
|
173
|
+
typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
174
|
+
|
|
175
|
+
const strings = (value: unknown): string[] =>
|
|
176
|
+
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
|
|
177
|
+
|
|
178
|
+
async function post(
|
|
179
|
+
url: string,
|
|
180
|
+
body: unknown,
|
|
181
|
+
what: string,
|
|
182
|
+
fetchImpl: Fetch,
|
|
183
|
+
headers: Record<string, string> = {},
|
|
184
|
+
): Promise<Record<string, unknown>> {
|
|
185
|
+
let response: Response;
|
|
186
|
+
try {
|
|
187
|
+
response = await fetchImpl(url, {
|
|
188
|
+
method: 'POST',
|
|
189
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
190
|
+
body: JSON.stringify(body),
|
|
191
|
+
});
|
|
192
|
+
} catch (cause) {
|
|
193
|
+
throw new BrokerError(`${what} could not be reached (${String(cause)}).`, { status: 0 });
|
|
194
|
+
}
|
|
195
|
+
return await unwrap(response, what);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function brokerConfig(
|
|
199
|
+
url: string,
|
|
200
|
+
fetchImpl: Fetch = globalThis.fetch,
|
|
201
|
+
): Promise<BrokerConfig> {
|
|
202
|
+
let response: Response;
|
|
203
|
+
try {
|
|
204
|
+
response = await fetchImpl(`${url}/config`, { headers: { accept: 'application/json' } });
|
|
205
|
+
} catch (cause) {
|
|
206
|
+
throw new BrokerError(`${url} could not be reached (${String(cause)}).`, { status: 0 });
|
|
207
|
+
}
|
|
208
|
+
const data = await unwrap(response, url);
|
|
209
|
+
|
|
210
|
+
const clientId = str(data['client_id']);
|
|
211
|
+
if (!clientId) {
|
|
212
|
+
throw new BrokerError(`${url} did not return a client id.`, { status: response.status });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const capacity = data['capacity'];
|
|
216
|
+
return {
|
|
217
|
+
clientId,
|
|
218
|
+
scopesSupported: strings(data['scopes_supported']),
|
|
219
|
+
identityScopes: strings(data['identity_scopes']),
|
|
220
|
+
open: data['status'] !== 'closed',
|
|
221
|
+
notice: str(data['notice']),
|
|
222
|
+
docsUrl: str(data['docs_url']),
|
|
223
|
+
capacity:
|
|
224
|
+
capacity !== null && typeof capacity === 'object'
|
|
225
|
+
? {
|
|
226
|
+
accounts: Number((capacity as Record<string, unknown>)['accounts']) || 0,
|
|
227
|
+
cap: Number((capacity as Record<string, unknown>)['cap']) || 0,
|
|
228
|
+
}
|
|
229
|
+
: undefined,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function brokerExchange(
|
|
234
|
+
url: string,
|
|
235
|
+
input: { code: string; codeVerifier: string; redirectUri: string },
|
|
236
|
+
fetchImpl: Fetch = globalThis.fetch,
|
|
237
|
+
): Promise<BrokerTokens> {
|
|
238
|
+
return (await post(
|
|
239
|
+
`${url}/exchange`,
|
|
240
|
+
{ code: input.code, code_verifier: input.codeVerifier, redirect_uri: input.redirectUri },
|
|
241
|
+
url,
|
|
242
|
+
fetchImpl,
|
|
243
|
+
)) as BrokerTokens;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export async function brokerRefresh(
|
|
247
|
+
url: string,
|
|
248
|
+
input: { refreshToken: string; idToken?: string | undefined },
|
|
249
|
+
fetchImpl: Fetch = globalThis.fetch,
|
|
250
|
+
): Promise<BrokerTokens> {
|
|
251
|
+
// The assertion goes in a header rather than the body so the broker can
|
|
252
|
+
// attribute and rate-limit the call before it parses anything, and so it does
|
|
253
|
+
// not land in whatever logs request bodies.
|
|
254
|
+
return (await post(
|
|
255
|
+
`${url}/refresh`,
|
|
256
|
+
{ refresh_token: input.refreshToken },
|
|
257
|
+
url,
|
|
258
|
+
fetchImpl,
|
|
259
|
+
input.idToken ? { authorization: `Bearer ${input.idToken}` } : {},
|
|
260
|
+
)) as BrokerTokens;
|
|
261
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { auth } from '@modelcontextprotocol/client';
|
|
2
|
+
import type { ProviderManifest } from '#connectivity';
|
|
3
|
+
import type { SecretStore } from '#secrets';
|
|
4
|
+
import { CredentialOAuthProvider, upstreamAccessToken } from './provider.ts';
|
|
5
|
+
import { refreshDirectly } from './refresh.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* OAuth 2.0 authorization code — the three-legged flow, with a refresh token
|
|
9
|
+
* held in the secret store.
|
|
10
|
+
*
|
|
11
|
+
* The only method here that needs more than a stored string: a browser round
|
|
12
|
+
* trip at connect time, a refresh on every use, and two different ways to run
|
|
13
|
+
* that refresh depending on whether the provider has a metadata document.
|
|
14
|
+
*
|
|
15
|
+
* The other flows the credential-type list names — JWT bearer, client
|
|
16
|
+
* credentials — are sibling folders that do not exist yet. See ../README.md.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export async function resolveUpstreamToken(
|
|
20
|
+
manifest: ProviderManifest,
|
|
21
|
+
connectionId: string,
|
|
22
|
+
credentials: SecretStore,
|
|
23
|
+
): Promise<string | null> {
|
|
24
|
+
if (manifest.auth.kind !== 'oauth') return null;
|
|
25
|
+
|
|
26
|
+
const provider = new CredentialOAuthProvider({
|
|
27
|
+
manifest,
|
|
28
|
+
connectionId,
|
|
29
|
+
credentials,
|
|
30
|
+
scopes: manifest.auth.scopes,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Where to re-run discovery from, if a refresh needs it. An `http` connector
|
|
34
|
+
// has no metadata document, so it names its authorization server in the
|
|
35
|
+
// manifest instead — and the refresh below only ever exchanges a stored
|
|
36
|
+
// refresh token, which needs the token endpoint and nothing else.
|
|
37
|
+
const endpoint =
|
|
38
|
+
manifest.connector.kind === 'mcp' ? manifest.connector.endpoint : manifest.auth.token_url;
|
|
39
|
+
|
|
40
|
+
return upstreamAccessToken({
|
|
41
|
+
connectionKey: `${manifest.id}.${connectionId}`,
|
|
42
|
+
provider,
|
|
43
|
+
async refresh(target) {
|
|
44
|
+
// No browser here on purpose: this path runs while serving requests, and
|
|
45
|
+
// a refresh that silently opened one would be both useless and alarming.
|
|
46
|
+
// If re-consent is genuinely needed the provider throws with the command
|
|
47
|
+
// that fixes it.
|
|
48
|
+
if (!endpoint) throw new Error(`Provider "${manifest.id}" declares no token endpoint.`);
|
|
49
|
+
|
|
50
|
+
// A non-MCP provider refreshes directly. The SDK's `auth()` wants to
|
|
51
|
+
// rediscover the authorization server first, which a REST API does not
|
|
52
|
+
// describe — it fails with "prepareTokenRequest() or authorizationCode is
|
|
53
|
+
// required", which says nothing about the real problem. Exchanging a
|
|
54
|
+
// stored refresh token needs the token endpoint and the client, and
|
|
55
|
+
// nothing else.
|
|
56
|
+
if (manifest.connector.kind !== 'mcp') {
|
|
57
|
+
return (await refreshDirectly(manifest, target, endpoint, credentials)) as never;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await auth(target as never, { serverUrl: endpoint });
|
|
61
|
+
return (await target.tokens()) as never;
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import type { SecretStore } from '#secrets';
|
|
2
|
+
import type { ProviderManifest } from '#connectivity';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The SDK's `OAuthClientProvider`, backed by our `SecretStore`.
|
|
6
|
+
*
|
|
7
|
+
* This one class is the whole reason a provider is now a manifest. The SDK
|
|
8
|
+
* drives discovery, registration, authorization, and refresh; we only say where
|
|
9
|
+
* the resulting material lives. **DCR and bring-your-own-client differ by a
|
|
10
|
+
* single method**: whether `clientInformation()` returns something.
|
|
11
|
+
*
|
|
12
|
+
* - returns a stored client → the SDK uses it (Google)
|
|
13
|
+
* - returns `undefined` → the SDK registers via DCR (Notion, Linear)
|
|
14
|
+
*
|
|
15
|
+
* Everything persists through the credential store, so registered clients and
|
|
16
|
+
* refresh tokens are encrypted at rest like any other credential. That matters
|
|
17
|
+
* for DCR in particular: re-registering orphans the previous grant, so the
|
|
18
|
+
* client id has to survive restarts.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Loosely typed against the SDK's shapes; the SDK validates them itself. */
|
|
22
|
+
type ClientInformation = Record<string, unknown> & { client_id?: string };
|
|
23
|
+
type Tokens = Record<string, unknown> & { refresh_token?: string; access_token?: string };
|
|
24
|
+
|
|
25
|
+
export interface OAuthProviderOptions {
|
|
26
|
+
readonly manifest: ProviderManifest;
|
|
27
|
+
readonly connectionId: string;
|
|
28
|
+
readonly credentials: SecretStore;
|
|
29
|
+
/** Loopback callback the CLI is listening on. Absent for non-interactive refresh. */
|
|
30
|
+
readonly redirectUrl?: string | undefined;
|
|
31
|
+
/** Opens the browser. Absent when refreshing, where no interaction is possible. */
|
|
32
|
+
readonly openBrowser?: ((url: URL) => void) | undefined;
|
|
33
|
+
readonly scopes?: readonly string[];
|
|
34
|
+
/**
|
|
35
|
+
* The `state` the CLI's loopback listener will accept. Absent for a
|
|
36
|
+
* non-interactive refresh, which has no callback leg to bind.
|
|
37
|
+
*/
|
|
38
|
+
readonly state?: string | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class CredentialOAuthProvider {
|
|
42
|
+
readonly #options: OAuthProviderOptions;
|
|
43
|
+
#codeVerifier: string | undefined;
|
|
44
|
+
#discoveryState: unknown;
|
|
45
|
+
|
|
46
|
+
constructor(options: OAuthProviderOptions) {
|
|
47
|
+
this.#options = options;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- where things live -------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The registered client.
|
|
54
|
+
*
|
|
55
|
+
* For `manual` registration this is keyed by the shared `oauth_apps` entry,
|
|
56
|
+
* because every Gmail and Drive connection authorises against the *same*
|
|
57
|
+
* Google app. For `dynamic` it is keyed per provider, since the registration
|
|
58
|
+
* belongs to us rather than to an operator-supplied app.
|
|
59
|
+
*/
|
|
60
|
+
get #clientRef(): string {
|
|
61
|
+
const auth = this.#options.manifest.auth;
|
|
62
|
+
if (auth.kind === 'oauth' && auth.registration === 'manual' && auth.app) {
|
|
63
|
+
return `${auth.app}/client`;
|
|
64
|
+
}
|
|
65
|
+
return `${this.#options.manifest.id}/client`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get #tokensRef(): string {
|
|
69
|
+
return `${this.#options.manifest.id}/${this.#options.connectionId}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- OAuthClientProvider ----------------------------------------------
|
|
73
|
+
|
|
74
|
+
get redirectUrl(): string | undefined {
|
|
75
|
+
return this.#options.redirectUrl;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get clientMetadata(): Record<string, unknown> {
|
|
79
|
+
return {
|
|
80
|
+
client_name: 'Lanes Link',
|
|
81
|
+
client_uri: 'https://github.com/lanes-sh/link',
|
|
82
|
+
redirect_uris: this.#options.redirectUrl ? [this.#options.redirectUrl] : [],
|
|
83
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
84
|
+
response_types: ['code'],
|
|
85
|
+
token_endpoint_auth_method: 'client_secret_post',
|
|
86
|
+
...(this.#options.scopes?.length ? { scope: this.#options.scopes.join(' ') } : {}),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async clientInformation(): Promise<ClientInformation | undefined> {
|
|
91
|
+
const auth = this.#options.manifest.auth;
|
|
92
|
+
|
|
93
|
+
// Manual registration: the operator supplied a client, so hand it over and
|
|
94
|
+
// the SDK will not attempt to register. Google requires this even for
|
|
95
|
+
// Google's own MCP servers.
|
|
96
|
+
if (auth.kind === 'oauth' && auth.registration === 'manual' && auth.app) {
|
|
97
|
+
const [clientId, clientSecret] = await Promise.all([
|
|
98
|
+
this.#options.credentials.get(`${auth.app}/client_id`),
|
|
99
|
+
this.#options.credentials.get(`${auth.app}/client_secret`),
|
|
100
|
+
]);
|
|
101
|
+
if (!clientId) return undefined;
|
|
102
|
+
return { client_id: clientId, ...(clientSecret ? { client_secret: clientSecret } : {}) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Dynamic: a previous registration, if we have one. Returning `undefined`
|
|
106
|
+
// is what tells the SDK to register — and re-registering orphans the prior
|
|
107
|
+
// grant, so this must survive restarts.
|
|
108
|
+
return this.#read(this.#clientRef);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async saveClientInformation(information: ClientInformation): Promise<void> {
|
|
112
|
+
await this.#write(this.#clientRef, information);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async tokens(): Promise<Tokens | undefined> {
|
|
116
|
+
return this.#read(this.#tokensRef);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async saveTokens(tokens: Tokens): Promise<void> {
|
|
120
|
+
// Stamp an absolute expiry alongside the relative `expires_in` the server
|
|
121
|
+
// returns. Without it there is no way to tell a live access token from a
|
|
122
|
+
// stale one after a restart, so every cold start would force a refresh —
|
|
123
|
+
// and a refresh needs the network, which a serve path should not.
|
|
124
|
+
const lifetime = typeof tokens['expires_in'] === 'number' ? (tokens['expires_in'] as number) : 3600;
|
|
125
|
+
|
|
126
|
+
await this.#write(this.#tokensRef, {
|
|
127
|
+
...tokens,
|
|
128
|
+
expires_at: Date.now() + lifetime * 1000,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
|
|
133
|
+
if (!this.#options.openBrowser) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`Connection ${this.#options.manifest.id}.${this.#options.connectionId} needs re-authorisation, ` +
|
|
136
|
+
`which requires a browser. Run: lanes link connect ${this.#options.manifest.id}.${this.#options.connectionId}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
this.#options.openBrowser(authorizationUrl);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The CSRF binding between the authorization request and the callback.
|
|
144
|
+
*
|
|
145
|
+
* The SDK sends a `state` only if we return one here — `state?()` is optional
|
|
146
|
+
* on its provider interface, and an absent method means the parameter is left
|
|
147
|
+
* off the authorization URL entirely. The value belongs to the listener in
|
|
148
|
+
* `captureOAuthCallback`, which is what compares it on the way back; this
|
|
149
|
+
* method only carries it outbound.
|
|
150
|
+
*
|
|
151
|
+
* Undefined during refresh, which has no callback leg. The SDK omits the
|
|
152
|
+
* parameter, which is correct: there is nothing to bind.
|
|
153
|
+
*/
|
|
154
|
+
state(): string | undefined {
|
|
155
|
+
return this.#options.state;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* SEP-2352: bind the callback leg to the authorization server discovered on
|
|
160
|
+
* the first leg.
|
|
161
|
+
*
|
|
162
|
+
* Held in memory beside the code verifier, and for the same reason — both are
|
|
163
|
+
* valid for exactly one exchange. Without it the SDK cannot check that the
|
|
164
|
+
* callback came back from the server it started with, which is an
|
|
165
|
+
* authorization-server mix-up defence worth having rather than a warning
|
|
166
|
+
* worth silencing.
|
|
167
|
+
*/
|
|
168
|
+
saveDiscoveryState(state: unknown): void {
|
|
169
|
+
this.#discoveryState = state;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
discoveryState(): unknown {
|
|
173
|
+
return this.#discoveryState;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
saveCodeVerifier(codeVerifier: string): void {
|
|
177
|
+
// In memory only: it is valid for one exchange and is worthless afterwards,
|
|
178
|
+
// so persisting it would add a stored secret for no benefit.
|
|
179
|
+
this.#codeVerifier = codeVerifier;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
codeVerifier(): string {
|
|
183
|
+
if (!this.#codeVerifier) throw new Error('No PKCE code verifier for this flow');
|
|
184
|
+
return this.#codeVerifier;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): Promise<void> {
|
|
188
|
+
if (scope === 'verifier') {
|
|
189
|
+
this.#codeVerifier = undefined;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (scope === 'discovery') {
|
|
193
|
+
this.#discoveryState = undefined;
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (scope === 'tokens' || scope === 'all') {
|
|
197
|
+
await this.#options.credentials.delete(this.#tokensRef).catch(() => {});
|
|
198
|
+
}
|
|
199
|
+
if (scope === 'client' || scope === 'all') {
|
|
200
|
+
// Only ever a dynamically registered client. An operator-supplied one is
|
|
201
|
+
// theirs, and deleting it would silently break every other connection
|
|
202
|
+
// sharing that oauth_apps entry.
|
|
203
|
+
const auth = this.#options.manifest.auth;
|
|
204
|
+
const manual = auth.kind === 'oauth' && auth.registration === 'manual';
|
|
205
|
+
if (!manual) await this.#options.credentials.delete(this.#clientRef).catch(() => {});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// --- storage helpers ---------------------------------------------------
|
|
210
|
+
|
|
211
|
+
async #read<T>(ref: string): Promise<T | undefined> {
|
|
212
|
+
const raw = await this.#options.credentials.get(ref);
|
|
213
|
+
if (!raw) return undefined;
|
|
214
|
+
try {
|
|
215
|
+
return JSON.parse(raw) as T;
|
|
216
|
+
} catch {
|
|
217
|
+
// Unparseable stored credential: treat as absent so the flow can recover
|
|
218
|
+
// by re-registering rather than failing permanently.
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async #write(ref: string, value: unknown): Promise<void> {
|
|
224
|
+
await this.#options.credentials.set(ref, JSON.stringify(value));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Access tokens for upstream calls, cached in memory and never persisted.
|
|
230
|
+
*
|
|
231
|
+
* Short-lived by design, so storing one would add a credential to protect for
|
|
232
|
+
* no benefit. Keyed per connection, so two accounts never share a token, and a
|
|
233
|
+
* cold instance simply refreshes.
|
|
234
|
+
*/
|
|
235
|
+
const accessTokens = new Map<string, { token: string; expiresAt: number }>();
|
|
236
|
+
|
|
237
|
+
export function clearUpstreamTokens(): void {
|
|
238
|
+
accessTokens.clear();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const EXPIRY_MARGIN_MS = 60_000;
|
|
242
|
+
|
|
243
|
+
export async function upstreamAccessToken(options: {
|
|
244
|
+
connectionKey: string;
|
|
245
|
+
provider: CredentialOAuthProvider;
|
|
246
|
+
refresh: (provider: CredentialOAuthProvider) => Promise<Tokens | undefined>;
|
|
247
|
+
now?: () => number;
|
|
248
|
+
}): Promise<string | null> {
|
|
249
|
+
const now = (options.now ?? Date.now)();
|
|
250
|
+
const cached = accessTokens.get(options.connectionKey);
|
|
251
|
+
if (cached && cached.expiresAt > now + EXPIRY_MARGIN_MS) return cached.token;
|
|
252
|
+
|
|
253
|
+
const stored = await options.provider.tokens();
|
|
254
|
+
const expiresAt = typeof stored?.['expires_at'] === 'number' ? (stored['expires_at'] as number) : 0;
|
|
255
|
+
|
|
256
|
+
if (stored?.access_token && expiresAt > now + EXPIRY_MARGIN_MS) {
|
|
257
|
+
accessTokens.set(options.connectionKey, { token: stored.access_token, expiresAt });
|
|
258
|
+
return stored.access_token;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Only refresh when there is something to refresh with. Not every
|
|
262
|
+
// authorization server issues a refresh token — Notion's does not — and
|
|
263
|
+
// calling the SDK's `auth()` without one fails in a way that looks like a
|
|
264
|
+
// configuration bug rather than "this token simply cannot be renewed".
|
|
265
|
+
// Hand back what we have and let a 401 surface as "re-authorise", which is
|
|
266
|
+
// the truthful instruction.
|
|
267
|
+
if (!stored?.refresh_token) return stored?.access_token ?? null;
|
|
268
|
+
|
|
269
|
+
const refreshed = await options.refresh(options.provider);
|
|
270
|
+
if (!refreshed?.access_token) return stored?.access_token ?? null;
|
|
271
|
+
|
|
272
|
+
const lifetime = typeof refreshed['expires_in'] === 'number' ? (refreshed['expires_in'] as number) : 3600;
|
|
273
|
+
accessTokens.set(options.connectionKey, {
|
|
274
|
+
token: refreshed.access_token,
|
|
275
|
+
expiresAt: now + lifetime * 1000,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
return refreshed.access_token;
|
|
279
|
+
}
|