@cjhyy/code-shell-server 0.9.26 → 0.9.28

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.
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomBytes, randomUUID } from "node:crypto";
2
2
  import { CredentialStore, beginRemoteLinkAuthorization, completeRemoteLinkAuthorization, revokeRemoteLinkAuthorization, isRemoteLinkCredential, connectCliLink, getCliLinkStatus, isCliLinkProvider, isCredentialSecretAvailable, summarizeOAuthCredentialSecret, validateLocalLinkToken, } from "@cjhyy/code-shell-core";
3
3
  import { listDesktopLinkProviders } from "./catalog.js";
4
+ import { readRemoteProviderCapabilities, REMOTE_LINK_ADAPTERS, } from "./remote-catalog.js";
4
5
  import { LinkDeviceOAuthBroker, isLocalBrowserLinkProvider, } from "./device-oauth.js";
5
6
  const messages = {
6
7
  invalid_request: "Link 请求参数无效。",
@@ -49,6 +50,9 @@ export function createLinkService(options = {}) {
49
50
  const operations = new Set();
50
51
  const jobs = new Map();
51
52
  const remoteJobs = new Map();
53
+ const interactiveJobs = new Map();
54
+ let remoteCatalog;
55
+ let catalogPending;
52
56
  const revokedOwners = new Set();
53
57
  let closed = false;
54
58
  let snapshotSignature = "";
@@ -148,11 +152,15 @@ export function createLinkService(options = {}) {
148
152
  ? summarizeOAuthCredentialSecret(credential.secret)
149
153
  : undefined;
150
154
  const remote = isRemoteLinkCredential(credential);
151
- const status = remote && meta.linkRemoteState !== "connected"
155
+ const status = (remote && meta.linkRemoteState !== "connected") ||
156
+ (meta.linkAuthSource === "browser-oauth" &&
157
+ meta.linkOAuthState &&
158
+ meta.linkOAuthState !== "connected") ||
159
+ (meta.linkAuthSource === "browser-oauth" && meta.linkCapabilityIds?.length === 0)
152
160
  ? "unavailable"
153
161
  : !isCredentialSecretAvailable(credential.secret)
154
162
  ? "unavailable"
155
- : oauth?.state === "expired"
163
+ : oauth?.state === "expired" && !oauth.canRefresh
156
164
  ? "expired"
157
165
  : oauth?.state === "invalid" || oauth?.state === "missing"
158
166
  ? "invalid"
@@ -193,17 +201,96 @@ export function createLinkService(options = {}) {
193
201
  if (!ids.has(id))
194
202
  revisions.delete(id);
195
203
  const remoteConfig = options.remoteLink?.();
204
+ const remoteProviders = remoteConfig
205
+ ? remoteCatalog?.issuer === remoteConfig.issuer
206
+ ? remoteCatalog.providers
207
+ : []
208
+ : [];
196
209
  const result = {
197
- providers: providers.map((provider) => ({
198
- ...provider,
199
- ...(isLocalBrowserLinkProvider(provider.id)
200
- ? { deviceAuth: createBroker().status(provider.id) }
201
- : {}),
202
- })),
210
+ providers: providers.map((provider) => {
211
+ const deviceAuth = isLocalBrowserLinkProvider(provider.id)
212
+ ? createBroker().status(provider.id)
213
+ : undefined;
214
+ const authModes = [];
215
+ for (const method of provider.connectionMethods) {
216
+ if (method.executionRuntime !== "local" || method.availability !== "available")
217
+ continue;
218
+ if (method.browserAuth?.flow === "device-code")
219
+ authModes.push({
220
+ id: "browser-oauth",
221
+ methodId: method.id,
222
+ kind: "device-code",
223
+ label: "网页授权",
224
+ available: !!deviceAuth?.configured,
225
+ preferred: !!deviceAuth?.configured,
226
+ ...(!deviceAuth?.configured
227
+ ? { unavailableReason: "当前 Host 未配置服务商授权。" }
228
+ : {}),
229
+ });
230
+ if (method.quickAuth)
231
+ authModes.push({
232
+ id: "cli-session",
233
+ methodId: method.id,
234
+ kind: "local-session",
235
+ label: "使用已有 CLI 登录",
236
+ available: true,
237
+ });
238
+ authModes.push({
239
+ id: "token",
240
+ methodId: method.id,
241
+ kind: "credential-input",
242
+ label: method.tokenLabel ?? provider.tokenLabel,
243
+ available: true,
244
+ preferred: !deviceAuth?.configured,
245
+ });
246
+ }
247
+ const remote = REMOTE_LINK_ADAPTERS.find((adapter) => adapter.id === provider.id &&
248
+ remoteProviders.some((remote) => remote.id === adapter.id));
249
+ return {
250
+ ...provider,
251
+ connectionMethods: [
252
+ ...provider.connectionMethods,
253
+ ...(remote
254
+ ? [
255
+ {
256
+ id: remote.methodId,
257
+ displayName: { zh: "服务器授权", en: "Server authorization" },
258
+ executionRuntime: "server",
259
+ secretLocation: "server",
260
+ authKind: "oauth",
261
+ availability: "available",
262
+ },
263
+ ]
264
+ : []),
265
+ ],
266
+ authModes: [
267
+ ...(remote
268
+ ? [
269
+ {
270
+ id: remote.authModeId,
271
+ methodId: remote.methodId,
272
+ kind: "redirect",
273
+ label: "网页授权",
274
+ preferred: true,
275
+ available: true,
276
+ },
277
+ ]
278
+ : []),
279
+ ...authModes,
280
+ ],
281
+ ...(deviceAuth ? { deviceAuth } : {}),
282
+ };
283
+ }),
203
284
  connections: current.credentials
204
285
  .filter(isLink)
205
286
  .map((credential) => masked(credential, current.projectIds.has(credential.id) ? "project" : "user")),
206
- capabilities: { token: true, cliBinding: true, deviceAuth: true, remoteAuth: !!remoteConfig },
287
+ capabilities: {
288
+ token: true,
289
+ cliBinding: true,
290
+ deviceAuth: true,
291
+ remoteAuth: remoteProviders.length > 0,
292
+ authorizationSteps: 1,
293
+ },
207
294
  ...(remoteConfig ? { remoteServer: { issuer: remoteConfig.issuer } } : {}),
208
295
  remoteCleanupPending: store.remoteLinkRetirementCount(),
209
296
  };
@@ -214,6 +301,41 @@ export function createLinkService(options = {}) {
214
301
  }
215
302
  return { ...result, revision: snapshotRevision };
216
303
  }
304
+ async function refreshRemoteCatalog(context) {
305
+ await authorize(context);
306
+ const config = options.remoteLink?.();
307
+ if (!config || (remoteCatalog?.issuer === config.issuer && remoteCatalog.expiresAt > now()))
308
+ return;
309
+ if (!catalogPending) {
310
+ catalogPending = (async () => {
311
+ let available;
312
+ try {
313
+ available = options.readRemoteCatalog
314
+ ? (await options.readRemoteCatalog(config)).flatMap((id) => {
315
+ const adapter = REMOTE_LINK_ADAPTERS.find((item) => item.id === id);
316
+ return adapter
317
+ ? [{ id, actions: [...adapter.actions], scopes: [...adapter.scopes] }]
318
+ : [];
319
+ })
320
+ : await readRemoteProviderCapabilities(config);
321
+ }
322
+ catch {
323
+ // Preserve the reviewed v1 behavior during temporary service/network failures.
324
+ available = remoteCatalog?.issuer === config.issuer ? remoteCatalog.providers : [];
325
+ }
326
+ if (!closed && options.remoteLink?.()?.issuer === config.issuer)
327
+ remoteCatalog = {
328
+ issuer: config.issuer,
329
+ expiresAt: now() + 60_000,
330
+ providers: available,
331
+ };
332
+ })().finally(() => {
333
+ catalogPending = undefined;
334
+ });
335
+ }
336
+ await catalogPending;
337
+ await authorize(context);
338
+ }
217
339
  function string(value, max = 200) {
218
340
  if (typeof value !== "string" ||
219
341
  !value.trim() ||
@@ -286,7 +408,7 @@ export function createLinkService(options = {}) {
286
408
  if (!store.compareAndSwap("user", prepared.id, prepared.expected, next, retirement))
287
409
  throw new LinkServiceError(409, "conflict");
288
410
  }
289
- async function commit(context, prepared, validation, secret, authSource) {
411
+ async function commit(context, prepared, validation, secret, authSource, lifecycle = undefined) {
290
412
  const credential = {
291
413
  id: prepared.id,
292
414
  type: "link",
@@ -308,8 +430,11 @@ export function createLinkService(options = {}) {
308
430
  },
309
431
  };
310
432
  return change(context, () => {
433
+ lifecycle?.beforeCommit();
311
434
  swap(prepared, credential);
312
- return masked(credential, "user");
435
+ const connection = masked(credential, "user");
436
+ lifecycle?.onCommitted(connection);
437
+ return connection;
313
438
  });
314
439
  }
315
440
  function tokenSecret(token) {
@@ -459,6 +584,290 @@ export function createLinkService(options = {}) {
459
584
  // Revocation must work during an active action; the host's credential observer aborts it.
460
585
  await change(context, () => swap({ id: credential.id, expected: credential, input: {} }, null), true);
461
586
  }
587
+ function terminal(value, state, extra = {}) {
588
+ const { step: _step, prompt: _prompt, redirect: _redirect, errorCode: _error, ...rest } = value;
589
+ return { ...rest, state, ...extra };
590
+ }
591
+ function endInteractive(job, code) {
592
+ if (job.public.state !== "pending")
593
+ return;
594
+ job.operation.controller.abort();
595
+ clearTimeout(job.timer);
596
+ finish(job.operation);
597
+ job.public = terminal(job.public, code === "cancelled" ? "cancelled" : "failed", {
598
+ errorCode: code,
599
+ });
600
+ job.expiresAt = now() + 5 * 60_000;
601
+ }
602
+ function pruneInteractive() {
603
+ for (const [id, job] of interactiveJobs) {
604
+ if (job.expiresAt > now())
605
+ continue;
606
+ if (job.public.state === "pending")
607
+ endInteractive(job, "authorization_expired");
608
+ else
609
+ interactiveJobs.delete(id);
610
+ }
611
+ }
612
+ function assertInteractivePending(job) {
613
+ if (job.public.state !== "pending" || job.operation.controller.signal.aborted)
614
+ throw new LinkServiceError(409, "cancelled");
615
+ if (job.expiresAt <= now())
616
+ throw new LinkServiceError(409, "authorization_expired");
617
+ }
618
+ function cliStep(job, expiresAt, installed, validation, message) {
619
+ if (validation &&
620
+ (validation.providerId !== job.prepared.input.providerId ||
621
+ typeof validation.identity.externalAccountId !== "string" ||
622
+ !validation.identity.externalAccountId))
623
+ throw new LinkServiceError(422, "provider_rejected");
624
+ job.confirmedCliAccountId = validation?.identity.externalAccountId;
625
+ return {
626
+ id: randomUUID(),
627
+ kind: "local-session",
628
+ expiresAt,
629
+ session: {
630
+ installed,
631
+ authenticated: !!validation,
632
+ canLogin: options.allowCliLogin === true && installed,
633
+ canInstall: options.allowCliLogin === true,
634
+ ...(validation ? { account: validation.identity.label } : {}),
635
+ ...(message ? { message } : {}),
636
+ },
637
+ };
638
+ }
639
+ async function detectCliSession(job) {
640
+ const providerId = job.prepared.input.providerId;
641
+ const status = await cliStatus(providerId, {
642
+ cwd: options.cwd,
643
+ signal: job.operation.controller.signal,
644
+ });
645
+ await authorize(job.context);
646
+ assertInteractivePending(job);
647
+ let validation;
648
+ if (status.installed && status.authenticated) {
649
+ try {
650
+ validation = await bindCli(providerId, {
651
+ cwd: options.cwd,
652
+ signal: job.operation.controller.signal,
653
+ loginIfNeeded: false,
654
+ });
655
+ }
656
+ catch {
657
+ // No identity has been confirmed; the user may log in or detect again.
658
+ }
659
+ await authorize(job.context);
660
+ assertInteractivePending(job);
661
+ }
662
+ return cliStep(job, job.public.expiresAt, status.installed, validation);
663
+ }
664
+ async function startAuthorization(context, input, rawAuthModeId) {
665
+ await authorize(context);
666
+ const authModeId = string(rawAuthModeId, 80);
667
+ if (authModeId === "remote-link")
668
+ await refreshRemoteCatalog(context);
669
+ const provider = snapshot().providers.find((candidate) => candidate.id === input?.providerId);
670
+ const mode = provider?.authModes?.find((candidate) => candidate.id === authModeId && candidate.methodId === input.methodId && candidate.available);
671
+ if (!mode)
672
+ throw new LinkServiceError(400, "invalid_request");
673
+ if (mode.kind === "redirect") {
674
+ const result = await startRemoteAuth(context, input);
675
+ const job = remoteJobs.get(result.id);
676
+ job.public.methodId = input.methodId;
677
+ job.public.authModeId = authModeId;
678
+ job.public.expiresAt = result.redirect.expiresAt;
679
+ job.public.step = { id: randomUUID(), kind: "redirect", ...result.redirect };
680
+ return structuredClone(job.public);
681
+ }
682
+ if (mode.kind === "device-code")
683
+ return startDeviceAuth(context, input);
684
+ pruneInteractive();
685
+ if (interactiveJobs.size >= 32)
686
+ throw new LinkServiceError(409, "busy");
687
+ const prepared = prepare(input);
688
+ const operation = begin(context);
689
+ const expiresAt = now() + 10 * 60_000;
690
+ const base = { id: randomUUID(), expiresAt: new Date(expiresAt).toISOString() };
691
+ const step = mode.kind === "credential-input"
692
+ ? {
693
+ ...base,
694
+ kind: "credential-input",
695
+ purpose: "credential",
696
+ fields: [
697
+ {
698
+ id: "token",
699
+ label: provider.tokenLabel,
700
+ secret: true,
701
+ required: true,
702
+ placeholder: provider.tokenPlaceholder,
703
+ maxLength: 16_384,
704
+ },
705
+ ],
706
+ }
707
+ : {
708
+ ...base,
709
+ kind: "local-session",
710
+ session: {
711
+ installed: false,
712
+ authenticated: false,
713
+ canInstall: options.allowCliLogin === true,
714
+ },
715
+ };
716
+ const job = {
717
+ context,
718
+ prepared,
719
+ operation,
720
+ expiresAt,
721
+ busy: false,
722
+ public: {
723
+ id: randomUUID(),
724
+ providerId: prepared.input.providerId,
725
+ methodId: input.methodId,
726
+ authModeId,
727
+ expiresAt: base.expiresAt,
728
+ state: "pending",
729
+ step,
730
+ },
731
+ };
732
+ interactiveJobs.set(job.public.id, job);
733
+ job.timer = setTimeout(() => endInteractive(job, "authorization_expired"), Math.max(1, expiresAt - now()));
734
+ job.timer.unref?.();
735
+ try {
736
+ if (step.kind === "local-session")
737
+ job.public.step = await detectCliSession(job);
738
+ await authorize(context);
739
+ assertInteractivePending(job);
740
+ return structuredClone(job.public);
741
+ }
742
+ catch (error) {
743
+ endInteractive(job, error instanceof LinkServiceError ? error.code : "cli_unavailable");
744
+ throw publicLinkError(error);
745
+ }
746
+ }
747
+ async function respondAuthorization(context, id, response) {
748
+ await authorize(context);
749
+ pruneInteractive();
750
+ const job = interactiveJobs.get(safeId(id));
751
+ if (!job || job.context.ownerId !== context.ownerId)
752
+ throw new LinkServiceError(404, "not_found");
753
+ await authorize(job.context);
754
+ if (job.public.state !== "pending" || job.busy)
755
+ throw new LinkServiceError(409, "conflict");
756
+ const step = job.public.step;
757
+ if (!response || response.stepId !== step.id || Date.parse(step.expiresAt) <= now())
758
+ throw new LinkServiceError(409, "conflict");
759
+ if (Object.keys(response).some((key) => !["stepId", "operation", "input"].includes(key)))
760
+ throw new LinkServiceError(400, "invalid_request");
761
+ if (response.input !== undefined &&
762
+ (!response.input || typeof response.input !== "object" || Array.isArray(response.input)))
763
+ throw new LinkServiceError(400, "invalid_request");
764
+ let token;
765
+ if (step.kind === "credential-input") {
766
+ if (response.operation !== "submit" ||
767
+ !response.input ||
768
+ Object.keys(response.input).some((key) => key !== "token"))
769
+ throw new LinkServiceError(400, "invalid_request");
770
+ token = string(response.input.token, 16_384);
771
+ }
772
+ else if (step.kind !== "local-session" ||
773
+ !["detect-session", "login-session", "bind-session"].includes(response.operation) ||
774
+ Object.keys(response.input ?? {}).length)
775
+ throw new LinkServiceError(400, "invalid_request");
776
+ if (response.operation === "login-session" && options.allowCliLogin !== true)
777
+ throw new LinkServiceError(400, "invalid_request");
778
+ if (response.operation === "bind-session" &&
779
+ (step.kind !== "local-session" || !step.session.authenticated || !job.confirmedCliAccountId))
780
+ throw new LinkServiceError(400, "invalid_request");
781
+ job.busy = true;
782
+ job.public = {
783
+ ...job.public,
784
+ errorCode: undefined,
785
+ step: { id: randomUUID(), kind: "processing", expiresAt: step.expiresAt },
786
+ };
787
+ let committed = false;
788
+ const check = async () => {
789
+ await authorize(context);
790
+ if (!committed) {
791
+ await authorize(job.context);
792
+ assertInteractivePending(job);
793
+ }
794
+ return true;
795
+ };
796
+ try {
797
+ if (response.operation === "detect-session") {
798
+ job.public.step = await detectCliSession(job);
799
+ await check();
800
+ }
801
+ else if (response.operation === "login-session") {
802
+ let validation;
803
+ try {
804
+ validation = await bindCli(job.prepared.input.providerId, { cwd: options.cwd, signal: job.operation.controller.signal, loginIfNeeded: true });
805
+ }
806
+ catch {
807
+ await check();
808
+ throw new LinkServiceError(422, "cli_unavailable");
809
+ }
810
+ await check();
811
+ job.public.step = cliStep(job, step.expiresAt, true, validation);
812
+ }
813
+ else {
814
+ let validation;
815
+ try {
816
+ validation =
817
+ token !== undefined
818
+ ? await validateToken(job.prepared.input.providerId, token, {
819
+ signal: job.operation.controller.signal,
820
+ })
821
+ : await bindCli(job.prepared.input.providerId, {
822
+ cwd: options.cwd,
823
+ signal: job.operation.controller.signal,
824
+ loginIfNeeded: false,
825
+ });
826
+ }
827
+ catch {
828
+ await check();
829
+ throw new LinkServiceError(422, token !== undefined ? "provider_rejected" : "cli_unavailable");
830
+ }
831
+ await check();
832
+ if (validation.providerId !== job.prepared.input.providerId)
833
+ throw new LinkServiceError(422, "provider_rejected");
834
+ if (token === undefined &&
835
+ validation.identity.externalAccountId !== job.confirmedCliAccountId) {
836
+ job.public.step = cliStep(job, step.expiresAt, true, validation, "CLI 已切换账号,请确认当前账号后继续。");
837
+ }
838
+ else {
839
+ await commit({ ownerId: context.ownerId, authorize: check }, job.prepared, validation, token ?? `cli-binding:${randomBytes(24).toString("base64url")}`, token !== undefined ? "manual-token" : "cli-session", {
840
+ beforeCommit: () => assertInteractivePending(job),
841
+ onCommitted: (connection) => {
842
+ committed = true;
843
+ job.public = terminal(job.public, "connected", { connection });
844
+ clearTimeout(job.timer);
845
+ finish(job.operation);
846
+ job.expiresAt = now() + 5 * 60_000;
847
+ },
848
+ });
849
+ }
850
+ }
851
+ await authorize(context);
852
+ return structuredClone(job.public);
853
+ }
854
+ catch (error) {
855
+ if (job.public.state === "pending") {
856
+ const code = error instanceof LinkServiceError ? error.code : "unavailable";
857
+ if (["provider_rejected", "cli_unavailable"].includes(code) &&
858
+ !job.operation.controller.signal.aborted &&
859
+ job.expiresAt > now())
860
+ job.public = { ...job.public, step: { ...step, id: randomUUID() }, errorCode: code };
861
+ else
862
+ endInteractive(job, code);
863
+ }
864
+ await authorize(context);
865
+ return structuredClone(job.public);
866
+ }
867
+ finally {
868
+ job.busy = false;
869
+ }
870
+ }
462
871
  function cancelJob(job) {
463
872
  job.operation.controller.abort();
464
873
  if (job.brokerAttempt)
@@ -466,12 +875,7 @@ export function createLinkService(options = {}) {
466
875
  if (job.timer)
467
876
  clearTimeout(job.timer);
468
877
  if (job.public.state === "pending")
469
- job.public = {
470
- id: job.public.id,
471
- providerId: job.public.providerId,
472
- state: "cancelled",
473
- errorCode: "cancelled",
474
- };
878
+ job.public = terminal(job.public, "cancelled", { errorCode: "cancelled" });
475
879
  finish(job.operation);
476
880
  job.expiresAt = now() + 5 * 60_000;
477
881
  }
@@ -479,12 +883,7 @@ export function createLinkService(options = {}) {
479
883
  if (job.public.state !== "pending")
480
884
  return;
481
885
  cancelJob(job);
482
- job.public = {
483
- id: job.public.id,
484
- providerId: job.public.providerId,
485
- state: "failed",
486
- errorCode: "authorization_expired",
487
- };
886
+ job.public = terminal(job.public, "failed", { errorCode: "authorization_expired" });
488
887
  }
489
888
  function pruneJobs() {
490
889
  for (const [id, job] of jobs)
@@ -496,41 +895,46 @@ export function createLinkService(options = {}) {
496
895
  }
497
896
  }
498
897
  async function completeDevice(job, prepared) {
898
+ let committed = false;
899
+ const assertPending = () => {
900
+ if (job.public.state !== "pending" || job.operation.controller.signal.aborted)
901
+ throw new LinkServiceError(409, "cancelled");
902
+ if (job.expiresAt <= now())
903
+ throw new LinkServiceError(409, "authorization_expired");
904
+ };
905
+ const check = async () => {
906
+ if (!committed) {
907
+ await authorize(job.context);
908
+ assertPending();
909
+ }
910
+ return true;
911
+ };
499
912
  try {
500
913
  const token = await job.broker.complete(job.brokerAttempt);
501
- await authorize(job.context);
502
- if (job.operation.controller.signal.aborted || job.public.state !== "pending")
503
- throw new LinkServiceError(409, "cancelled");
914
+ await check();
504
915
  if (token.providerId !== prepared.input.providerId)
505
916
  throw new LinkServiceError(422, "authorization_failed");
506
917
  const validation = await validateToken(token.providerId, token.accessToken, {
507
918
  signal: job.operation.controller.signal,
919
+ authKind: "oauth",
920
+ });
921
+ await check();
922
+ await commit({ ownerId: job.context.ownerId, authorize: check }, prepared, validation, tokenSecret(token), "browser-oauth", {
923
+ beforeCommit: assertPending,
924
+ onCommitted: (connection) => {
925
+ committed = true;
926
+ job.public = terminal(job.public, "connected", { connection });
927
+ clearTimeout(job.timer);
928
+ finish(job.operation);
929
+ job.expiresAt = now() + 5 * 60_000;
930
+ },
508
931
  });
509
- await authorize(job.context);
510
- if (job.operation.controller.signal.aborted || job.public.state !== "pending")
511
- throw new LinkServiceError(409, "cancelled");
512
- const connection = await commit({
513
- ...job.context,
514
- authorize: async () => (await job.context.authorize()) &&
515
- !job.operation.controller.signal.aborted &&
516
- job.public.state === "pending",
517
- }, prepared, validation, tokenSecret(token), "browser-oauth");
518
- if (job.public.state === "pending")
519
- job.public = {
520
- id: job.public.id,
521
- providerId: job.public.providerId,
522
- state: "connected",
523
- connection,
524
- };
525
932
  }
526
933
  catch (error) {
527
934
  if (job.public.state === "pending")
528
- job.public = {
529
- id: job.public.id,
530
- providerId: job.public.providerId,
531
- state: "failed",
935
+ job.public = terminal(job.public, "failed", {
532
936
  errorCode: error instanceof LinkServiceError ? error.code : "authorization_failed",
533
- };
937
+ });
534
938
  }
535
939
  finally {
536
940
  if (job.timer)
@@ -559,7 +963,13 @@ export function createLinkService(options = {}) {
559
963
  ownerId: context.ownerId,
560
964
  context,
561
965
  broker,
562
- public: { id, providerId, state: "pending" },
966
+ public: {
967
+ id,
968
+ providerId,
969
+ methodId: input.methodId,
970
+ authModeId: "browser-oauth",
971
+ state: "pending",
972
+ },
563
973
  expiresAt: now() + 30 * 60_000,
564
974
  operation,
565
975
  };
@@ -576,6 +986,8 @@ export function createLinkService(options = {}) {
576
986
  verificationUriComplete: prompt.verificationUriComplete,
577
987
  expiresAt: prompt.expiresAt,
578
988
  };
989
+ job.public.expiresAt = prompt.expiresAt;
990
+ job.public.step = { id: randomUUID(), kind: "device-code", ...job.public.prompt };
579
991
  job.timer = setTimeout(() => expireJob(job), Math.max(1, Math.min(30 * 60_000, job.expiresAt - now())));
580
992
  job.timer.unref?.();
581
993
  const result = structuredClone(job.public);
@@ -592,21 +1004,45 @@ export function createLinkService(options = {}) {
592
1004
  }
593
1005
  }
594
1006
  function pruneRemoteJobs() {
595
- for (const [id, job] of remoteJobs)
596
- if (job.attempt.expiresAt <= now())
1007
+ for (const [id, job] of remoteJobs) {
1008
+ if (job.attempt.expiresAt > now() && !job.retireAt)
1009
+ continue;
1010
+ if (!job.public.authModeId) {
597
1011
  remoteJobs.delete(id);
1012
+ continue;
1013
+ }
1014
+ if (job.public.state === "pending")
1015
+ job.public = terminal(job.public, "failed", { errorCode: "authorization_expired" });
1016
+ job.retireAt ??= now() + 5 * 60_000;
1017
+ if (job.retireAt <= now())
1018
+ remoteJobs.delete(id);
1019
+ }
598
1020
  }
599
1021
  async function startRemoteAuth(context, input) {
600
1022
  await authorize(context);
601
- pruneRemoteJobs();
602
- if (remoteJobs.size >= 32 ||
603
- [...remoteJobs.values()].filter((job) => job.context.ownerId === context.ownerId && job.public.state === "pending").length >= 2)
604
- throw new LinkServiceError(409, "busy");
605
- if (!input || input.providerId !== "github" || input.methodId !== "remote-link")
1023
+ const checkCapacity = () => {
1024
+ pruneRemoteJobs();
1025
+ if (remoteJobs.size >= 32 ||
1026
+ [...remoteJobs.values()].filter((job) => job.context.ownerId === context.ownerId && job.public.state === "pending").length >= 2)
1027
+ throw new LinkServiceError(409, "busy");
1028
+ };
1029
+ checkCapacity();
1030
+ if (!input ||
1031
+ input.methodId !== "remote-link" ||
1032
+ !REMOTE_LINK_ADAPTERS.some((adapter) => adapter.id === input.providerId))
606
1033
  throw new LinkServiceError(400, "invalid_request");
607
1034
  const config = options.remoteLink?.();
608
1035
  if (!config)
609
1036
  throw new LinkServiceError(503, "unavailable");
1037
+ await refreshRemoteCatalog(context);
1038
+ // Discovery awaits must not bypass owner revocation or the pending-job limit.
1039
+ await authorize(context);
1040
+ checkCapacity();
1041
+ const capabilities = remoteCatalog?.issuer === config.issuer
1042
+ ? remoteCatalog.providers.find((provider) => provider.id === input.providerId)
1043
+ : undefined;
1044
+ if (!capabilities)
1045
+ throw new LinkServiceError(503, "unavailable");
610
1046
  const id = input.connectionId === undefined ? `link-remote-${randomUUID()}` : safeId(input.connectionId);
611
1047
  let expected = null;
612
1048
  if (input.expectedRevision === null) {
@@ -615,20 +1051,23 @@ export function createLinkService(options = {}) {
615
1051
  }
616
1052
  else {
617
1053
  expected = reviewed(id, input.expectedRevision);
618
- if (!isRemoteLinkCredential(expected))
1054
+ if (!isRemoteLinkCredential(expected) || expected.meta?.linkProvider !== input.providerId)
619
1055
  throw new LinkServiceError(409, "conflict");
620
1056
  }
621
1057
  const prepared = { id, expected, input: { ...input, label: string(input.label) } };
622
1058
  let attempt;
623
1059
  try {
624
- attempt = beginRemoteLinkAuthorization(config, now());
1060
+ attempt = beginRemoteLinkAuthorization(config, now(), {
1061
+ providerId: input.providerId,
1062
+ actions: capabilities.actions,
1063
+ });
625
1064
  }
626
1065
  catch {
627
1066
  throw new LinkServiceError(400, "invalid_request");
628
1067
  }
629
1068
  const publicJob = {
630
1069
  id: randomUUID(),
631
- providerId: "github",
1070
+ providerId: input.providerId,
632
1071
  state: "pending",
633
1072
  redirect: {
634
1073
  authorizationUrl: attempt.authorizationUrl,
@@ -653,19 +1092,24 @@ export function createLinkService(options = {}) {
653
1092
  if (job.completing || job.public.state !== "pending")
654
1093
  throw new LinkServiceError(409, "conflict");
655
1094
  const originalConfig = JSON.stringify(job.attempt.configuration);
656
- const check = async () => {
657
- await authorize(context);
658
- await authorize(job.context);
659
- if (remoteJobs.get(id) !== job ||
660
- job.attempt.expiresAt <= now() ||
661
- job.public.state !== "pending")
1095
+ let committed = false;
1096
+ const assertPending = () => {
1097
+ if (remoteJobs.get(id) !== job || job.public.state !== "pending")
662
1098
  throw new LinkServiceError(409, "cancelled");
663
- // Re-normalize exactly as at start; a changed client, callback, issuer or secret invalidates the attempt.
1099
+ if (job.attempt.expiresAt <= now())
1100
+ throw new LinkServiceError(409, "authorization_expired");
664
1101
  const config = options.remoteLink?.();
665
1102
  if (!config ||
666
1103
  JSON.stringify(beginRemoteLinkAuthorization(config, now()).configuration) !== originalConfig)
667
1104
  throw new LinkServiceError(409, "conflict");
668
1105
  };
1106
+ const check = async () => {
1107
+ if (!committed) {
1108
+ await authorize(context);
1109
+ await authorize(job.context);
1110
+ assertPending();
1111
+ }
1112
+ };
669
1113
  job.completing = true;
670
1114
  let credential;
671
1115
  let staged;
@@ -686,15 +1130,17 @@ export function createLinkService(options = {}) {
686
1130
  return true;
687
1131
  },
688
1132
  };
689
- const connection = await change(guarded, () => {
1133
+ await change(guarded, () => {
1134
+ assertPending();
690
1135
  swap(job.prepared, credential, {
691
1136
  retireExpected: !!job.prepared.expected,
692
1137
  adopt: staged,
693
1138
  now: now(),
694
1139
  });
695
- return masked(credential, "user");
1140
+ const connection = masked(credential, "user");
1141
+ committed = true;
1142
+ job.public = terminal(job.public, "connected", { connection });
696
1143
  });
697
- job.public = { id, providerId: "github", state: "connected", connection };
698
1144
  // Replacing the active record and retaining its previous grant is one atomic write.
699
1145
  await retryRemoteCleanup();
700
1146
  if (store.remoteLinkRetirementCount())
@@ -703,6 +1149,11 @@ export function createLinkService(options = {}) {
703
1149
  return structuredClone(job.public);
704
1150
  }
705
1151
  catch (error) {
1152
+ if (committed) {
1153
+ // Notification or post-write authorization failure cannot revoke an adopted grant.
1154
+ await authorize(context);
1155
+ return structuredClone(job.public);
1156
+ }
706
1157
  // If the callback minted a grant but its destination changed, do not leave it usable remotely.
707
1158
  if (staged) {
708
1159
  store.readyRemoteLinkRetirement(staged, now());
@@ -717,12 +1168,10 @@ export function createLinkService(options = {}) {
717
1168
  /* Report failure below. */
718
1169
  }
719
1170
  }
720
- job.public = {
721
- id,
722
- providerId: "github",
723
- state: "failed",
724
- errorCode: error instanceof LinkServiceError ? error.code : "authorization_failed",
725
- };
1171
+ if (job.public.state === "pending")
1172
+ job.public = terminal(job.public, "failed", {
1173
+ errorCode: error instanceof LinkServiceError ? error.code : "authorization_failed",
1174
+ });
726
1175
  if (error instanceof LinkServiceError)
727
1176
  throw error;
728
1177
  throw new LinkServiceError(422, "authorization_failed");
@@ -732,6 +1181,15 @@ export function createLinkService(options = {}) {
732
1181
  await authorize(context);
733
1182
  pruneJobs();
734
1183
  pruneRemoteJobs();
1184
+ pruneInteractive();
1185
+ const interactive = interactiveJobs.get(safeId(id));
1186
+ if (interactive) {
1187
+ if (interactive.context.ownerId !== context.ownerId)
1188
+ throw new LinkServiceError(404, "not_found");
1189
+ if (interactive.public.state === "pending")
1190
+ await authorize(interactive.context);
1191
+ return structuredClone(interactive.public);
1192
+ }
735
1193
  const remote = remoteJobs.get(safeId(id));
736
1194
  if (remote) {
737
1195
  if (remote.context.ownerId !== context.ownerId)
@@ -744,8 +1202,21 @@ export function createLinkService(options = {}) {
744
1202
  return structuredClone(job.public);
745
1203
  }
746
1204
  async function cancelAuthorization(context, id) {
747
- await authorization(context, id);
748
- remoteJobs.delete(id);
1205
+ await authorize(context);
1206
+ safeId(id);
1207
+ const ownerId = remoteJobs.get(id)?.context.ownerId ??
1208
+ interactiveJobs.get(id)?.context.ownerId ??
1209
+ jobs.get(id)?.ownerId;
1210
+ if (!ownerId || ownerId !== context.ownerId)
1211
+ throw new LinkServiceError(404, "not_found");
1212
+ const remote = remoteJobs.get(id);
1213
+ if (remote?.public.state === "pending") {
1214
+ remote.public = terminal(remote.public, "cancelled", { errorCode: "cancelled" });
1215
+ remote.retireAt = now() + 5 * 60_000;
1216
+ }
1217
+ const interactive = interactiveJobs.get(id);
1218
+ if (interactive)
1219
+ endInteractive(interactive, "cancelled");
749
1220
  const job = jobs.get(id);
750
1221
  if (job)
751
1222
  cancelJob(job);
@@ -753,6 +1224,11 @@ export function createLinkService(options = {}) {
753
1224
  }
754
1225
  function cancelOwner(ownerId) {
755
1226
  revokedOwners.add(ownerId);
1227
+ for (const [id, job] of interactiveJobs)
1228
+ if (job.context.ownerId === ownerId) {
1229
+ endInteractive(job, "cancelled");
1230
+ interactiveJobs.delete(id);
1231
+ }
756
1232
  for (const [id, job] of remoteJobs)
757
1233
  if (job.context.ownerId === ownerId)
758
1234
  remoteJobs.delete(id);
@@ -772,6 +1248,9 @@ export function createLinkService(options = {}) {
772
1248
  clearInterval(cleanupTimer);
773
1249
  clearTimeout(cleanupStartup);
774
1250
  remoteJobs.clear();
1251
+ for (const job of interactiveJobs.values())
1252
+ endInteractive(job, "cancelled");
1253
+ interactiveJobs.clear();
775
1254
  for (const operation of operations)
776
1255
  operation.controller.abort();
777
1256
  operations.clear();
@@ -789,6 +1268,9 @@ export function createLinkService(options = {}) {
789
1268
  rename,
790
1269
  disconnect,
791
1270
  startDeviceAuth,
1271
+ startAuthorization,
1272
+ respondAuthorization,
1273
+ refreshRemoteCatalog,
792
1274
  startRemoteAuth,
793
1275
  completeRemoteAuth,
794
1276
  retryRemoteCleanup,