@cjhyy/code-shell-server 0.9.44 → 0.9.47
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/dist/attachment-service.d.ts +2 -0
- package/dist/attachment-service.js +40 -27
- package/dist/bin/code-shell-link-cleanup.d.ts +2 -0
- package/dist/bin/code-shell-link-cleanup.js +3 -0
- package/dist/desktop-web/http-api.d.ts +3 -0
- package/dist/desktop-web/http-api.js +16 -1
- package/dist/hub/configuration.d.ts +6 -3
- package/dist/hub/configuration.js +19 -6
- package/dist/hub/mcp-probe.d.ts +4 -24
- package/dist/hub/mcp-probe.js +18 -389
- package/dist/hub/model-probe.d.ts +2 -20
- package/dist/hub/model-probe.js +2 -193
- package/dist/hub/run-replay.d.ts +7 -1
- package/dist/hub/run-replay.js +10 -2
- package/dist/hub/session-management.js +18 -53
- package/dist/hub/skills-management.js +6 -8
- package/dist/hub/uploads.d.ts +10 -0
- package/dist/hub/uploads.js +107 -15
- package/dist/index.desktop-web.d.ts +1 -0
- package/dist/index.desktop-web.js +1 -0
- package/dist/index.links.d.ts +3 -0
- package/dist/index.links.js +2 -0
- package/dist/index.panels.d.ts +1 -0
- package/dist/index.panels.js +1 -0
- package/dist/index.serve.d.ts +1 -0
- package/dist/index.serve.js +1 -0
- package/dist/index.storage.d.ts +1 -0
- package/dist/index.storage.js +1 -0
- package/dist/links/catalog.js +2 -0
- package/dist/links/diagnostics.d.ts +60 -0
- package/dist/links/diagnostics.js +184 -0
- package/dist/links/http.d.ts +10 -8
- package/dist/links/http.js +144 -125
- package/dist/links/legacy-cleanup-cli.d.ts +3 -0
- package/dist/links/legacy-cleanup-cli.js +64 -0
- package/dist/links/providers/defaults.d.ts +5 -0
- package/dist/links/providers/defaults.js +782 -0
- package/dist/links/providers/index.d.ts +7 -0
- package/dist/links/providers/index.js +22 -0
- package/dist/links/remote-catalog.d.ts +10 -3
- package/dist/links/remote-catalog.js +76 -29
- package/dist/links/runtime/operation.d.ts +9 -0
- package/dist/links/runtime/operation.js +16 -0
- package/dist/links/service.d.ts +18 -2
- package/dist/links/service.js +593 -62
- package/dist/links/types.d.ts +21 -1
- package/dist/mobile-remote/codex-parse.d.ts +3 -1
- package/dist/mobile-remote/codex-parse.js +45 -11
- package/dist/mobile-remote/codex-room-agent.d.ts +0 -6
- package/dist/mobile-remote/codex-room-agent.js +30 -32
- package/dist/mobile-remote/mobile-chat-turn.d.ts +1 -0
- package/dist/mobile-remote/mobile-chat-turn.js +3 -0
- package/dist/mobile-remote/mobile-client-event-validator.js +21 -0
- package/dist/mobile-remote/mobile-history.d.ts +10 -1
- package/dist/mobile-remote/mobile-history.js +24 -16
- package/dist/mobile-remote/remote-host-manager.d.ts +10 -0
- package/dist/mobile-remote/remote-host-manager.js +50 -13
- package/dist/mobile-remote/resident-agent.js +38 -22
- package/dist/mobile-remote/room-manager.d.ts +23 -13
- package/dist/mobile-remote/room-manager.js +106 -22
- package/dist/mobile-remote/room-model-tracing.d.ts +18 -0
- package/dist/mobile-remote/room-model-tracing.js +196 -0
- package/dist/mobile-remote/room-process-stop.d.ts +3 -0
- package/dist/mobile-remote/room-process-stop.js +28 -0
- package/dist/model-trace-history.d.ts +29 -0
- package/dist/model-trace-history.js +198 -0
- package/dist/model-trace-http.d.ts +14 -0
- package/dist/model-trace-http.js +105 -0
- package/dist/panels/agent-task-host.js +1 -0
- package/dist/panels/agent-task-service.d.ts +2 -1
- package/dist/panels/agent-task-service.js +10 -3
- package/dist/panels/directory-bookmarks.js +62 -29
- package/dist/panels/execution-gate.d.ts +19 -1
- package/dist/panels/execution-gate.js +33 -5
- package/dist/panels/http.d.ts +1 -0
- package/dist/panels/hub-automations.js +1 -0
- package/dist/panels/hub-binding.js +5 -5
- package/dist/panels/model-trace-channel.d.ts +21 -0
- package/dist/panels/model-trace-channel.js +110 -0
- package/dist/panels/model-traces.d.ts +39 -0
- package/dist/panels/model-traces.js +222 -0
- package/dist/panels/process-service.d.ts +12 -2
- package/dist/panels/process-service.js +49 -6
- package/dist/panels/resources/uploads.js +15 -6
- package/dist/panels/runtime.d.ts +1 -0
- package/dist/panels/runtime.js +66 -8
- package/dist/panels/task-cookies.js +8 -16
- package/dist/panels/tool-jobs.d.ts +2 -0
- package/dist/panels/tool-jobs.js +28 -3
- package/dist/serve/cli.js +8 -1
- package/dist/serve/headless-server.js +68 -14
- package/dist/sessions-service.js +17 -27
- package/dist/worker-bridge-core.js +11 -0
- package/package.json +8 -7
package/dist/links/service.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { linkConnectionMetadata } from "@cjhyy/code-shell-link";
|
|
1
2
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
3
|
import { CredentialStore, beginRemoteLinkAuthorization, completeRemoteLinkAuthorization, revokeRemoteLinkAuthorization, isRemoteLinkCredential, connectCliLink, getCliLinkStatus, isCliLinkProvider, isCredentialSecretAvailable, summarizeOAuthCredentialSecret, validateLocalLinkToken, } from "@cjhyy/code-shell-core";
|
|
3
4
|
import { listDesktopLinkProviders } from "./catalog.js";
|
|
4
|
-
import {
|
|
5
|
+
import { authorizationDiagnosticFailure, authorizationDiagnosticIdentity, authorizationDiagnosticStage, withLinkAuthorizationTrace, } from "./diagnostics.js";
|
|
6
|
+
import { readRemoteProviderCapabilities, RemoteLinkCatalogError, REMOTE_LINK_ADAPTERS, } from "./remote-catalog.js";
|
|
5
7
|
import { LinkDeviceOAuthBroker, isLocalBrowserLinkProvider, } from "./device-oauth.js";
|
|
6
8
|
const messages = {
|
|
7
9
|
invalid_request: "Link 请求参数无效。",
|
|
@@ -27,6 +29,64 @@ export class LinkServiceError extends Error {
|
|
|
27
29
|
this.name = "LinkServiceError";
|
|
28
30
|
}
|
|
29
31
|
}
|
|
32
|
+
/** Trusted Host adapters use this to expose reviewed failure stages, never raw provider text. */
|
|
33
|
+
export class HostLinkOAuthError extends LinkServiceError {
|
|
34
|
+
failure;
|
|
35
|
+
constructor(failure) {
|
|
36
|
+
super(422, "authorization_failed");
|
|
37
|
+
this.name = "HostLinkOAuthError";
|
|
38
|
+
if (![
|
|
39
|
+
"invalid_request",
|
|
40
|
+
"access_denied",
|
|
41
|
+
"timeout",
|
|
42
|
+
"network_error",
|
|
43
|
+
"server_error",
|
|
44
|
+
"protocol_error",
|
|
45
|
+
].includes(failure.code) ||
|
|
46
|
+
![
|
|
47
|
+
"validation",
|
|
48
|
+
"authorization",
|
|
49
|
+
"discovery_registration",
|
|
50
|
+
"authorization_callback",
|
|
51
|
+
"token_exchange",
|
|
52
|
+
"credential_verification",
|
|
53
|
+
].includes(failure.stage) ||
|
|
54
|
+
(failure.verificationStage !== undefined &&
|
|
55
|
+
![
|
|
56
|
+
"credential_binding",
|
|
57
|
+
"mcp_initialize",
|
|
58
|
+
"tool_discovery",
|
|
59
|
+
"account_verification",
|
|
60
|
+
"capability_verification",
|
|
61
|
+
].includes(failure.verificationStage)) ||
|
|
62
|
+
(failure.reason !== undefined &&
|
|
63
|
+
![
|
|
64
|
+
"binding_mismatch",
|
|
65
|
+
"cancelled",
|
|
66
|
+
"network_error",
|
|
67
|
+
"http_error",
|
|
68
|
+
"protocol_error",
|
|
69
|
+
"tool_unavailable",
|
|
70
|
+
"invalid_pagination",
|
|
71
|
+
"response_too_large",
|
|
72
|
+
"account_tool_error",
|
|
73
|
+
"account_identity_unavailable",
|
|
74
|
+
"no_read_capabilities",
|
|
75
|
+
].includes(failure.reason)) ||
|
|
76
|
+
(failure.httpStatus !== undefined &&
|
|
77
|
+
(!Number.isInteger(failure.httpStatus) ||
|
|
78
|
+
failure.httpStatus < 400 ||
|
|
79
|
+
failure.httpStatus > 599)))
|
|
80
|
+
throw new LinkServiceError(400, "invalid_request");
|
|
81
|
+
this.failure = Object.freeze({
|
|
82
|
+
code: failure.code,
|
|
83
|
+
stage: failure.stage,
|
|
84
|
+
...(failure.verificationStage ? { verificationStage: failure.verificationStage } : {}),
|
|
85
|
+
...(failure.reason ? { reason: failure.reason } : {}),
|
|
86
|
+
...(failure.httpStatus !== undefined ? { httpStatus: failure.httpStatus } : {}),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
30
90
|
export function publicLinkError(error) {
|
|
31
91
|
if (error instanceof LinkServiceError)
|
|
32
92
|
return error;
|
|
@@ -51,8 +111,20 @@ export function createLinkService(options = {}) {
|
|
|
51
111
|
const jobs = new Map();
|
|
52
112
|
const remoteJobs = new Map();
|
|
53
113
|
const interactiveJobs = new Map();
|
|
114
|
+
const hostOAuthJobs = new Map();
|
|
115
|
+
const hostOAuthAdapters = options.hostOAuthAdapters ?? [];
|
|
54
116
|
let remoteCatalog;
|
|
55
|
-
|
|
117
|
+
const catalogPending = new Map();
|
|
118
|
+
const catalogCodes = new Map([
|
|
119
|
+
["network", "catalog_network"],
|
|
120
|
+
["timeout", "catalog_timeout"],
|
|
121
|
+
["http_status", "catalog_http_status"],
|
|
122
|
+
["invalid_json", "catalog_invalid_json"],
|
|
123
|
+
["invalid_schema", "catalog_invalid_schema"],
|
|
124
|
+
["unreviewed_capability", "catalog_unreviewed_capability"],
|
|
125
|
+
["unsupported_auth", "catalog_unsupported_auth"],
|
|
126
|
+
["response_limit", "catalog_response_limit"],
|
|
127
|
+
]);
|
|
56
128
|
const revokedOwners = new Set();
|
|
57
129
|
let closed = false;
|
|
58
130
|
let snapshotSignature = "";
|
|
@@ -105,16 +177,54 @@ export function createLinkService(options = {}) {
|
|
|
105
177
|
throw new LinkServiceError(401, "login_required");
|
|
106
178
|
}
|
|
107
179
|
async function authorize(context) {
|
|
108
|
-
|
|
109
|
-
|
|
180
|
+
try {
|
|
181
|
+
unavailable(context);
|
|
182
|
+
if (!(await context.authorize()))
|
|
183
|
+
throw new LinkServiceError(401, "login_required");
|
|
184
|
+
unavailable(context);
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
authorizationDiagnosticFailure("authorization", "authorization_check_failed");
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function remoteAuthority(context) {
|
|
192
|
+
if (!options.captureRemoteAuthority)
|
|
193
|
+
return context;
|
|
194
|
+
let lease;
|
|
195
|
+
try {
|
|
196
|
+
lease = options.captureRemoteAuthority();
|
|
197
|
+
lease.signal.throwIfAborted();
|
|
198
|
+
lease.current();
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
authorizationDiagnosticFailure("authorization", "authorization_check_failed");
|
|
110
202
|
throw new LinkServiceError(401, "login_required");
|
|
111
|
-
|
|
203
|
+
}
|
|
204
|
+
const signal = context.signal ? AbortSignal.any([context.signal, lease.signal]) : lease.signal;
|
|
205
|
+
const current = () => {
|
|
206
|
+
try {
|
|
207
|
+
signal.throwIfAborted();
|
|
208
|
+
lease.current();
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
return {
|
|
216
|
+
ownerId: context.ownerId,
|
|
217
|
+
signal,
|
|
218
|
+
authorize: async () => current() && (await context.authorize()) && current(),
|
|
219
|
+
};
|
|
112
220
|
}
|
|
113
221
|
function begin(context) {
|
|
114
222
|
unavailable(context);
|
|
115
223
|
if (operations.size >= 8 ||
|
|
116
|
-
[...operations].filter((operation) => operation.ownerId === context.ownerId).length >= 2)
|
|
224
|
+
[...operations].filter((operation) => operation.ownerId === context.ownerId).length >= 2) {
|
|
225
|
+
authorizationDiagnosticFailure("job_creation", "job_capacity_exceeded");
|
|
117
226
|
throw new LinkServiceError(409, "busy");
|
|
227
|
+
}
|
|
118
228
|
const operation = { ownerId: context.ownerId, controller: new AbortController() };
|
|
119
229
|
operations.add(operation);
|
|
120
230
|
return operation;
|
|
@@ -132,31 +242,46 @@ export function createLinkService(options = {}) {
|
|
|
132
242
|
return current.revision;
|
|
133
243
|
}
|
|
134
244
|
function records() {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
245
|
+
authorizationDiagnosticStage("host_store_read");
|
|
246
|
+
try {
|
|
247
|
+
const current = store.listWithStatus("full");
|
|
248
|
+
if (!current.readable) {
|
|
249
|
+
authorizationDiagnosticFailure("host_store_read", "host_store_unreadable");
|
|
250
|
+
throw new LinkServiceError(503, "unavailable");
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
credentials: current.credentials,
|
|
254
|
+
projectIds: new Set(store.list("project").map((c) => c.id)),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
if (!(error instanceof LinkServiceError))
|
|
259
|
+
authorizationDiagnosticFailure("host_store_read", "host_store_read_failed");
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
142
262
|
}
|
|
143
263
|
function isLink(credential) {
|
|
144
264
|
return (isRemoteLinkCredential(credential) ||
|
|
145
265
|
(credential.type === "link" &&
|
|
146
|
-
credential.meta
|
|
266
|
+
linkConnectionMetadata(credential.meta).linkExecutionRuntime === "local" &&
|
|
267
|
+
providers.some((provider) => provider.id === credential.meta?.linkProvider)) ||
|
|
268
|
+
(credential.type === "oauth" &&
|
|
269
|
+
linkConnectionMetadata(credential.meta).linkExecutionBackend === "mcp" &&
|
|
270
|
+
linkConnectionMetadata(credential.meta).linkExecutionRuntime === "local" &&
|
|
147
271
|
providers.some((provider) => provider.id === credential.meta?.linkProvider)));
|
|
148
272
|
}
|
|
149
273
|
function masked(credential, scope) {
|
|
150
|
-
const meta = credential.meta;
|
|
151
|
-
const oauth = meta.linkAuthSource === "browser-oauth"
|
|
274
|
+
const meta = linkConnectionMetadata(credential.meta);
|
|
275
|
+
const oauth = meta.linkAuthSource === "browser-oauth" || meta.linkAuthSource === "mcp-oauth"
|
|
152
276
|
? summarizeOAuthCredentialSecret(credential.secret)
|
|
153
277
|
: undefined;
|
|
154
278
|
const remote = isRemoteLinkCredential(credential);
|
|
155
279
|
const status = (remote && meta.linkRemoteState !== "connected") ||
|
|
156
|
-
(meta.linkAuthSource === "browser-oauth" &&
|
|
280
|
+
((meta.linkAuthSource === "browser-oauth" || meta.linkAuthSource === "mcp-oauth") &&
|
|
157
281
|
meta.linkOAuthState &&
|
|
158
282
|
meta.linkOAuthState !== "connected") ||
|
|
159
|
-
(meta.linkAuthSource === "browser-oauth"
|
|
283
|
+
((meta.linkAuthSource === "browser-oauth" || meta.linkAuthSource === "mcp-oauth") &&
|
|
284
|
+
meta.linkCapabilityIds?.length === 0)
|
|
160
285
|
? "unavailable"
|
|
161
286
|
: !isCredentialSecretAvailable(credential.secret)
|
|
162
287
|
? "unavailable"
|
|
@@ -175,10 +300,29 @@ export function createLinkService(options = {}) {
|
|
|
175
300
|
? "remote-link"
|
|
176
301
|
: meta.linkExecutionBackend === "cli"
|
|
177
302
|
? "cli-session"
|
|
178
|
-
: meta.linkAuthSource === "
|
|
179
|
-
? "
|
|
180
|
-
: "
|
|
303
|
+
: meta.linkAuthSource === "mcp-oauth"
|
|
304
|
+
? "mcp-oauth"
|
|
305
|
+
: meta.linkAuthSource === "browser-oauth"
|
|
306
|
+
? "browser-oauth"
|
|
307
|
+
: "manual-token",
|
|
181
308
|
status,
|
|
309
|
+
recipeId: meta.linkRecipeId,
|
|
310
|
+
connectionState: {
|
|
311
|
+
authorization: {
|
|
312
|
+
state: status === "connected"
|
|
313
|
+
? "authorized"
|
|
314
|
+
: status === "expired" ||
|
|
315
|
+
status === "invalid" ||
|
|
316
|
+
meta.linkRemoteState === "reconnect" ||
|
|
317
|
+
meta.linkOAuthState === "reconnect"
|
|
318
|
+
? "reauthorization_required"
|
|
319
|
+
: "unknown",
|
|
320
|
+
},
|
|
321
|
+
// Compatibility is operation-specific. A saved grant or a successful
|
|
322
|
+
// account check cannot prove the contract required by a future task.
|
|
323
|
+
compatibility: { state: "unknown" },
|
|
324
|
+
observation: { state: "unchecked" },
|
|
325
|
+
},
|
|
182
326
|
scope,
|
|
183
327
|
editable: scope === "user",
|
|
184
328
|
revision: revision(credential),
|
|
@@ -208,19 +352,38 @@ export function createLinkService(options = {}) {
|
|
|
208
352
|
for (const id of revisions.keys())
|
|
209
353
|
if (!ids.has(id))
|
|
210
354
|
revisions.delete(id);
|
|
211
|
-
const remoteConfig =
|
|
355
|
+
const remoteConfig = remoteConfiguration();
|
|
212
356
|
const remoteProviders = remoteConfig
|
|
213
357
|
? remoteCatalog?.issuer === remoteConfig.issuer
|
|
214
358
|
? remoteCatalog.providers
|
|
215
359
|
: []
|
|
216
360
|
: [];
|
|
361
|
+
const catalogHealth = !remoteConfig
|
|
362
|
+
? { state: "unconfigured" }
|
|
363
|
+
: remoteCatalog?.issuer === remoteConfig.issuer
|
|
364
|
+
? { ...remoteCatalog.health }
|
|
365
|
+
: { state: "unknown" };
|
|
366
|
+
const remoteReady = catalogHealth.state === "ready";
|
|
217
367
|
const result = {
|
|
218
368
|
providers: providers.map((provider) => {
|
|
219
369
|
const deviceAuth = isLocalBrowserLinkProvider(provider.id)
|
|
220
370
|
? createBroker().status(provider.id)
|
|
221
371
|
: undefined;
|
|
222
372
|
const authModes = [];
|
|
373
|
+
for (const adapter of hostOAuthAdapters.filter((entry) => entry.providerId === provider.id))
|
|
374
|
+
authModes.push({
|
|
375
|
+
id: adapter.authModeId,
|
|
376
|
+
methodId: adapter.methodId,
|
|
377
|
+
kind: "redirect",
|
|
378
|
+
label: adapter.label,
|
|
379
|
+
preferred: true,
|
|
380
|
+
available: true,
|
|
381
|
+
});
|
|
223
382
|
for (const method of provider.connectionMethods) {
|
|
383
|
+
if (hostOAuthAdapters.some((adapter) => adapter.providerId === provider.id && adapter.methodId === method.id))
|
|
384
|
+
continue;
|
|
385
|
+
if (method.oauthProfileId && method.browserAuth?.flow === "authorization-code-pkce")
|
|
386
|
+
continue;
|
|
224
387
|
if (method.executionRuntime !== "local" || method.availability !== "available")
|
|
225
388
|
continue;
|
|
226
389
|
if (method.browserAuth?.flow === "device-code")
|
|
@@ -253,7 +416,10 @@ export function createLinkService(options = {}) {
|
|
|
253
416
|
});
|
|
254
417
|
}
|
|
255
418
|
const remote = REMOTE_LINK_ADAPTERS.find((adapter) => adapter.id === provider.id &&
|
|
256
|
-
remoteProviders.some((remote) => remote.id === adapter.id)
|
|
419
|
+
(remoteProviders.some((remote) => remote.id === adapter.id) ||
|
|
420
|
+
current.credentials.some((credential) => isRemoteLinkCredential(credential) &&
|
|
421
|
+
credential.meta?.linkProvider === provider.id)));
|
|
422
|
+
const remoteAvailable = remoteReady && remoteProviders.some((candidate) => candidate.id === provider.id);
|
|
257
423
|
return {
|
|
258
424
|
...provider,
|
|
259
425
|
connectionMethods: [
|
|
@@ -280,7 +446,16 @@ export function createLinkService(options = {}) {
|
|
|
280
446
|
kind: "redirect",
|
|
281
447
|
label: "网页授权",
|
|
282
448
|
preferred: true,
|
|
283
|
-
available:
|
|
449
|
+
available: remoteAvailable,
|
|
450
|
+
...(!remoteAvailable
|
|
451
|
+
? {
|
|
452
|
+
unavailableReason: !remoteConfig
|
|
453
|
+
? "当前未配置 Link 服务。已有账号仍可管理。"
|
|
454
|
+
: remoteReady
|
|
455
|
+
? "当前 Link 服务未提供此连接方式。已有账号仍可管理。"
|
|
456
|
+
: "连接目录暂时不可用。已有账号仍可管理,请稍后重试。",
|
|
457
|
+
}
|
|
458
|
+
: {}),
|
|
284
459
|
},
|
|
285
460
|
]
|
|
286
461
|
: []),
|
|
@@ -296,10 +471,11 @@ export function createLinkService(options = {}) {
|
|
|
296
471
|
token: true,
|
|
297
472
|
cliBinding: true,
|
|
298
473
|
deviceAuth: true,
|
|
299
|
-
remoteAuth: remoteProviders.length > 0,
|
|
474
|
+
remoteAuth: remoteReady && remoteProviders.length > 0,
|
|
300
475
|
authorizationSteps: 1,
|
|
301
476
|
},
|
|
302
477
|
...(remoteConfig ? { remoteServer: { issuer: remoteConfig.issuer } } : {}),
|
|
478
|
+
remoteCatalog: catalogHealth,
|
|
303
479
|
remoteCleanupPending: store.remoteLinkRetirementCount(),
|
|
304
480
|
};
|
|
305
481
|
const signature = JSON.stringify(result);
|
|
@@ -309,14 +485,29 @@ export function createLinkService(options = {}) {
|
|
|
309
485
|
}
|
|
310
486
|
return { ...result, revision: snapshotRevision };
|
|
311
487
|
}
|
|
312
|
-
|
|
488
|
+
function remoteConfiguration() {
|
|
489
|
+
authorizationDiagnosticStage("remote_configuration");
|
|
490
|
+
try {
|
|
491
|
+
return options.remoteLink?.();
|
|
492
|
+
}
|
|
493
|
+
catch (error) {
|
|
494
|
+
authorizationDiagnosticFailure("remote_configuration", "remote_configuration_invalid");
|
|
495
|
+
throw error;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
async function refreshRemoteCatalogWithDiagnostic(context, force = false) {
|
|
313
499
|
await authorize(context);
|
|
314
|
-
const config =
|
|
315
|
-
if (!config ||
|
|
500
|
+
const config = remoteConfiguration();
|
|
501
|
+
if (!config ||
|
|
502
|
+
(!force && remoteCatalog?.issuer === config.issuer && remoteCatalog.expiresAt > now()))
|
|
316
503
|
return;
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
504
|
+
authorizationDiagnosticStage("remote_catalog");
|
|
505
|
+
if (!catalogPending.has(config.issuer)) {
|
|
506
|
+
const pending = (async () => {
|
|
507
|
+
const previous = remoteCatalog?.issuer === config.issuer ? remoteCatalog : undefined;
|
|
508
|
+
let available = previous?.providers ?? [];
|
|
509
|
+
let health;
|
|
510
|
+
let diagnostic;
|
|
320
511
|
try {
|
|
321
512
|
available = options.readRemoteCatalog
|
|
322
513
|
? (await options.readRemoteCatalog(config)).flatMap((id) => {
|
|
@@ -326,23 +517,54 @@ export function createLinkService(options = {}) {
|
|
|
326
517
|
: [];
|
|
327
518
|
})
|
|
328
519
|
: await readRemoteProviderCapabilities(config);
|
|
520
|
+
const checkedAt = new Date(now()).toISOString();
|
|
521
|
+
health = { state: "ready", checkedAt, lastSuccessAt: checkedAt };
|
|
329
522
|
}
|
|
330
|
-
catch {
|
|
331
|
-
|
|
332
|
-
|
|
523
|
+
catch (error) {
|
|
524
|
+
diagnostic = {
|
|
525
|
+
internalCode: error instanceof RemoteLinkCatalogError
|
|
526
|
+
? (catalogCodes.get(error.reason) ?? "catalog_unexpected_error")
|
|
527
|
+
: "catalog_unexpected_error",
|
|
528
|
+
...(error instanceof RemoteLinkCatalogError
|
|
529
|
+
? { upstreamStatus: error.httpStatus }
|
|
530
|
+
: {}),
|
|
531
|
+
};
|
|
532
|
+
// A failed discovery does not revoke saved accounts or erase the last good catalog.
|
|
533
|
+
health = {
|
|
534
|
+
state: previous?.health.lastSuccessAt ? "stale" : "unavailable",
|
|
535
|
+
checkedAt: new Date(now()).toISOString(),
|
|
536
|
+
...(previous?.health.lastSuccessAt
|
|
537
|
+
? { lastSuccessAt: previous.health.lastSuccessAt }
|
|
538
|
+
: {}),
|
|
539
|
+
errorCode: error instanceof RemoteLinkCatalogError ? error.code : "network_error",
|
|
540
|
+
...(error instanceof RemoteLinkCatalogError && error.httpStatus !== undefined
|
|
541
|
+
? { httpStatus: error.httpStatus }
|
|
542
|
+
: {}),
|
|
543
|
+
};
|
|
333
544
|
}
|
|
334
545
|
if (!closed && options.remoteLink?.()?.issuer === config.issuer)
|
|
335
546
|
remoteCatalog = {
|
|
336
547
|
issuer: config.issuer,
|
|
337
|
-
expiresAt: now() + 60_000,
|
|
548
|
+
expiresAt: now() + (health.state === "ready" ? 60_000 : 15_000),
|
|
338
549
|
providers: available,
|
|
550
|
+
health,
|
|
339
551
|
};
|
|
552
|
+
return diagnostic;
|
|
340
553
|
})().finally(() => {
|
|
341
|
-
catalogPending
|
|
554
|
+
if (catalogPending.get(config.issuer) === pending)
|
|
555
|
+
catalogPending.delete(config.issuer);
|
|
342
556
|
});
|
|
557
|
+
catalogPending.set(config.issuer, pending);
|
|
343
558
|
}
|
|
344
|
-
|
|
559
|
+
// The shared request performs no trace writes. Every waiter owns its failure correlation.
|
|
560
|
+
const diagnostic = await catalogPending.get(config.issuer);
|
|
561
|
+
if (diagnostic)
|
|
562
|
+
authorizationDiagnosticFailure("remote_catalog", diagnostic.internalCode, diagnostic.upstreamStatus);
|
|
345
563
|
await authorize(context);
|
|
564
|
+
return diagnostic;
|
|
565
|
+
}
|
|
566
|
+
async function refreshRemoteCatalog(context, force = false) {
|
|
567
|
+
await refreshRemoteCatalogWithDiagnostic(context, force);
|
|
346
568
|
}
|
|
347
569
|
function string(value, max = 200) {
|
|
348
570
|
if (typeof value !== "string" ||
|
|
@@ -359,7 +581,9 @@ export function createLinkService(options = {}) {
|
|
|
359
581
|
return id;
|
|
360
582
|
}
|
|
361
583
|
function reviewed(id, expectedRevision) {
|
|
584
|
+
authorizationDiagnosticStage("connection_review");
|
|
362
585
|
const current = records();
|
|
586
|
+
authorizationDiagnosticStage("connection_review");
|
|
363
587
|
const credential = current.credentials.find((entry) => entry.id === id);
|
|
364
588
|
if (!credential || !isLink(credential))
|
|
365
589
|
throw new LinkServiceError(409, "conflict");
|
|
@@ -369,7 +593,8 @@ export function createLinkService(options = {}) {
|
|
|
369
593
|
throw new LinkServiceError(409, "conflict");
|
|
370
594
|
return credential;
|
|
371
595
|
}
|
|
372
|
-
function prepare(raw) {
|
|
596
|
+
function prepare(raw, hostAdapter) {
|
|
597
|
+
authorizationDiagnosticStage("input_validation");
|
|
373
598
|
if (!raw || typeof raw !== "object")
|
|
374
599
|
throw new LinkServiceError(400, "invalid_request");
|
|
375
600
|
const input = {
|
|
@@ -384,11 +609,27 @@ export function createLinkService(options = {}) {
|
|
|
384
609
|
?.connectionMethods.find((candidate) => candidate.id === input.methodId);
|
|
385
610
|
if (!method || method.executionRuntime !== "local" || method.availability !== "available")
|
|
386
611
|
throw new LinkServiceError(400, "invalid_request");
|
|
387
|
-
|
|
612
|
+
if (method.oauthProfileId &&
|
|
613
|
+
method.browserAuth?.flow === "authorization-code-pkce" &&
|
|
614
|
+
(!hostAdapter ||
|
|
615
|
+
hostAdapter.providerId !== input.providerId ||
|
|
616
|
+
hostAdapter.methodId !== input.methodId))
|
|
617
|
+
throw new LinkServiceError(400, "invalid_request");
|
|
618
|
+
const id = input.connectionId ?? `link-${randomUUID()}`;
|
|
619
|
+
authorizationDiagnosticStage("connection_review");
|
|
388
620
|
let expected = null;
|
|
621
|
+
let knownConnectionIds;
|
|
389
622
|
if (input.expectedRevision === null) {
|
|
390
|
-
|
|
623
|
+
const current = records();
|
|
624
|
+
authorizationDiagnosticStage("connection_review");
|
|
625
|
+
if (current.credentials.some((credential) => credential.id === id))
|
|
391
626
|
throw new LinkServiceError(409, "conflict");
|
|
627
|
+
if (!input.connectionId)
|
|
628
|
+
knownConnectionIds = current.credentials
|
|
629
|
+
.filter((credential) => credential.meta?.linkProvider === input.providerId &&
|
|
630
|
+
credential.meta?.linkConnectionMethod === input.methodId)
|
|
631
|
+
.map((credential) => credential.id)
|
|
632
|
+
.sort();
|
|
392
633
|
}
|
|
393
634
|
else {
|
|
394
635
|
expected = reviewed(id, input.expectedRevision);
|
|
@@ -396,7 +637,7 @@ export function createLinkService(options = {}) {
|
|
|
396
637
|
expected.meta.linkConnectionMethod !== input.methodId)
|
|
397
638
|
throw new LinkServiceError(409, "conflict");
|
|
398
639
|
}
|
|
399
|
-
return { input, id, expected };
|
|
640
|
+
return { input, id, expected, ...(knownConnectionIds ? { knownConnectionIds } : {}) };
|
|
400
641
|
}
|
|
401
642
|
async function change(context, write, bypassBusy = false) {
|
|
402
643
|
const action = async () => {
|
|
@@ -413,6 +654,15 @@ export function createLinkService(options = {}) {
|
|
|
413
654
|
const current = records();
|
|
414
655
|
if (current.projectIds.has(prepared.id))
|
|
415
656
|
throw new LinkServiceError(403, "read_only");
|
|
657
|
+
if (prepared.knownConnectionIds) {
|
|
658
|
+
const currentIds = current.credentials
|
|
659
|
+
.filter((credential) => credential.meta?.linkProvider === prepared.input.providerId &&
|
|
660
|
+
credential.meta?.linkConnectionMethod === prepared.input.methodId)
|
|
661
|
+
.map((credential) => credential.id)
|
|
662
|
+
.sort();
|
|
663
|
+
if (JSON.stringify(currentIds) !== JSON.stringify(prepared.knownConnectionIds))
|
|
664
|
+
throw new LinkServiceError(409, "conflict");
|
|
665
|
+
}
|
|
416
666
|
if (!store.compareAndSwap("user", prepared.id, prepared.expected, next, retirement))
|
|
417
667
|
throw new LinkServiceError(409, "conflict");
|
|
418
668
|
}
|
|
@@ -426,9 +676,12 @@ export function createLinkService(options = {}) {
|
|
|
426
676
|
meta: {
|
|
427
677
|
linkProvider: prepared.input.providerId,
|
|
428
678
|
linkConnectionMethod: prepared.input.methodId,
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
679
|
+
recordSchemaVersion: 2,
|
|
680
|
+
linkRecipeId: authSource === "cli-session"
|
|
681
|
+
? "cli-session"
|
|
682
|
+
: authSource === "browser-oauth"
|
|
683
|
+
? "api-oauth"
|
|
684
|
+
: "api-token",
|
|
432
685
|
agentExposable: false,
|
|
433
686
|
linkAccountId: validation.identity.externalAccountId,
|
|
434
687
|
linkAccountLabel: validation.identity.label,
|
|
@@ -554,6 +807,7 @@ export function createLinkService(options = {}) {
|
|
|
554
807
|
installed: result.installed,
|
|
555
808
|
authenticated: result.authenticated,
|
|
556
809
|
...(result.account ? { account: result.account } : {}),
|
|
810
|
+
...(result.accountId ? { accountId: result.accountId } : {}),
|
|
557
811
|
};
|
|
558
812
|
}
|
|
559
813
|
finally {
|
|
@@ -572,6 +826,14 @@ export function createLinkService(options = {}) {
|
|
|
572
826
|
async function disconnect(context, id, expectedRevision) {
|
|
573
827
|
await authorize(context);
|
|
574
828
|
const credential = reviewed(safeId(id), expectedRevision);
|
|
829
|
+
if (linkConnectionMetadata(credential.meta).linkExecutionBackend === "mcp") {
|
|
830
|
+
const adapter = hostOAuthAdapters.find((entry) => entry.providerId === credential.meta?.linkProvider &&
|
|
831
|
+
entry.methodId === credential.meta?.linkConnectionMethod);
|
|
832
|
+
await change(context, () => swap({ id: credential.id, expected: credential, input: {} }, null), true);
|
|
833
|
+
// Local authority is already gone. Cleanup never deletes a new connection with this ID.
|
|
834
|
+
await adapter?.disconnect?.(credential).catch(() => { });
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
575
837
|
if (isRemoteLinkCredential(credential)) {
|
|
576
838
|
// Disable local use before external I/O. Retain the disabled record if remote revocation
|
|
577
839
|
// fails so the user can retry; never report successful remote revocation on a timeout.
|
|
@@ -669,19 +931,237 @@ export function createLinkService(options = {}) {
|
|
|
669
931
|
}
|
|
670
932
|
return cliStep(job, job.public.expiresAt, status.installed, validation);
|
|
671
933
|
}
|
|
934
|
+
async function startHostOAuth(context, input, adapter) {
|
|
935
|
+
authorizationDiagnosticStage("job_creation");
|
|
936
|
+
for (const [id, job] of hostOAuthJobs)
|
|
937
|
+
if (job.retireAt !== undefined && job.retireAt <= now())
|
|
938
|
+
hostOAuthJobs.delete(id);
|
|
939
|
+
if (hostOAuthJobs.size >= 32) {
|
|
940
|
+
authorizationDiagnosticFailure("job_creation", "job_capacity_exceeded");
|
|
941
|
+
throw new LinkServiceError(409, "busy");
|
|
942
|
+
}
|
|
943
|
+
const prepared = prepare(input, adapter);
|
|
944
|
+
authorizationDiagnosticStage("job_creation");
|
|
945
|
+
const operation = begin(context);
|
|
946
|
+
const expiresAt = new Date(now() + 10 * 60_000).toISOString();
|
|
947
|
+
const job = {
|
|
948
|
+
context,
|
|
949
|
+
adapter,
|
|
950
|
+
prepared,
|
|
951
|
+
operation,
|
|
952
|
+
public: {
|
|
953
|
+
id: randomUUID(),
|
|
954
|
+
providerId: input.providerId,
|
|
955
|
+
methodId: input.methodId,
|
|
956
|
+
authModeId: adapter.authModeId,
|
|
957
|
+
state: "pending",
|
|
958
|
+
expiresAt,
|
|
959
|
+
step: { id: randomUUID(), kind: "processing", expiresAt },
|
|
960
|
+
},
|
|
961
|
+
};
|
|
962
|
+
hostOAuthJobs.set(job.public.id, job);
|
|
963
|
+
let ready;
|
|
964
|
+
const preparedStep = new Promise((resolve) => {
|
|
965
|
+
ready = resolve;
|
|
966
|
+
});
|
|
967
|
+
const end = (code) => {
|
|
968
|
+
if (job.public.state !== "pending")
|
|
969
|
+
return;
|
|
970
|
+
operation.controller.abort();
|
|
971
|
+
job.public = terminal(job.public, code === "cancelled" ? "cancelled" : "failed", {
|
|
972
|
+
errorCode: code,
|
|
973
|
+
});
|
|
974
|
+
job.retireAt = now() + 5 * 60_000;
|
|
975
|
+
clearTimeout(job.timer);
|
|
976
|
+
ready();
|
|
977
|
+
};
|
|
978
|
+
job.cancel = end;
|
|
979
|
+
const onAbort = () => end("cancelled");
|
|
980
|
+
context.signal?.addEventListener("abort", onAbort, { once: true });
|
|
981
|
+
job.timer = setTimeout(() => end("authorization_expired"), 10 * 60_000);
|
|
982
|
+
job.timer.unref?.();
|
|
983
|
+
if (context.signal?.aborted)
|
|
984
|
+
end("cancelled");
|
|
985
|
+
void (async () => {
|
|
986
|
+
let candidate;
|
|
987
|
+
let committed = false;
|
|
988
|
+
try {
|
|
989
|
+
operation.controller.signal.throwIfAborted();
|
|
990
|
+
candidate = await adapter.authorize({
|
|
991
|
+
id: prepared.id,
|
|
992
|
+
label: prepared.input.label,
|
|
993
|
+
previous: prepared.expected,
|
|
994
|
+
signal: operation.controller.signal,
|
|
995
|
+
onAuthorizationUrl: async (url, expiry) => {
|
|
996
|
+
await authorize(context);
|
|
997
|
+
operation.controller.signal.throwIfAborted();
|
|
998
|
+
const target = new URL(url);
|
|
999
|
+
const time = Date.parse(expiry);
|
|
1000
|
+
if (target.protocol !== "https:" ||
|
|
1001
|
+
target.username ||
|
|
1002
|
+
target.password ||
|
|
1003
|
+
target.hash ||
|
|
1004
|
+
!Number.isFinite(time) ||
|
|
1005
|
+
time <= now() ||
|
|
1006
|
+
time > now() + 10 * 60_000 + 1_000)
|
|
1007
|
+
throw new LinkServiceError(422, "authorization_failed");
|
|
1008
|
+
if (job.public.state !== "pending")
|
|
1009
|
+
throw new LinkServiceError(409, "cancelled");
|
|
1010
|
+
job.public.expiresAt = expiry;
|
|
1011
|
+
clearTimeout(job.timer);
|
|
1012
|
+
job.timer = setTimeout(() => end("authorization_expired"), Math.max(1, time - now()));
|
|
1013
|
+
job.timer.unref?.();
|
|
1014
|
+
job.public.step = {
|
|
1015
|
+
id: randomUUID(),
|
|
1016
|
+
kind: "redirect",
|
|
1017
|
+
expiresAt: expiry,
|
|
1018
|
+
authorizationUrl: target.href,
|
|
1019
|
+
};
|
|
1020
|
+
ready();
|
|
1021
|
+
},
|
|
1022
|
+
});
|
|
1023
|
+
await authorize(context);
|
|
1024
|
+
operation.controller.signal.throwIfAborted();
|
|
1025
|
+
if (job.public.state !== "pending" ||
|
|
1026
|
+
candidate.id !== prepared.id ||
|
|
1027
|
+
candidate.type !== "oauth" ||
|
|
1028
|
+
candidate.meta?.linkProvider !== input.providerId ||
|
|
1029
|
+
candidate.meta.linkConnectionMethod !== input.methodId ||
|
|
1030
|
+
linkConnectionMetadata(candidate.meta).linkExecutionBackend !== "mcp" ||
|
|
1031
|
+
candidate.meta.agentExposable !== false)
|
|
1032
|
+
throw new LinkServiceError(422, "provider_rejected");
|
|
1033
|
+
// Host adapters may still return legacy routing fields. The installed
|
|
1034
|
+
// connection keeps only its reviewed recipe; presentation derives axes.
|
|
1035
|
+
const { linkExecutionRuntime: _runtime, linkExecutionBackend: _backend, linkAuthSource: _source, ...candidateMeta } = candidate.meta;
|
|
1036
|
+
candidate = {
|
|
1037
|
+
...candidate,
|
|
1038
|
+
meta: { ...candidateMeta, recordSchemaVersion: 2, linkRecipeId: "mcp-oauth" },
|
|
1039
|
+
};
|
|
1040
|
+
await change(context, () => {
|
|
1041
|
+
operation.controller.signal.throwIfAborted();
|
|
1042
|
+
if (job.public.state !== "pending")
|
|
1043
|
+
throw new LinkServiceError(409, "cancelled");
|
|
1044
|
+
swap(prepared, candidate);
|
|
1045
|
+
committed = true;
|
|
1046
|
+
job.public = terminal(job.public, "connected", {
|
|
1047
|
+
connection: masked(candidate, "user"),
|
|
1048
|
+
});
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
catch (error) {
|
|
1052
|
+
if (job.public.state === "pending") {
|
|
1053
|
+
end(operation.controller.signal.aborted
|
|
1054
|
+
? "cancelled"
|
|
1055
|
+
: error instanceof LinkServiceError
|
|
1056
|
+
? error.code
|
|
1057
|
+
: "authorization_failed");
|
|
1058
|
+
if (job.public.state === "failed" &&
|
|
1059
|
+
error instanceof HostLinkOAuthError)
|
|
1060
|
+
job.public = { ...job.public, failure: { ...error.failure } };
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
finally {
|
|
1064
|
+
if (candidate?.secret && !committed) {
|
|
1065
|
+
try {
|
|
1066
|
+
const saved = store.resolve(candidate.id, "full");
|
|
1067
|
+
if (candidate.secret !== prepared.expected?.secret &&
|
|
1068
|
+
candidate.secret !== saved?.secret)
|
|
1069
|
+
await adapter.disconnect?.(candidate);
|
|
1070
|
+
}
|
|
1071
|
+
catch {
|
|
1072
|
+
/* Detached cleanup cannot change the original authorization result. */
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
clearTimeout(job.timer);
|
|
1076
|
+
context.signal?.removeEventListener("abort", onAbort);
|
|
1077
|
+
job.retireAt = now() + 5 * 60_000;
|
|
1078
|
+
finish(operation);
|
|
1079
|
+
ready();
|
|
1080
|
+
}
|
|
1081
|
+
})();
|
|
1082
|
+
await preparedStep;
|
|
1083
|
+
return structuredClone(job.public);
|
|
1084
|
+
}
|
|
1085
|
+
async function openHostAuthorization(context, id) {
|
|
1086
|
+
await authorize(context);
|
|
1087
|
+
const job = hostOAuthJobs.get(safeId(id));
|
|
1088
|
+
if (!job)
|
|
1089
|
+
return false;
|
|
1090
|
+
if (job.context.ownerId !== context.ownerId ||
|
|
1091
|
+
job.public.state !== "pending" ||
|
|
1092
|
+
job.operation.controller.signal.aborted ||
|
|
1093
|
+
job.public.step?.kind !== "redirect" ||
|
|
1094
|
+
Date.parse(job.public.step.expiresAt) <= now())
|
|
1095
|
+
throw new LinkServiceError(409, "conflict");
|
|
1096
|
+
const target = new URL(job.public.step.authorizationUrl);
|
|
1097
|
+
if (target.protocol !== "https:" || target.username || target.password || target.hash)
|
|
1098
|
+
throw new LinkServiceError(400, "invalid_request");
|
|
1099
|
+
await job.adapter.openAuthorizationUrl(target.href);
|
|
1100
|
+
await authorize(context);
|
|
1101
|
+
return true;
|
|
1102
|
+
}
|
|
1103
|
+
function diagnosticIdentity(input, authModeId) {
|
|
1104
|
+
const provider = providers.find((candidate) => candidate.id === input?.providerId);
|
|
1105
|
+
if (!provider)
|
|
1106
|
+
return;
|
|
1107
|
+
const remote = REMOTE_LINK_ADAPTERS.find((adapter) => adapter.id === provider.id && adapter.methodId === input.methodId);
|
|
1108
|
+
const method = provider.connectionMethods.find((candidate) => candidate.id === input.methodId);
|
|
1109
|
+
const modes = remote
|
|
1110
|
+
? [remote.authModeId]
|
|
1111
|
+
: method
|
|
1112
|
+
? [
|
|
1113
|
+
"token",
|
|
1114
|
+
...(method.quickAuth ? ["cli-session"] : []),
|
|
1115
|
+
...(method.browserAuth?.flow === "device-code" ? ["browser-oauth"] : []),
|
|
1116
|
+
...(method.oauthProfileId ? ["mcp-oauth"] : []),
|
|
1117
|
+
]
|
|
1118
|
+
: [];
|
|
1119
|
+
authorizationDiagnosticIdentity({
|
|
1120
|
+
providerId: provider.id,
|
|
1121
|
+
...(remote || method ? { methodId: remote?.methodId ?? method.id } : {}),
|
|
1122
|
+
...(modes.includes(authModeId) ? { authModeId: authModeId } : {}),
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
672
1125
|
async function startAuthorization(context, input, rawAuthModeId) {
|
|
1126
|
+
return withLinkAuthorizationTrace(options.onAuthorizationDiagnostic, () => startAuthorizationWithinTrace(context, input, rawAuthModeId), publicLinkError);
|
|
1127
|
+
}
|
|
1128
|
+
async function startAuthorizationWithinTrace(context, input, rawAuthModeId) {
|
|
673
1129
|
await authorize(context);
|
|
1130
|
+
diagnosticIdentity(input, rawAuthModeId);
|
|
1131
|
+
authorizationDiagnosticStage("input_validation");
|
|
674
1132
|
const authModeId = string(rawAuthModeId, 80);
|
|
675
|
-
|
|
1133
|
+
const hostAdapter = hostOAuthAdapters.find((adapter) => adapter.providerId === input?.providerId &&
|
|
1134
|
+
adapter.methodId === input.methodId &&
|
|
1135
|
+
adapter.authModeId === authModeId);
|
|
1136
|
+
if (input?.resourceUrl !== undefined && authModeId !== "remote-link" && !hostAdapter)
|
|
676
1137
|
throw new LinkServiceError(400, "invalid_request");
|
|
677
|
-
if (authModeId === "remote-link")
|
|
678
|
-
|
|
1138
|
+
if (authModeId === "remote-link") {
|
|
1139
|
+
context = remoteAuthority(context);
|
|
1140
|
+
const diagnostic = await refreshRemoteCatalogWithDiagnostic(context, true);
|
|
1141
|
+
const state = snapshot().remoteCatalog?.state;
|
|
1142
|
+
if (state !== "ready") {
|
|
1143
|
+
if (state === "unconfigured")
|
|
1144
|
+
authorizationDiagnosticFailure("remote_configuration", "remote_configuration_missing");
|
|
1145
|
+
else
|
|
1146
|
+
authorizationDiagnosticFailure("remote_catalog", diagnostic?.internalCode ?? "catalog_unavailable", diagnostic?.upstreamStatus);
|
|
1147
|
+
throw new LinkServiceError(503, "unavailable");
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
679
1150
|
const provider = snapshot().providers.find((candidate) => candidate.id === input?.providerId);
|
|
1151
|
+
authorizationDiagnosticStage("mode_selection");
|
|
680
1152
|
const mode = provider?.authModes?.find((candidate) => candidate.id === authModeId && candidate.methodId === input.methodId && candidate.available);
|
|
681
|
-
if (!mode)
|
|
1153
|
+
if (!mode) {
|
|
1154
|
+
authorizationDiagnosticFailure("mode_selection", authModeId === "remote-link" &&
|
|
1155
|
+
REMOTE_LINK_ADAPTERS.some((adapter) => adapter.id === input?.providerId) &&
|
|
1156
|
+
!remoteCatalog?.providers.some((candidate) => candidate.id === input.providerId)
|
|
1157
|
+
? "provider_capability_missing"
|
|
1158
|
+
: "auth_mode_unavailable");
|
|
682
1159
|
throw new LinkServiceError(400, "invalid_request");
|
|
1160
|
+
}
|
|
1161
|
+
if (hostAdapter)
|
|
1162
|
+
return startHostOAuth(context, input, hostAdapter);
|
|
683
1163
|
if (mode.kind === "redirect") {
|
|
684
|
-
const result = await
|
|
1164
|
+
const result = await startRemoteAuthAfterCatalog(context, input);
|
|
685
1165
|
const job = remoteJobs.get(result.id);
|
|
686
1166
|
job.public.methodId = input.methodId;
|
|
687
1167
|
job.public.authModeId = authModeId;
|
|
@@ -691,10 +1171,14 @@ export function createLinkService(options = {}) {
|
|
|
691
1171
|
}
|
|
692
1172
|
if (mode.kind === "device-code")
|
|
693
1173
|
return startDeviceAuth(context, input);
|
|
1174
|
+
authorizationDiagnosticStage("job_creation");
|
|
694
1175
|
pruneInteractive();
|
|
695
|
-
if (interactiveJobs.size >= 32)
|
|
1176
|
+
if (interactiveJobs.size >= 32) {
|
|
1177
|
+
authorizationDiagnosticFailure("job_creation", "job_capacity_exceeded");
|
|
696
1178
|
throw new LinkServiceError(409, "busy");
|
|
1179
|
+
}
|
|
697
1180
|
const prepared = prepare(input);
|
|
1181
|
+
authorizationDiagnosticStage("job_creation");
|
|
698
1182
|
const operation = begin(context);
|
|
699
1183
|
const expiresAt = now() + 10 * 60_000;
|
|
700
1184
|
const base = { id: randomUUID(), expiresAt: new Date(expiresAt).toISOString() };
|
|
@@ -954,14 +1438,18 @@ export function createLinkService(options = {}) {
|
|
|
954
1438
|
}
|
|
955
1439
|
}
|
|
956
1440
|
async function startDeviceAuth(context, input) {
|
|
1441
|
+
authorizationDiagnosticStage("job_creation");
|
|
957
1442
|
pruneJobs();
|
|
958
|
-
if (jobs.size >= 32)
|
|
1443
|
+
if (jobs.size >= 32) {
|
|
1444
|
+
authorizationDiagnosticFailure("job_creation", "job_capacity_exceeded");
|
|
959
1445
|
throw new LinkServiceError(409, "busy");
|
|
1446
|
+
}
|
|
960
1447
|
const operation = begin(context);
|
|
961
1448
|
let job;
|
|
962
1449
|
try {
|
|
963
1450
|
await authorize(context);
|
|
964
1451
|
const prepared = prepare(input);
|
|
1452
|
+
authorizationDiagnosticStage("job_creation");
|
|
965
1453
|
const providerId = prepared.input.providerId;
|
|
966
1454
|
if (!isLocalBrowserLinkProvider(providerId))
|
|
967
1455
|
throw new LinkServiceError(400, "invalid_request");
|
|
@@ -1029,14 +1517,23 @@ export function createLinkService(options = {}) {
|
|
|
1029
1517
|
}
|
|
1030
1518
|
}
|
|
1031
1519
|
async function startRemoteAuth(context, input) {
|
|
1520
|
+
context = remoteAuthority(context);
|
|
1521
|
+
await refreshRemoteCatalog(context, true);
|
|
1522
|
+
return startRemoteAuthAfterCatalog(context, input);
|
|
1523
|
+
}
|
|
1524
|
+
async function startRemoteAuthAfterCatalog(context, input) {
|
|
1032
1525
|
await authorize(context);
|
|
1526
|
+
authorizationDiagnosticStage("job_creation");
|
|
1033
1527
|
const checkCapacity = () => {
|
|
1034
1528
|
pruneRemoteJobs();
|
|
1035
1529
|
if (remoteJobs.size >= 32 ||
|
|
1036
|
-
[...remoteJobs.values()].filter((job) => job.context.ownerId === context.ownerId && job.public.state === "pending").length >= 2)
|
|
1530
|
+
[...remoteJobs.values()].filter((job) => job.context.ownerId === context.ownerId && job.public.state === "pending").length >= 2) {
|
|
1531
|
+
authorizationDiagnosticFailure("job_creation", "job_capacity_exceeded");
|
|
1037
1532
|
throw new LinkServiceError(409, "busy");
|
|
1533
|
+
}
|
|
1038
1534
|
};
|
|
1039
1535
|
checkCapacity();
|
|
1536
|
+
authorizationDiagnosticStage("input_validation");
|
|
1040
1537
|
if (!input ||
|
|
1041
1538
|
input.methodId !== "remote-link" ||
|
|
1042
1539
|
!REMOTE_LINK_ADAPTERS.some((adapter) => adapter.id === input.providerId) ||
|
|
@@ -1045,22 +1542,27 @@ export function createLinkService(options = {}) {
|
|
|
1045
1542
|
typeof input.resourceUrl !== "string" ||
|
|
1046
1543
|
!input.resourceUrl)))
|
|
1047
1544
|
throw new LinkServiceError(400, "invalid_request");
|
|
1048
|
-
const config =
|
|
1049
|
-
if (!config)
|
|
1545
|
+
const config = remoteConfiguration();
|
|
1546
|
+
if (!config) {
|
|
1547
|
+
authorizationDiagnosticFailure("remote_configuration", "remote_configuration_missing");
|
|
1050
1548
|
throw new LinkServiceError(503, "unavailable");
|
|
1051
|
-
|
|
1052
|
-
//
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
const capabilities = remoteCatalog?.issuer === config.issuer
|
|
1549
|
+
}
|
|
1550
|
+
// No await between selecting the deployment and creating its authorization URL.
|
|
1551
|
+
authorizationDiagnosticStage("remote_catalog");
|
|
1552
|
+
const capabilities = remoteCatalog?.issuer === config.issuer && remoteCatalog.health.state === "ready"
|
|
1056
1553
|
? remoteCatalog.providers.find((provider) => provider.id === input.providerId)
|
|
1057
1554
|
: undefined;
|
|
1058
|
-
if (!capabilities)
|
|
1555
|
+
if (!capabilities) {
|
|
1556
|
+
authorizationDiagnosticFailure("remote_catalog", "provider_capability_missing");
|
|
1059
1557
|
throw new LinkServiceError(503, "unavailable");
|
|
1558
|
+
}
|
|
1559
|
+
authorizationDiagnosticStage("connection_review");
|
|
1060
1560
|
const id = input.connectionId === undefined ? `link-remote-${randomUUID()}` : safeId(input.connectionId);
|
|
1061
1561
|
let expected = null;
|
|
1062
1562
|
if (input.expectedRevision === null) {
|
|
1063
|
-
|
|
1563
|
+
const current = records();
|
|
1564
|
+
authorizationDiagnosticStage("connection_review");
|
|
1565
|
+
if (current.credentials.some((credential) => credential.id === id))
|
|
1064
1566
|
throw new LinkServiceError(409, "conflict");
|
|
1065
1567
|
}
|
|
1066
1568
|
else {
|
|
@@ -1069,6 +1571,7 @@ export function createLinkService(options = {}) {
|
|
|
1069
1571
|
throw new LinkServiceError(409, "conflict");
|
|
1070
1572
|
}
|
|
1071
1573
|
const prepared = { id, expected, input: { ...input, label: string(input.label) } };
|
|
1574
|
+
authorizationDiagnosticStage("job_creation");
|
|
1072
1575
|
let attempt;
|
|
1073
1576
|
try {
|
|
1074
1577
|
let resourceFiles;
|
|
@@ -1089,6 +1592,7 @@ export function createLinkService(options = {}) {
|
|
|
1089
1592
|
});
|
|
1090
1593
|
}
|
|
1091
1594
|
catch {
|
|
1595
|
+
authorizationDiagnosticFailure("job_creation", "remote_authorization_invalid");
|
|
1092
1596
|
throw new LinkServiceError(400, "invalid_request");
|
|
1093
1597
|
}
|
|
1094
1598
|
const publicJob = {
|
|
@@ -1246,6 +1750,12 @@ export function createLinkService(options = {}) {
|
|
|
1246
1750
|
pruneJobs();
|
|
1247
1751
|
pruneRemoteJobs();
|
|
1248
1752
|
pruneInteractive();
|
|
1753
|
+
const host = hostOAuthJobs.get(safeId(id));
|
|
1754
|
+
if (host) {
|
|
1755
|
+
if (host.context.ownerId !== context.ownerId)
|
|
1756
|
+
throw new LinkServiceError(404, "not_found");
|
|
1757
|
+
return structuredClone(host.public);
|
|
1758
|
+
}
|
|
1249
1759
|
const interactive = interactiveJobs.get(safeId(id));
|
|
1250
1760
|
if (interactive) {
|
|
1251
1761
|
if (interactive.context.ownerId !== context.ownerId)
|
|
@@ -1258,6 +1768,8 @@ export function createLinkService(options = {}) {
|
|
|
1258
1768
|
if (remote) {
|
|
1259
1769
|
if (remote.context.ownerId !== context.ownerId)
|
|
1260
1770
|
throw new LinkServiceError(404, "not_found");
|
|
1771
|
+
if (options.captureRemoteAuthority && remote.public.state === "pending")
|
|
1772
|
+
await authorize(remote.context);
|
|
1261
1773
|
return structuredClone(remote.public);
|
|
1262
1774
|
}
|
|
1263
1775
|
const job = jobs.get(safeId(id));
|
|
@@ -1268,11 +1780,16 @@ export function createLinkService(options = {}) {
|
|
|
1268
1780
|
async function cancelAuthorization(context, id) {
|
|
1269
1781
|
await authorize(context);
|
|
1270
1782
|
safeId(id);
|
|
1271
|
-
const ownerId =
|
|
1783
|
+
const ownerId = hostOAuthJobs.get(id)?.context.ownerId ??
|
|
1784
|
+
remoteJobs.get(id)?.context.ownerId ??
|
|
1272
1785
|
interactiveJobs.get(id)?.context.ownerId ??
|
|
1273
1786
|
jobs.get(id)?.ownerId;
|
|
1274
1787
|
if (!ownerId || ownerId !== context.ownerId)
|
|
1275
1788
|
throw new LinkServiceError(404, "not_found");
|
|
1789
|
+
const host = hostOAuthJobs.get(id);
|
|
1790
|
+
if (host?.public.state === "pending") {
|
|
1791
|
+
host.cancel?.("cancelled");
|
|
1792
|
+
}
|
|
1276
1793
|
const remote = remoteJobs.get(id);
|
|
1277
1794
|
if (remote?.public.state === "pending") {
|
|
1278
1795
|
remote.public = terminal(remote.public, "cancelled", { errorCode: "cancelled" });
|
|
@@ -1288,6 +1805,13 @@ export function createLinkService(options = {}) {
|
|
|
1288
1805
|
}
|
|
1289
1806
|
function cancelOwner(ownerId) {
|
|
1290
1807
|
revokedOwners.add(ownerId);
|
|
1808
|
+
for (const [id, job] of hostOAuthJobs)
|
|
1809
|
+
if (job.context.ownerId === ownerId) {
|
|
1810
|
+
job.cancel?.("cancelled");
|
|
1811
|
+
job.operation.controller.abort();
|
|
1812
|
+
clearTimeout(job.timer);
|
|
1813
|
+
hostOAuthJobs.delete(id);
|
|
1814
|
+
}
|
|
1291
1815
|
for (const [id, job] of interactiveJobs)
|
|
1292
1816
|
if (job.context.ownerId === ownerId) {
|
|
1293
1817
|
endInteractive(job, "cancelled");
|
|
@@ -1312,6 +1836,12 @@ export function createLinkService(options = {}) {
|
|
|
1312
1836
|
clearInterval(cleanupTimer);
|
|
1313
1837
|
clearTimeout(cleanupStartup);
|
|
1314
1838
|
remoteJobs.clear();
|
|
1839
|
+
for (const job of hostOAuthJobs.values()) {
|
|
1840
|
+
job.cancel?.("cancelled");
|
|
1841
|
+
job.operation.controller.abort();
|
|
1842
|
+
clearTimeout(job.timer);
|
|
1843
|
+
}
|
|
1844
|
+
hostOAuthJobs.clear();
|
|
1315
1845
|
for (const job of interactiveJobs.values())
|
|
1316
1846
|
endInteractive(job, "cancelled");
|
|
1317
1847
|
interactiveJobs.clear();
|
|
@@ -1333,6 +1863,7 @@ export function createLinkService(options = {}) {
|
|
|
1333
1863
|
disconnect,
|
|
1334
1864
|
startDeviceAuth,
|
|
1335
1865
|
startAuthorization,
|
|
1866
|
+
openHostAuthorization,
|
|
1336
1867
|
respondAuthorization,
|
|
1337
1868
|
refreshRemoteCatalog,
|
|
1338
1869
|
startRemoteAuth,
|