@ibgib/space-gib 0.0.7 → 0.0.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ibgib/space-gib",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "type": "module",
5
5
  "description": "ibgib storage and provider service — SaaS at ibgib.space",
6
6
  "scripts": {
@@ -12,11 +12,11 @@
12
12
  "build:test": "tsc -p tsconfig.test.json"
13
13
  },
14
14
  "dependencies": {
15
- "@ibgib/core-gib": "^0.1.64",
15
+ "@ibgib/core-gib": "^0.1.65",
16
16
  "@ibgib/helper-gib": "^0.0.36",
17
17
  "@ibgib/ts-gib": "^0.5.33",
18
- "@ibgib/web-gib": "^0.0.50",
19
- "@ibgib/node-gib": "^0.0.8"
18
+ "@ibgib/web-gib": "^0.0.51",
19
+ "@ibgib/node-gib": "^0.0.9"
20
20
  },
21
21
  "engines": {
22
22
  "node": ">=20.0.0"
@@ -8,4 +8,4 @@
8
8
  /**
9
9
  * Version of space-gib, auto-updated by the build process.
10
10
  */
11
- export const AUTO_GENERATED_VERSION = '0.0.7';
11
+ export const AUTO_GENERATED_VERSION = '0.0.8';
@@ -53,7 +53,12 @@ export class SpaceGibApiBridge {
53
53
  /**
54
54
  * PUTs an evolved domain keystone and any related ibgibs (e.g. S^Stjp) to the server.
55
55
  */
56
- public async putEvolveKeystone(domainAddr: string, keystoneIbGib: IbGib_V1, relatedIbGibs: IbGib_V1[] = []): Promise<{ success: boolean; message?: string }> {
56
+ public async putEvolveKeystone(
57
+ domainAddr: string,
58
+ keystoneIbGib: IbGib_V1,
59
+ relatedIbGibs: IbGib_V1[] = [],
60
+ revokeTokens?: Record<string, string>
61
+ ): Promise<{ success: boolean; message?: string }> {
57
62
  const lc = `${this.lc}[${this.putEvolveKeystone.name}]`;
58
63
  try {
59
64
  console.log(`${lc} posting evolved keystone...`);
@@ -65,7 +70,7 @@ export class SpaceGibApiBridge {
65
70
  headers: {
66
71
  'Content-Type': 'application/json',
67
72
  },
68
- body: JSON.stringify({ keystoneIbGib, relatedIbGibs }),
73
+ body: JSON.stringify({ keystoneIbGib, relatedIbGibs, revokeTokens }),
69
74
  });
70
75
 
71
76
  const data = await response.json();
@@ -433,40 +433,137 @@
433
433
  }
434
434
  }
435
435
 
436
- .sso-actions {
436
+ /* Grouped Toggle Switch for Identity Providers */
437
+ .sso-toggle-group {
437
438
  display: flex;
438
- gap: 1rem;
439
+ gap: 1.5rem;
439
440
  margin-top: 1rem;
440
441
  flex-wrap: wrap;
441
442
  }
442
443
 
443
- .sso-btn {
444
+ .sso-toggle-card {
444
445
  flex: 1;
445
- min-width: 200px;
446
- display: inline-flex;
447
- justify-content: center;
446
+ min-width: 180px;
447
+ max-width: 240px;
448
+ height: 180px;
449
+ background: rgba(255, 255, 255, 0.02);
450
+ border: 1px solid rgba(255, 255, 255, 0.06);
451
+ border-radius: var(--radius-md, 8px);
452
+ display: flex;
453
+ flex-direction: column;
454
+ justify-content: space-between;
448
455
  align-items: center;
449
- gap: 0.5rem;
450
- padding: 0.75rem 1.25rem;
456
+ cursor: pointer;
457
+ overflow: hidden;
458
+ position: relative;
459
+ user-select: none;
460
+ transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
461
+ }
462
+
463
+ .sso-toggle-card:hover {
464
+ background: rgba(255, 255, 255, 0.04);
465
+ border-color: rgba(255, 255, 255, 0.12);
466
+ transform: translateY(-2px);
467
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
468
+ }
469
+
470
+ .sso-toggle-card:focus-visible {
471
+ outline: 2px solid var(--clr-accent, #50fa7b);
472
+ outline-offset: 2px;
473
+ }
474
+
475
+ /* Status Indicator bars: Top (Green/Active) and Bottom (Red/Faded) */
476
+ .toggle-status-indicator {
477
+ width: 100%;
478
+ height: 6px;
479
+ transition: all 0.25s ease-in-out;
480
+ }
481
+
482
+ .toggle-status-indicator.top {
483
+ background: transparent;
484
+ }
485
+
486
+ .toggle-status-indicator.bottom {
487
+ background: transparent;
488
+ }
489
+
490
+ /* Middle Content Wrapper */
491
+ .toggle-content {
492
+ display: flex;
493
+ flex-direction: column;
494
+ align-items: center;
495
+ justify-content: center;
496
+ gap: 0.6rem;
497
+ padding: 1rem;
498
+ text-align: center;
499
+ }
500
+
501
+ .sso-icon {
502
+ width: 2.25rem;
503
+ height: 2.25rem;
504
+ transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
505
+ }
506
+
507
+ .sso-toggle-card:hover .sso-icon {
508
+ transform: scale(1.1);
509
+ }
510
+
511
+ .provider-label {
512
+ font-size: 0.95rem;
451
513
  font-weight: 600;
514
+ color: var(--clr-text-primary, #ffffff);
452
515
  }
453
516
 
454
- .sso-btn.google {
455
- background: #4285F4;
456
- border-color: #4285F4;
457
- color: #ffffff;
517
+ .toggle-state-text {
518
+ font-size: 0.75rem;
519
+ font-weight: 500;
520
+ text-transform: uppercase;
521
+ letter-spacing: 0.05em;
522
+ color: var(--clr-text-secondary, #8fa08f);
523
+ transition: color 0.25s ease-in-out;
458
524
  }
459
525
 
460
- .sso-btn.google:hover {
461
- background: #357ae8;
526
+ /* Linked (Active State) styles */
527
+ .sso-toggle-card.linked {
528
+ background: rgba(80, 250, 123, 0.02);
529
+ border-color: rgba(80, 250, 123, 0.2);
530
+ box-shadow: 0 4px 16px rgba(80, 250, 123, 0.04);
462
531
  }
463
532
 
464
- .sso-btn.github {
465
- background: #333333;
466
- border-color: #444444;
467
- color: #ffffff;
533
+ .sso-toggle-card.linked:hover {
534
+ background: rgba(80, 250, 123, 0.04);
535
+ border-color: rgba(80, 250, 123, 0.35);
536
+ }
537
+
538
+ .sso-toggle-card.linked .toggle-status-indicator.top {
539
+ background: var(--clr-accent, #50fa7b);
540
+ box-shadow: 0 2px 10px rgba(80, 250, 123, 0.5);
541
+ }
542
+
543
+ .sso-toggle-card.linked .toggle-state-text {
544
+ color: var(--clr-accent, #50fa7b);
545
+ font-weight: bold;
546
+ }
547
+
548
+ /* Unlinked (Inactive/Faded State) styles */
549
+ .sso-toggle-card.unlinked {
550
+ opacity: 0.75;
551
+ }
552
+
553
+ .sso-toggle-card.unlinked:hover {
554
+ opacity: 1.0;
555
+ }
556
+
557
+ .sso-toggle-card.unlinked .toggle-status-indicator.bottom {
558
+ background: rgba(255, 85, 85, 0.4);
559
+ }
560
+
561
+ .sso-toggle-card.unlinked .toggle-state-text {
562
+ color: var(--clr-text-secondary, #8fa08f);
468
563
  }
469
564
 
470
- .sso-btn.github:hover {
471
- background: #444444;
565
+ /* Historical/Disabled state */
566
+ .sso-toggle-card.disabled {
567
+ pointer-events: none;
568
+ opacity: 0.4;
472
569
  }
@@ -76,11 +76,39 @@
76
76
 
77
77
  <!-- SSO Identity Linking Card -->
78
78
  <div id="sso-link-card" class="card hidden">
79
- <h3>Link Identity Provider</h3>
80
- <p class="description">Evolve this Domain Identity by linking a Web2 provider (Google, GitHub) to delegate sync access.</p>
81
- <div class="sso-actions">
82
- <button id="btn-link-google" class="action-btn sso-btn google">Link Google Account</button>
83
- <button id="btn-link-github" class="action-btn sso-btn github">Link GitHub Account</button>
79
+ <h3>Identity Providers</h3>
80
+ <p class="description">Securely link or unlink external OAuth2 providers to delegate sync access to this identity.</p>
81
+ <div class="sso-toggle-group">
82
+ <!-- Google Card Toggle -->
83
+ <div id="btn-link-google" class="sso-toggle-card google" role="button" tabindex="0">
84
+ <div class="toggle-status-indicator top"></div>
85
+ <div class="toggle-content">
86
+ <!-- Custom Google SVG Icon -->
87
+ <svg class="sso-icon" viewBox="0 0 24 24">
88
+ <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
89
+ <path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
90
+ <path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.06H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.94l2.85-2.22.81-.63z"/>
91
+ <path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.06l3.66 2.84c.87-2.6 3.3-4.52 6.16-4.52z"/>
92
+ </svg>
93
+ <span class="provider-label">Google</span>
94
+ <span class="toggle-state-text">Unlinked</span>
95
+ </div>
96
+ <div class="toggle-status-indicator bottom"></div>
97
+ </div>
98
+
99
+ <!-- GitHub Card Toggle -->
100
+ <div id="btn-link-github" class="sso-toggle-card github" role="button" tabindex="0">
101
+ <div class="toggle-status-indicator top"></div>
102
+ <div class="toggle-content">
103
+ <!-- Custom GitHub SVG Icon -->
104
+ <svg class="sso-icon" viewBox="0 0 24 24" fill="currentColor">
105
+ <path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>
106
+ </svg>
107
+ <span class="provider-label">GitHub</span>
108
+ <span class="toggle-state-text">Unlinked</span>
109
+ </div>
110
+ <div class="toggle-status-indicator bottom"></div>
111
+ </div>
84
112
  </div>
85
113
  <div id="sso-status-msg" class="status-msg hidden"></div>
86
114
  </div>
@@ -24,6 +24,8 @@ import { toDto } from "@ibgib/core-gib/dist/common/other/ibgib-helper.mjs";
24
24
  import { GLOBAL_LOG_A_LOT, APP_CONFIG, } from "../../constants.mjs";
25
25
  import { getComponentCtorArg, getIbGibGlobalThis_SpaceGib } from "../../helpers.web.mjs";
26
26
  import { devLog } from "../../dev-tools.mjs";
27
+ import { promptForSecret } from "@ibgib/web-gib/dist/helpers.web.mjs";
28
+ import { isSsoProviderLinked } from "@ibgib/web-gib/dist/identity/sso/sso-helpers.mjs";
27
29
 
28
30
  const logalot = GLOBAL_LOG_A_LOT || true;
29
31
 
@@ -84,8 +86,8 @@ interface KeystoneDetailsElements extends ElementsBase {
84
86
  historicalWarningBannerEl: HTMLDivElement;
85
87
  containerEl: HTMLDivElement;
86
88
  ssoLinkCardEl: HTMLDivElement;
87
- btnLinkGoogleEl: HTMLButtonElement;
88
- btnLinkGithubEl: HTMLButtonElement;
89
+ btnLinkGoogleEl: HTMLDivElement;
90
+ btnLinkGithubEl: HTMLDivElement;
89
91
  ssoStatusMsgEl: HTMLDivElement;
90
92
  }
91
93
 
@@ -190,8 +192,8 @@ export class KeystoneDetailsComponentInstance
190
192
  this.elements.containerEl = shadow.getElementById('container') as HTMLDivElement;
191
193
  this.elements.historicalWarningBannerEl = shadow.getElementById('historical-warning-banner') as HTMLDivElement;
192
194
  this.elements.ssoLinkCardEl = shadow.getElementById('sso-link-card') as HTMLDivElement;
193
- this.elements.btnLinkGoogleEl = shadow.getElementById('btn-link-google') as HTMLButtonElement;
194
- this.elements.btnLinkGithubEl = shadow.getElementById('btn-link-github') as HTMLButtonElement;
195
+ this.elements.btnLinkGoogleEl = shadow.getElementById('btn-link-google') as HTMLDivElement;
196
+ this.elements.btnLinkGithubEl = shadow.getElementById('btn-link-github') as HTMLDivElement;
195
197
  this.elements.ssoStatusMsgEl = shadow.getElementById('sso-status-msg') as HTMLDivElement;
196
198
 
197
199
  // ElementBase requires contentEl
@@ -229,8 +231,8 @@ export class KeystoneDetailsComponentInstance
229
231
  // Buttons
230
232
  this.elements!.btnVerifyChainEl.addEventListener('click', () => this.handleVerifyIdentityChain());
231
233
  this.elements!.btnSetActiveEl.addEventListener('click', () => this.handleSetActiveIdentity());
232
- this.elements!.btnLinkGoogleEl.addEventListener('click', () => this.handleSSOLink('google'));
233
- this.elements!.btnLinkGithubEl.addEventListener('click', () => this.handleSSOLink('github'));
234
+ this.elements!.btnLinkGoogleEl.addEventListener('click', () => this.handleSSOClick('google'));
235
+ this.elements!.btnLinkGithubEl.addEventListener('click', () => this.handleSSOClick('github'));
234
236
  }
235
237
 
236
238
  private toggleAccordion() {
@@ -348,9 +350,43 @@ export class KeystoneDetailsComponentInstance
348
350
  if (this.isHistoricalFrame) {
349
351
  this.elements.btnSetActiveEl.disabled = true;
350
352
  this.elements.btnVerifyChainEl.disabled = true;
353
+ this.elements.btnLinkGoogleEl.classList.add('disabled');
354
+ this.elements.btnLinkGithubEl.classList.add('disabled');
351
355
  } else {
352
356
  this.elements.btnVerifyChainEl.disabled = false;
353
357
  this.elements.btnSetActiveEl.disabled = isTimelineActive;
358
+ this.elements.btnLinkGoogleEl.classList.remove('disabled');
359
+ this.elements.btnLinkGithubEl.classList.remove('disabled');
360
+
361
+ // Check if already linked
362
+ const isGoogleLinked = isSsoProviderLinked({ keystone: this.ibGib, providerId: 'google' });
363
+ const isGithubLinked = isSsoProviderLinked({ keystone: this.ibGib, providerId: 'github' });
364
+
365
+ // Google Card States
366
+ const googleCard = this.elements.btnLinkGoogleEl;
367
+ const googleLabel = googleCard.querySelector('.toggle-state-text')!;
368
+ if (isGoogleLinked) {
369
+ googleCard.classList.remove('unlinked');
370
+ googleCard.classList.add('linked');
371
+ googleLabel.textContent = "Google Linked ✅";
372
+ } else {
373
+ googleCard.classList.remove('linked');
374
+ googleCard.classList.add('unlinked');
375
+ googleLabel.textContent = "Unlinked";
376
+ }
377
+
378
+ // GitHub Card States
379
+ const githubCard = this.elements.btnLinkGithubEl;
380
+ const githubLabel = githubCard.querySelector('.toggle-state-text')!;
381
+ if (isGithubLinked) {
382
+ githubCard.classList.remove('unlinked');
383
+ githubCard.classList.add('linked');
384
+ githubLabel.textContent = "GitHub Linked ✅";
385
+ } else {
386
+ githubCard.classList.remove('linked');
387
+ githubCard.classList.add('unlinked');
388
+ githubLabel.textContent = "Unlinked";
389
+ }
354
390
  }
355
391
  }
356
392
 
@@ -687,7 +723,7 @@ export class KeystoneDetailsComponentInstance
687
723
 
688
724
  // 2. Generate a secure, high-entropy user nonce
689
725
  const userNonce = (Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2)).substring(0, 32);
690
-
726
+
691
727
  // 3. Construct state payload
692
728
  const stateObj = {
693
729
  providerId,
@@ -710,16 +746,30 @@ export class KeystoneDetailsComponentInstance
710
746
  }
711
747
 
712
748
  // 6. Listen for callback from pop-up
749
+ let checkClosedInterval: any;
713
750
  const messageListener = async (event: MessageEvent) => {
714
751
  if (event.origin !== window.location.origin) return;
715
752
  const callbackData = event.data;
716
753
  if (callbackData && callbackData.type === 'sso-oauth-callback') {
717
754
  window.removeEventListener('message', messageListener);
755
+ clearInterval(checkClosedInterval);
718
756
  await this.handleSSOLinkCallback(callbackData.code, stateObj, providerConfig.redirectUri);
719
757
  }
720
758
  };
721
759
  window.addEventListener('message', messageListener);
722
760
 
761
+ // 7. Poll for manual closure of the popup window
762
+ checkClosedInterval = setInterval(() => {
763
+ if (popup.closed) {
764
+ clearInterval(checkClosedInterval);
765
+ window.removeEventListener('message', messageListener);
766
+ const statusEl = this.elements!.ssoStatusMsgEl;
767
+ if (statusEl.textContent && statusEl.textContent.startsWith('Linking')) {
768
+ this.setSSOStatus('Linking cancelled (window closed).', 'error');
769
+ }
770
+ }
771
+ }, 500);
772
+
723
773
  } catch (error) {
724
774
  console.error(`${lc} SSO link initiation failed: ${extractErrorMsg(error)}`);
725
775
  this.setSSOStatus(`Link failed: ${extractErrorMsg(error)}`, "error");
@@ -762,10 +812,47 @@ export class KeystoneDetailsComponentInstance
762
812
 
763
813
  const custodianPool = linkData.custodianPool;
764
814
 
765
- // 2. Prompt user for master secret to evolve locally
766
- const masterSecret = prompt("Link successful! Enter your local Master Secret (Passphrase) to sign and register the custodian pool:");
767
- if (!masterSecret) {
768
- this.setSSOStatus("Link cancelled by user.", "info");
815
+ // 2. Prompt user for master secret and verify it
816
+ const keystoneService = new KeystoneService_V1();
817
+ let masterSecret: string | undefined;
818
+ let isCorrect = false;
819
+ let tries = 0;
820
+ const maxTries = 8;
821
+
822
+ while (tries < maxTries && !isCorrect) {
823
+ try {
824
+ const promptMsg = tries === 0
825
+ ? "Link successful! Enter your local Master Secret (Passphrase) to sign and register the custodian pool"
826
+ : `Incorrect passphrase. Please try again (${maxTries - tries} tries remaining):`;
827
+ masterSecret = await promptForSecret({
828
+ msg: promptMsg,
829
+ confirm: false
830
+ });
831
+ } catch (error) {
832
+ // cancelled
833
+ if (logalot) { console.log(`${lc}[promptForSecret] user cancelled (I: 911ff1fb0234949228481968e9087626)`); }
834
+ masterSecret = undefined;
835
+ break;
836
+ }
837
+
838
+ if (!masterSecret) {
839
+ break;
840
+ }
841
+
842
+ // Verify secret using verifySigningSecret (requires manage poolId)
843
+ isCorrect = await keystoneService.verifySigningSecret({
844
+ keystoneIbGib: this.ibGib!,
845
+ signingSecret: masterSecret,
846
+ poolId: 'manage'
847
+ });
848
+
849
+ if (!isCorrect) {
850
+ tries++;
851
+ }
852
+ }
853
+
854
+ if (!isCorrect) {
855
+ this.setSSOStatus("Link cancelled or failed (incorrect passphrase).", "error");
769
856
  return;
770
857
  }
771
858
 
@@ -774,13 +861,12 @@ export class KeystoneDetailsComponentInstance
774
861
  const metaspace = getIbGibGlobalThis_SpaceGib(APP_CONFIG).metaspace;
775
862
  if (!metaspace) throw new Error("Metaspace not found");
776
863
  const space = await metaspace.getLocalUserSpace({ lock: false });
777
- if (!space) throw new Error("Local space not found");
864
+ if (!space) { throw new Error("Local space not found"); }
778
865
 
779
866
  // 3. Evolve parent keystone to include the custodian pool
780
- const keystoneService = new KeystoneService_V1();
781
867
  const evolvedParent = await keystoneService.addPools({
782
868
  latestKeystone: this.ibGib!,
783
- masterSecret,
869
+ masterSecret: masterSecret!,
784
870
  newPools: [custodianPool],
785
871
  metaspace,
786
872
  space
@@ -818,4 +904,110 @@ export class KeystoneDetailsComponentInstance
818
904
  this.setSSOStatus(`Link failed: ${extractErrorMsg(error)}`, "error");
819
905
  }
820
906
  }
907
+
908
+ protected async handleSSOClick(providerId: 'google' | 'github') {
909
+ const isLinked = isSsoProviderLinked({ keystone: this.ibGib, providerId });
910
+ if (isLinked) {
911
+ await this.handleSSOUnlink(providerId);
912
+ } else {
913
+ await this.handleSSOLink(providerId);
914
+ }
915
+ }
916
+
917
+ protected async handleSSOUnlink(providerId: 'google' | 'github') {
918
+ const lc = `${this.lc}[${this.handleSSOUnlink.name}]`;
919
+ this.setSSOStatus(`Unlinking ${providerId}...`, "info");
920
+
921
+ try {
922
+ // 1. Prompt user for passphrase
923
+ const keystoneService = new KeystoneService_V1();
924
+ let masterSecret: string | undefined;
925
+ let isCorrect = false;
926
+ let tries = 0;
927
+ const maxTries = 8;
928
+
929
+ while (tries < maxTries && !isCorrect) {
930
+ try {
931
+ const promptMsg = tries === 0
932
+ ? `Are you sure you want to unlink your ${providerId} account? If so, enter your local Master Secret (Passphrase) to authorize. Otherwise, click Cancel.`
933
+ : `Incorrect passphrase. Please try again (${maxTries - tries} tries remaining):`;
934
+ masterSecret = await promptForSecret({
935
+ msg: promptMsg,
936
+ confirm: false
937
+ });
938
+ } catch (error) {
939
+ masterSecret = undefined;
940
+ break;
941
+ }
942
+
943
+ if (!masterSecret) {
944
+ break;
945
+ }
946
+
947
+ // Verify secret using verifySigningSecret (requires manage poolId)
948
+ isCorrect = await keystoneService.verifySigningSecret({
949
+ keystoneIbGib: this.ibGib!,
950
+ signingSecret: masterSecret,
951
+ poolId: 'manage'
952
+ });
953
+
954
+ if (!isCorrect) {
955
+ tries++;
956
+ }
957
+ }
958
+
959
+ if (!isCorrect) {
960
+ this.setSSOStatus("Unlink cancelled or failed (incorrect passphrase).", "error");
961
+ return;
962
+ }
963
+
964
+ this.setSSOStatus("Evolving identity locally and removing custodian pool...", "info");
965
+
966
+ const metaspace = getIbGibGlobalThis_SpaceGib(APP_CONFIG).metaspace;
967
+ if (!metaspace) throw new Error("Metaspace not found");
968
+ const space = await metaspace.getLocalUserSpace({ lock: false });
969
+ if (!space) { throw new Error("Local space not found"); }
970
+
971
+ // 2. Evolve parent keystone to remove the custodian pool
972
+ const targetPoolId = `custodian-manage-${providerId}`;
973
+ const evolvedParent = await keystoneService.removePools({
974
+ latestKeystone: this.ibGib!,
975
+ masterSecret: masterSecret!,
976
+ poolIds: [targetPoolId],
977
+ metaspace,
978
+ space
979
+ });
980
+
981
+ // 3. Sync evolved parent keystone back to server
982
+ this.setSSOStatus("Publishing evolved identity keystone to server...", "info");
983
+ const syncRes = await spaceGibApiBridge.putEvolveKeystone(
984
+ getIbGibAddr({ ibGib: this.ibGib! }),
985
+ evolvedParent,
986
+ [] // No outward delegate timelines
987
+ );
988
+ if (!syncRes.success) {
989
+ throw new Error(`Server synchronization rejected: ${syncRes.message}`);
990
+ }
991
+
992
+ // 4. Update index locally
993
+ await updateSpecialIndex({
994
+ type: "keystones",
995
+ rel8nInfos: [
996
+ {
997
+ rel8nName: "keystone",
998
+ ibGibs: [toDto({ ibGib: evolvedParent })]
999
+ }
1000
+ ],
1001
+ metaspace,
1002
+ space
1003
+ });
1004
+
1005
+ this.setSSOStatus(`Successfully unlinked ${providerId}!`, "success");
1006
+ await this.loadIbGib({ getLatest: false });
1007
+
1008
+ } catch (error) {
1009
+ console.error(`${lc} SSO unlink failed: ${extractErrorMsg(error)}`);
1010
+ this.setSSOStatus(`Unlink failed: ${extractErrorMsg(error)}`, "error");
1011
+ }
1012
+ }
821
1013
  }
@@ -15,6 +15,8 @@ import { getIbGibAddr } from '@ibgib/ts-gib/dist/helper.mjs';
15
15
  import { IbGib_V1 } from '@ibgib/ts-gib/dist/V1/types.mjs';
16
16
  import { validateAndRegisterEvolveKeystone } from '@ibgib/core-gib/dist/sync/sync-peer/sync-peer-websocket/sync-peer-websocket-receiver/sync-websocket-peer-helpers.mjs';
17
17
  import { KeystoneIbGib_V1 } from '@ibgib/core-gib/dist/keystone/keystone-types.mjs';
18
+ import { loadSsoServerConfig } from '@ibgib/web-gib/dist/identity/sso/sso-config-helper.mjs';
19
+ import { SsoCustodianService } from '@ibgib/web-gib/dist/identity/sso/sso-custodian-service.mjs';
18
20
 
19
21
  import { GLOBAL_LOG_A_LOT } from '../../../constants.mjs';
20
22
  import { ServeGibHandlerWithMetaspaceBase } from '../../handler-base.mjs';
@@ -30,6 +32,7 @@ interface EvolveParams extends ParamsWithDomain {
30
32
  interface EvolveBody {
31
33
  keystoneIbGib: KeystoneIbGib_V1;
32
34
  relatedIbGibs?: IbGib_V1[];
35
+ revokeTokens?: Record<string, string>;
33
36
  }
34
37
 
35
38
  export class KeystoneEvolveHandler extends ServeGibHandlerWithMetaspaceBase<EvolveParams, EvolveBody> {
@@ -94,6 +97,36 @@ export class KeystoneEvolveHandler extends ServeGibHandlerWithMetaspaceBase<Evol
94
97
  space
95
98
  });
96
99
 
100
+ // Post-Evolution Hook: Handle SSO provider unlinking
101
+ const claim = keystoneIbGib.data?.proofs?.[0]?.claim;
102
+ if (claim?.details?.type === 'remove-pool') {
103
+ const removeInfo = claim.details.info;
104
+ if (removeInfo && Array.isArray(removeInfo.remove)) {
105
+ // Lazy-load custodian service config
106
+ const config = loadSsoServerConfig();
107
+ const custodian = new SsoCustodianService(config);
108
+
109
+ for (const poolId of removeInfo.remove) {
110
+ if (poolId.startsWith('custodian-manage-')) {
111
+ const providerId = poolId.replace('custodian-manage-', '');
112
+ console.log(`${lc} Evolved keystone removed custodian pool: ${poolId}. Performing unlink cleanup for provider: ${providerId}`);
113
+
114
+ // Revoke token if supplied by the client
115
+ const token = parsed.revokeTokens?.[providerId];
116
+ if (token) {
117
+ try {
118
+ await custodian.revokeToken(providerId as any, token);
119
+ } catch (e) {
120
+ console.warn(`${lc} Failed to revoke token for provider ${providerId}: ${extractErrorMsg(e)}`);
121
+ }
122
+ } else {
123
+ console.log(`${lc} No OAuth token supplied by client for revocation of provider: ${providerId}`);
124
+ }
125
+ }
126
+ }
127
+ }
128
+ }
129
+
97
130
  return this.ok({
98
131
  message: `Successfully evolved domain keystone with session keystone`,
99
132
  });
@@ -3,6 +3,8 @@ import { getIbGibAddr } from '@ibgib/ts-gib/dist/helper.mjs';
3
3
  import { loadSsoServerConfig } from '@ibgib/web-gib/dist/identity/sso/sso-config-helper.mjs';
4
4
  import { SsoCustodianService } from '@ibgib/web-gib/dist/identity/sso/sso-custodian-service.mjs';
5
5
  import { SsoProviderId } from '@ibgib/web-gib/dist/identity/sso/sso-types.mjs';
6
+ import { isSsoProviderLinked } from '@ibgib/web-gib/dist/identity/sso/sso-helpers.mjs';
7
+ import { KeystoneService_V1 } from '@ibgib/core-gib/dist/keystone/keystone-service-v1.mjs';
6
8
 
7
9
  import { GLOBAL_LOG_A_LOT } from '../../../constants.mjs';
8
10
  import { ServeGibHandlerWithMetaspaceBase } from '../../handler-base.mjs';
@@ -91,15 +93,34 @@ export class SsoLinkHandler extends ServeGibHandlerWithMetaspaceBase<LinkParams,
91
93
  return this.error(500, 'No local user space found in domain metaspace');
92
94
  }
93
95
 
96
+ // 0. Fetch target keystone and verify if already linked
97
+ const keystoneService = new KeystoneService_V1();
98
+ const targetKeystone = await keystoneService.getLatestKeystone({
99
+ addr: parentTjpAddr,
100
+ metaspace: reqCtx.metaspace,
101
+ space
102
+ });
103
+ if (!targetKeystone) {
104
+ return this.error(400, `Keystone not found for address: ${parentTjpAddr}`);
105
+ }
106
+
107
+ if (isSsoProviderLinked({ keystone: targetKeystone, providerId })) {
108
+ return this.error(400, `SSO provider "${providerId}" is already linked to this keystone.`);
109
+ }
110
+
94
111
  // 1. Authenticate with provider and retrieve user info
95
112
  const service = getCustodian();
96
113
  const userInfo = await service.getOAuthUserInfo(providerId, code, redirectUri);
97
114
 
98
115
  // 2. Generate the custodian foreign challenge pool
99
- const custodianPool = await service.createCustodianChallengePool(
100
- userInfo.providerKey,
101
- userNonce
102
- );
116
+ const custodianPool = await service.createCustodianChallengePool({
117
+ providerId,
118
+ providerKey: userInfo.providerKey,
119
+ userNonce,
120
+ keystoneAddr: parentTjpAddr,
121
+ metaspace: reqCtx.metaspace,
122
+ space
123
+ });
103
124
 
104
125
  console.log(`${lc} created custodian pool for: ${userInfo.providerKey}`);
105
126