u-chan-secrets 5.0.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +233 -91
  2. package/dist/cli.js +115 -19
  3. package/dist/client/claude-setup.js +97 -0
  4. package/dist/client/codex-setup.js +6 -5
  5. package/dist/client/discord.js +67 -0
  6. package/dist/client/github.js +28 -0
  7. package/dist/client/setup.js +40 -6
  8. package/dist/client/update-package.js +166 -0
  9. package/dist/client/update.js +138 -0
  10. package/dist/client/windows-command.js +54 -0
  11. package/dist/core/browser.js +0 -5
  12. package/dist/core/config.js +1 -0
  13. package/dist/core/doctor.js +5 -1
  14. package/dist/core/vault.js +110 -5
  15. package/dist/discord/http.js +84 -0
  16. package/dist/discord/policy.js +47 -0
  17. package/dist/discord/service.js +349 -0
  18. package/dist/discord/store.js +85 -0
  19. package/dist/github/http.js +93 -0
  20. package/dist/github/policy.js +24 -0
  21. package/dist/github/service.js +185 -0
  22. package/dist/github/store.js +54 -0
  23. package/dist/index.js +1 -1
  24. package/dist/ipc/client.js +74 -6
  25. package/dist/ipc/server.js +38 -3
  26. package/dist/mcp/server.js +25 -2
  27. package/dist/platform/windows/assets/prompt-src.js +160 -38
  28. package/dist/platform/windows/doctor.js +2 -2
  29. package/dist/platform/windows/install.js +4 -0
  30. package/dist/service/control.js +47 -5
  31. package/dist/service/doctor.js +3 -0
  32. package/dist/service/main.js +34 -17
  33. package/dist/service/manage.js +20 -16
  34. package/dist/service/state.js +28 -0
  35. package/dist/service/update/coordinator.js +171 -0
  36. package/dist/service/update/files.js +47 -0
  37. package/dist/service/update/gate.js +21 -0
  38. package/dist/service/update/journal.js +157 -0
  39. package/dist/service/update/native.js +50 -0
  40. package/dist/service/update/network.js +28 -0
  41. package/dist/service/update/protocol.js +28 -0
  42. package/dist/service/update/release.js +102 -0
  43. package/dist/service/update/worker-main.js +116 -0
  44. package/dist/service/update/worker.js +58 -0
  45. package/package.json +1 -1
  46. package/scripts/debug-install.bat +69 -69
  47. package/scripts/desktop-browser.ps1 +110 -103
  48. package/scripts/login-bw.bat +21 -21
  49. package/scripts/migrate.bat +4 -4
  50. package/scripts/start-browser.ps1 +33 -33
  51. package/scripts/windows-security/desktop-matrix.mjs +54 -54
@@ -303,6 +303,7 @@ namespace UChan.Platform.Windows.Assets
303
303
  if (args[i] == "--domains") domains = ParseLines(args[i + 1]);
304
304
  return RunManagement(title, phrase, lines, domains);
305
305
  }
306
+ if (mode == "password") return RunPasswordDialog(title, phrase, lines);
306
307
 
307
308
  Form form = new Form();
308
309
  form.Text = title;
@@ -311,7 +312,7 @@ namespace UChan.Platform.Windows.Assets
311
312
  form.StartPosition = FormStartPosition.CenterScreen;
312
313
  form.MaximizeBox = false;
313
314
  form.MinimizeBox = false;
314
- form.ClientSize = new Size(460, mode == "password" ? 380 : 320);
315
+ form.ClientSize = new Size(760, 520);
315
316
 
316
317
  // Anti-phishing phrase banner
317
318
  Panel banner = new Panel();
@@ -335,35 +336,16 @@ namespace UChan.Platform.Windows.Assets
335
336
  contentPanel.Padding = new Padding(16);
336
337
  contentPanel.AutoScroll = true;
337
338
 
338
- int yOffset = 60;
339
- foreach (string line in lines)
340
- {
341
- Label lbl = new Label();
342
- lbl.Text = line;
343
- lbl.Location = new Point(16, yOffset);
344
- lbl.Size = new Size(428, 22);
345
- lbl.AutoEllipsis = true;
346
- contentPanel.Controls.Add(lbl);
347
- yOffset += 24;
348
- }
349
-
350
- TextBox txtPassword = null;
351
- if (mode == "password")
352
- {
353
- Label pwdLabel = new Label();
354
- pwdLabel.Text = "Master Password:";
355
- pwdLabel.Location = new Point(16, yOffset + 8);
356
- pwdLabel.Size = new Size(428, 20);
357
- contentPanel.Controls.Add(pwdLabel);
358
- yOffset += 30;
359
-
360
- txtPassword = new TextBox();
361
- txtPassword.Location = new Point(16, yOffset);
362
- txtPassword.Size = new Size(428, 24);
363
- txtPassword.UseSystemPasswordChar = true;
364
- txtPassword.MaxLength = 512;
365
- contentPanel.Controls.Add(txtPassword);
366
- }
339
+ TextBox details = new TextBox();
340
+ details.Multiline = true;
341
+ details.ReadOnly = true;
342
+ details.WordWrap = true;
343
+ details.ScrollBars = ScrollBars.Vertical;
344
+ details.MaxLength = 65536;
345
+ details.Location = new Point(16, 60);
346
+ details.Size = new Size(728, 330);
347
+ details.Text = string.Join(Environment.NewLine, lines.ToArray());
348
+ contentPanel.Controls.Add(details);
367
349
 
368
350
  // Bottom panel for countdown & buttons
369
351
  Panel bottomPanel = new Panel();
@@ -418,10 +400,6 @@ namespace UChan.Platform.Windows.Assets
418
400
  form.Shown += (s, e) =>
419
401
  {
420
402
  timer.Start();
421
- if (txtPassword != null)
422
- {
423
- txtPassword.Focus();
424
- }
425
403
  };
426
404
 
427
405
  DialogResult result = form.ShowDialog();
@@ -429,16 +407,160 @@ namespace UChan.Platform.Windows.Assets
429
407
 
430
408
  if (result == DialogResult.OK)
431
409
  {
432
- if (mode == "password" && txtPassword != null)
433
- {
434
- Console.Write(txtPassword.Text);
435
- }
436
410
  return 0;
437
411
  }
438
412
 
439
413
  return 1;
440
414
  }
441
415
 
416
+ private static int RunPasswordDialog(string title, string phrase, List<string> lines)
417
+ {
418
+ using (Form form = new Form())
419
+ using (Timer timer = new Timer())
420
+ {
421
+ bool unlock = lines.Count == 1 && lines[0] == "Unlock the vault";
422
+ form.Text = title;
423
+ form.Font = new Font("Segoe UI", 10f);
424
+ form.AutoScaleDimensions = new SizeF(96f, 96f);
425
+ form.AutoScaleMode = AutoScaleMode.Dpi;
426
+ form.StartPosition = FormStartPosition.CenterScreen;
427
+ form.FormBorderStyle = FormBorderStyle.FixedDialog;
428
+ form.MaximizeBox = false;
429
+ form.MinimizeBox = false;
430
+ form.TopMost = true;
431
+ form.BackColor = SystemColors.Window;
432
+ form.ForeColor = SystemColors.WindowText;
433
+ form.ClientSize = new Size(520, 360);
434
+ form.Padding = new Padding(24);
435
+
436
+ TableLayoutPanel layout = new TableLayoutPanel();
437
+ layout.Dock = DockStyle.Top;
438
+ layout.AutoSize = true;
439
+ layout.ColumnCount = 1;
440
+ layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
441
+ layout.RowCount = 7;
442
+ for (int row = 0; row < layout.RowCount; row++) layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
443
+
444
+ Label heading = new Label();
445
+ heading.Text = unlock ? "Unlock vault" : "Confirm action";
446
+ heading.Font = new Font("Segoe UI", 18f, FontStyle.Bold);
447
+ heading.AutoSize = true;
448
+ heading.Margin = new Padding(0, 0, 0, 16);
449
+
450
+ Label verification = new Label();
451
+ verification.Text = "Verification phrase: " + phrase;
452
+ verification.AutoSize = true;
453
+ verification.MaximumSize = new Size(472, 0);
454
+ verification.Dock = DockStyle.Fill;
455
+ verification.Padding = new Padding(12);
456
+ verification.Margin = new Padding(0, 0, 0, 16);
457
+ verification.BackColor = SystemInformation.HighContrast ? SystemColors.Info : Color.FromArgb(235, 243, 255);
458
+ verification.ForeColor = SystemInformation.HighContrast ? SystemColors.InfoText : Color.FromArgb(16, 44, 87);
459
+
460
+ TextBox details = new TextBox();
461
+ details.Multiline = true;
462
+ details.ReadOnly = true;
463
+ details.WordWrap = true;
464
+ details.ScrollBars = lines.Count > 1 ? ScrollBars.Vertical : ScrollBars.None;
465
+ details.BorderStyle = BorderStyle.None;
466
+ details.BackColor = SystemColors.Window;
467
+ details.ForeColor = SystemColors.WindowText;
468
+ details.Text = string.Join(Environment.NewLine, lines.ToArray());
469
+ details.Height = Math.Min(144, Math.Max(32, lines.Count * 22));
470
+ details.Dock = DockStyle.Fill;
471
+ details.Margin = new Padding(0, 0, 0, 16);
472
+ details.AccessibleName = "Requested action";
473
+ details.TabStop = false;
474
+
475
+ Label passwordLabel = new Label();
476
+ passwordLabel.Text = "Master password";
477
+ passwordLabel.AutoSize = true;
478
+ passwordLabel.Margin = new Padding(0, 0, 0, 8);
479
+
480
+ TextBox txtPassword = new TextBox();
481
+ txtPassword.UseSystemPasswordChar = true;
482
+ txtPassword.MaxLength = 512;
483
+ txtPassword.Dock = DockStyle.Fill;
484
+ txtPassword.Margin = new Padding(0, 0, 0, 12);
485
+ txtPassword.AccessibleName = "Master password";
486
+ txtPassword.TabIndex = 0;
487
+
488
+ Label hint = new Label();
489
+ hint.Text = "Check your verification phrase before entering your password.";
490
+ hint.AutoSize = true;
491
+ hint.MaximumSize = new Size(472, 0);
492
+ hint.Margin = new Padding(0, 0, 0, 24);
493
+
494
+ TableLayoutPanel footer = new TableLayoutPanel();
495
+ footer.Dock = DockStyle.Fill;
496
+ footer.AutoSize = true;
497
+ footer.ColumnCount = 3;
498
+ footer.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
499
+ footer.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
500
+ footer.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
501
+ footer.Margin = new Padding(0);
502
+ Label countdown = new Label();
503
+ countdown.AutoSize = true;
504
+ countdown.Anchor = AnchorStyles.Left;
505
+ countdown.Margin = new Padding(0, 0, 12, 0);
506
+ countdown.Text = "Closes in 2:00";
507
+
508
+ Button cancel = new Button();
509
+ cancel.Text = "Cancel";
510
+ cancel.Size = new Size(96, 36);
511
+ cancel.Margin = new Padding(0, 0, 8, 0);
512
+ cancel.DialogResult = DialogResult.Cancel;
513
+ cancel.TabIndex = 2;
514
+ Button submit = new Button();
515
+ submit.Text = unlock ? "Unlock" : "Confirm";
516
+ submit.Size = new Size(104, 36);
517
+ submit.Margin = new Padding(0);
518
+ submit.DialogResult = DialogResult.OK;
519
+ submit.Enabled = false;
520
+ submit.TabIndex = 1;
521
+ submit.FlatStyle = FlatStyle.Flat;
522
+ submit.FlatAppearance.BorderSize = 1;
523
+ submit.UseVisualStyleBackColor = false;
524
+ submit.BackColor = SystemColors.Control;
525
+ submit.ForeColor = SystemColors.GrayText;
526
+ txtPassword.TextChanged += delegate {
527
+ submit.Enabled = txtPassword.Text.Length > 0;
528
+ submit.BackColor = submit.Enabled ? (SystemInformation.HighContrast ? SystemColors.Highlight : Color.FromArgb(29, 78, 216)) : SystemColors.Control;
529
+ submit.ForeColor = submit.Enabled ? (SystemInformation.HighContrast ? SystemColors.HighlightText : Color.White) : SystemColors.GrayText;
530
+ };
531
+ footer.Controls.Add(countdown, 0, 0);
532
+ footer.Controls.Add(cancel, 1, 0);
533
+ footer.Controls.Add(submit, 2, 0);
534
+ layout.Controls.Add(heading, 0, 0);
535
+ layout.Controls.Add(verification, 0, 1);
536
+ layout.Controls.Add(details, 0, 2);
537
+ layout.Controls.Add(passwordLabel, 0, 3);
538
+ layout.Controls.Add(txtPassword, 0, 4);
539
+ layout.Controls.Add(hint, 0, 5);
540
+ layout.Controls.Add(footer, 0, 6);
541
+ form.Controls.Add(layout);
542
+ form.AcceptButton = submit;
543
+ form.CancelButton = cancel;
544
+ form.Load += delegate {
545
+ layout.PerformLayout();
546
+ form.ClientSize = new Size(form.ClientSize.Width, layout.GetPreferredSize(new Size(layout.Width, 0)).Height + form.Padding.Vertical);
547
+ };
548
+ int remainingSeconds = 120;
549
+ timer.Interval = 1000;
550
+ timer.Tick += delegate {
551
+ remainingSeconds--;
552
+ countdown.Text = "Closes in " + (remainingSeconds / 60).ToString() + ":" + (remainingSeconds % 60).ToString("00");
553
+ if (remainingSeconds <= 0) { form.DialogResult = DialogResult.Cancel; form.Close(); }
554
+ };
555
+ form.Shown += delegate { txtPassword.Focus(); timer.Start(); };
556
+ DialogResult result = form.ShowDialog();
557
+ timer.Stop();
558
+ if (result == DialogResult.OK) Console.Write(txtPassword.Text);
559
+ txtPassword.Clear();
560
+ return result == DialogResult.OK ? 0 : 1;
561
+ }
562
+ }
563
+
442
564
  private static int RunManagement(string title, string phrase, List<string> lines, List<string> domains)
443
565
  {
444
566
  using (Form form = new Form())
@@ -2,8 +2,8 @@ import { commandState } from '../../core/doctor.js';
2
2
  export const checkWindowsRegistration = () => commandState('sc.exe', ['query', 'u-chan-secrets'], s => /SERVICE_NAME:\s*u-chan-secrets/i.test(s));
3
3
  export const checkWindowsUac = () => commandState('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
4
4
  "$p=Get-ItemProperty -LiteralPath 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System'; if($p.EnableLUA -eq 1 -and $p.ConsentPromptBehaviorAdmin -eq 2 -and $p.PromptOnSecureDesktop -eq 1){'SAFE'}else{'UNSAFE'}"], s => s.trim() === 'SAFE');
5
- export function checkWindowsPermissions(home) {
5
+ export function checkWindowsPermissions(home, recursive = true) {
6
6
  const encoded = Buffer.from(home, 'utf8').toString('base64');
7
- const script = `$ErrorActionPreference='Stop'; $p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}')); $root=Get-Item -LiteralPath $p -Force; if($root.Attributes -band [IO.FileAttributes]::ReparsePoint){'UNSAFE';exit}; $items=@($root)+@(Get-ChildItem -LiteralPath $p -Force -Recurse); foreach($i in $items){if($i.Attributes -band [IO.FileAttributes]::ReparsePoint){'UNSAFE';exit}; $acl=$i.GetAccessControl(); $owner=$acl.GetOwner([Security.Principal.SecurityIdentifier]).Value; if($owner -notin @('S-1-5-18','S-1-5-32-544')){'UNSAFE';exit}; foreach($ace in $acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])){if($ace.AccessControlType -eq 'Allow' -and $ace.IdentityReference.Value -notin @('S-1-5-18','S-1-5-32-544')){'UNSAFE';exit}} }; 'SAFE'`;
7
+ const script = `$ErrorActionPreference='Stop'; $p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}')); $root=Get-Item -LiteralPath $p -Force; if($root.Attributes -band [IO.FileAttributes]::ReparsePoint){'UNSAFE';exit}; $items=@($root)${recursive ? '+@(Get-ChildItem -LiteralPath $p -Force -Recurse)' : ''}; foreach($i in $items){if($i.Attributes -band [IO.FileAttributes]::ReparsePoint){'UNSAFE';exit}; $acl=$i.GetAccessControl(); $owner=$acl.GetOwner([Security.Principal.SecurityIdentifier]).Value; if($owner -notin @('S-1-5-18','S-1-5-32-544')){'UNSAFE';exit}; foreach($ace in $acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])){if($ace.AccessControlType -eq 'Allow' -and $ace.IdentityReference.Value -notin @('S-1-5-18','S-1-5-32-544')){'UNSAFE';exit}} }; 'SAFE'`;
8
8
  return commandState('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], s => s.trim() === 'SAFE', 60000);
9
9
  }
@@ -92,6 +92,10 @@ export async function installWindowsService(opts = {}) {
92
92
  const pkgRoot = opts.packageRoot ?? fileURLToPath(new URL('../../../', import.meta.url));
93
93
  await fs.cp(path.join(pkgRoot, 'dist'), path.join(stagedProgram, 'dist'), { recursive: true });
94
94
  await fs.cp(path.join(pkgRoot, 'package.json'), path.join(stagedProgram, 'package.json'));
95
+ await fs.mkdir(path.join(stagedProgram, 'scripts'));
96
+ for (const file of ['start-browser.ps1', 'desktop-browser.ps1']) {
97
+ await fs.copyFile(path.join(pkgRoot, 'scripts', file), path.join(stagedProgram, 'scripts', file));
98
+ }
95
99
  const srcNm = path.join(pkgRoot, 'node_modules');
96
100
  const destNm = path.join(stagedProgram, 'node_modules');
97
101
  {
@@ -3,18 +3,22 @@ import { LOGIN_MSG } from '../core/vault.js';
3
3
  import { redact } from '../core/redact.js';
4
4
  import { UserFacingError } from '../core/types.js';
5
5
  import { GrantCoordinator } from './grants.js';
6
+ import { performance } from 'node:perf_hooks';
6
7
  const BLOCKED = 'too many wrong passwords — try again in 15 minutes';
7
8
  const TITLE = 'U-Chan Secrets';
8
9
  /** Commands from the `u-chan` CLI. Audit entries never contain passwords or sessions. */
9
10
  export class ControlService {
10
11
  d;
11
12
  grants;
13
+ unlockPending;
14
+ lockEpoch = 0;
15
+ agentUnlockAfter = -Infinity;
12
16
  constructor(d) {
13
17
  this.d = d;
14
18
  this.grants = d.grants ?? new GrantCoordinator(d.allowlist);
15
19
  }
16
20
  async capabilities() {
17
- return { ok: true, message: JSON.stringify({ protocol: 3, features: ['doctor', 'success_conditions', 'grants', 'verified_session_transfer'], serviceVersion: this.d.serviceVersion }) };
21
+ return { ok: true, message: JSON.stringify({ protocol: 3, features: ['doctor', 'success_conditions', 'grants', 'verified_session_transfer', 'agent_unlock', ...(this.d.githubSupported ? ['github_broker'] : []), ...(this.d.discordSupported ? ['discord_broker', 'discord_discovery'] : []), ...(this.d.updateBoot ? ['coordinated_update'] : [])], serviceVersion: this.d.serviceVersion, ...(this.d.updateBoot ? { release: this.d.updateBoot } : {}) }) };
18
22
  }
19
23
  async diagnose() {
20
24
  try {
@@ -63,15 +67,53 @@ export class ControlService {
63
67
  limiter.fail();
64
68
  return { ok: false, message: 'wrong password' };
65
69
  }
66
- unlock() {
67
- return this.guarded('unlock', async () => {
68
- if ((await this.d.vault.status()) === 'unauthenticated')
70
+ requestUnlock() { return this.unlock(true); }
71
+ unlock(agentRequested = false) {
72
+ if (this.unlockPending)
73
+ return this.unlockPending;
74
+ const epoch = this.lockEpoch;
75
+ let prompted = false;
76
+ const pending = this.guarded('unlock', async () => {
77
+ const status = await this.d.vault.status();
78
+ if (epoch !== this.lockEpoch)
79
+ return { ok: false, message: 'unlock cancelled by lock' };
80
+ if (status === 'unauthenticated')
69
81
  return { ok: false, message: LOGIN_MSG };
70
- const refused = await this.checkedPassword(['Unlock the vault'], (pw) => this.d.vault.unlock(pw));
82
+ if (status === 'unlocked')
83
+ return { ok: true, message: 'already unlocked' };
84
+ if (status !== 'locked')
85
+ return { ok: false, message: 'vault unavailable; run u-chan doctor' };
86
+ if (agentRequested) {
87
+ if (this.d.limiter.blocked())
88
+ return { ok: false, message: BLOCKED };
89
+ const now = this.d.now?.() ?? performance.now();
90
+ if (now < this.agentUnlockAfter)
91
+ return { ok: false, message: 'unlock prompt cooldown; wait 30 seconds or use u-chan unlock manually' };
92
+ this.agentUnlockAfter = now + 30000;
93
+ }
94
+ prompted = !this.d.limiter.blocked();
95
+ const refused = await this.checkedPassword(['Unlock the vault'], async (pw) => {
96
+ if (epoch !== this.lockEpoch)
97
+ throw new UserFacingError('unlock cancelled by lock');
98
+ const unlocked = await this.d.vault.unlock(pw);
99
+ if (epoch !== this.lockEpoch) {
100
+ await this.d.vault.lock();
101
+ throw new UserFacingError('unlock cancelled by lock');
102
+ }
103
+ return unlocked;
104
+ });
71
105
  return refused ?? { ok: true, message: 'unlocked' };
106
+ }).finally(() => {
107
+ if (prompted)
108
+ this.agentUnlockAfter = (this.d.now?.() ?? performance.now()) + 30000;
109
+ if (this.unlockPending === pending)
110
+ this.unlockPending = undefined;
72
111
  });
112
+ this.unlockPending = pending;
113
+ return pending;
73
114
  }
74
115
  lock() {
116
+ this.lockEpoch++;
75
117
  return this.guarded('lock', async () => {
76
118
  await this.d.vault.lock();
77
119
  return { ok: true, message: 'locked' };
@@ -8,6 +8,9 @@ export async function diagnoseService(home, vault, platform, cdpEndpoint) {
8
8
  const paths = [join(home, 'program', 'dist', 'service', 'main.js'), join(home, 'program', 'node_modules'),
9
9
  platform === 'win32' ? join(home, 'bin', 'node.exe') : process.execPath,
10
10
  platform === 'win32' ? join(home, 'bin', 'bw.exe') : join(home, 'bin', 'bw')];
11
+ if (platform === 'win32')
12
+ for (const file of ['start-browser.ps1', 'desktop-browser.ps1'])
13
+ paths.push(join(home, 'program', 'scripts', file));
11
14
  const [assets, permissions, status, browser, browserKind] = await Promise.all([
12
15
  bounded(async () => {
13
16
  await privateBrowserPath(home);
@@ -18,7 +18,11 @@ import { IsolatedLogin } from './isolated-login.js';
18
18
  import { PolicyApprover } from './policy-approver.js';
19
19
  import { SettingsStore } from './settings.js';
20
20
  import { NpmPackageVerifier } from './update/verifier.js';
21
- import { ServiceUpdater } from './update/updater.js';
21
+ import { UpdateCoordinator } from './update/coordinator.js';
22
+ import { UpdateGate } from './update/gate.js';
23
+ import { readBootIdentity } from './update/files.js';
24
+ import { productionJournal, secureUpdateDirectory, verifyUpdateActive, launchUpdateWorker } from './update/native.js';
25
+ import { boundedRegistryFetch } from './update/network.js';
22
26
  import { diagnoseService } from './doctor.js';
23
27
  import { GrantCoordinator } from './grants.js';
24
28
  import { ensureV3State } from './state.js';
@@ -26,6 +30,13 @@ import { ManagementService } from './manage.js';
26
30
  import { privateBrowserPath } from '../platform/runtime-assets.js';
27
31
  import { safeVersion } from '../core/doctor.js';
28
32
  import { inspectServiceDesktopBrowser } from '../platform/windows/desktop-browser.js';
33
+ import { DiscordStore } from '../discord/store.js';
34
+ import { DiscordService } from '../discord/service.js';
35
+ import { DiscordHttp } from '../discord/http.js';
36
+ import { backupDiscordState } from './state.js';
37
+ import { GithubStore, backupGithubPolicy } from '../github/store.js';
38
+ import { GithubService } from '../github/service.js';
39
+ import { GithubHttp } from '../github/http.js';
29
40
  export function prepareSocketEndpoint(endpoint, platform = process.platform) {
30
41
  if (platform !== 'win32') {
31
42
  const dir = dirname(endpoint);
@@ -61,6 +72,12 @@ export async function runService(platform = process.platform, env = process.env)
61
72
  const allowlist = new AllowlistStore(cfg.allowlistPath, () => stateReady ??= ensureV3State(cfg.home).catch(e => { stateReady = undefined; throw e; }));
62
73
  const grants = new GrantCoordinator(allowlist);
63
74
  const audit = new FileAuditLog(cfg.auditPath);
75
+ let discordStateReady;
76
+ const discordStore = new DiscordStore(cfg.discordAllowlistPath, () => discordStateReady ??= backupDiscordState(cfg.home).catch(e => { discordStateReady = undefined; throw e; }));
77
+ const discord = new DiscordService({ platform, store: discordStore, vault, approver, audit, http: new DiscordHttp(), queue: work => grants.run(work) });
78
+ let githubStateReady;
79
+ const githubStore = new GithubStore(join(cfg.home, 'github-allowlist.json'), () => githubStateReady ??= ensureV3State(cfg.home).then(() => backupGithubPolicy(cfg.home, secureUpdateDirectory)).catch(error => { githubStateReady = undefined; throw error; }));
80
+ const github = new GithubService({ platform, store: githubStore, vault, approver, audit, http: new GithubHttp(), queue: work => grants.run(work) });
64
81
  const endpoint = loadConfig(env, platform).cdpEndpoint;
65
82
  const validateTarget = platform === 'win32' ? async (browser = 'chrome') => {
66
83
  const owner = (await settings.get()).authorizedSid;
@@ -76,27 +93,27 @@ export async function runService(platform = process.platform, env = process.env)
76
93
  currentVersion = safeVersion(JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version) ?? 'unknown';
77
94
  }
78
95
  catch { }
79
- const verifier = new NpmPackageVerifier();
80
- const restarter = {
81
- restart: async () => {
82
- process.exit(0);
83
- },
84
- };
85
- const updater = new ServiceUpdater({
86
- verifier,
87
- restarter,
88
- programDir: cfg.programDir,
89
- stagingDir: cfg.stagingDir,
90
- currentVersion,
91
- });
92
- const diagnose = async () => ({ ...await diagnoseService(cfg.home, vault, platform, loadConfig(env, platform).cdpEndpoint), serviceVersion: safeVersion(currentVersion) });
93
- const control = new ControlService({ vault, approver, limiter, settings, allowlist, grants, audit, platform, updater, diagnose, serviceVersion: safeVersion(currentVersion), manage: () => manager.open() });
96
+ const updateBoot = await readBootIdentity(cfg.programDir), updateGate = new UpdateGate();
97
+ const coordinator = new UpdateCoordinator({ home: cfg.home, boot: updateBoot, platform, verifier: new NpmPackageVerifier({ fetcher: boundedRegistryFetch }), journal: productionJournal(cfg.home), gate: updateGate,
98
+ secure: secureUpdateDirectory, verifyActive: path => verifyUpdateActive(cfg.home, path), launch: job => launchUpdateWorker(cfg.home, job), audit: result => audit.record({ action: 'update', domain: '', result }) });
99
+ await coordinator.refreshGate();
100
+ const updater = { update: async () => ({ ok: false, message: 'Use a coordinated-update desktop client; legacy service-only update is disabled' }) };
101
+ const diagnose = async () => ({ ...await diagnoseService(cfg.home, vault, platform, loadConfig(env, platform).cdpEndpoint), serviceVersion: safeVersion(currentVersion), discordPolicy: await discordStore.list().then(() => 'pass', () => 'fail'), discordBroker: platform === 'win32' ? 'available' : 'unsupported' });
102
+ const control = new ControlService({ vault, approver, limiter, settings, allowlist, grants, audit, platform, updater, diagnose, serviceVersion: safeVersion(currentVersion), manage: () => manager.open(), discordSupported: platform === 'win32', githubSupported: platform === 'win32', updateBoot });
94
103
  const manager = new ManagementService({
95
104
  store: allowlist, grants, vault, audit, diagnose, phrase: async () => (await settings.get()).phrase,
96
105
  show: req => adapters.prompt.manage ? adapters.prompt.manage(req) : Promise.reject(new Error('unsupported')),
97
106
  lock: () => control.lock(), unlock: () => control.unlock(), revoke: domain => core.revoke(domain),
107
+ discordStore, discordRevoke: alias => discord.discordRevoke(alias),
98
108
  });
99
109
  const api = {
110
+ requestUnlock: () => control.requestUnlock(), githubRequestAllow: proposal => github.githubRequestAllow(proposal), githubListAllowed: () => github.githubListAllowed(), githubRead: (alias, request) => github.githubRead(alias, request), githubCreate: (alias, operation) => github.githubCreate(alias, operation), githubRevoke: alias => github.githubRevoke(alias),
111
+ planUpdate: () => coordinator.planUpdate(), beginUpdate: target => coordinator.beginUpdate(target), updateStatus: id => coordinator.updateStatus(id), activateUpdate: id => coordinator.activateUpdate(id), confirmUpdateReady: id => coordinator.confirmUpdateReady(id),
112
+ discordRequestAllow: proposal => discord.discordRequestAllow(proposal),
113
+ discordListAllowed: () => discord.discordListAllowed(),
114
+ discordInspect: (alias, memberId, page) => discord.discordInspect(alias, memberId, page),
115
+ discordApply: (alias, operations) => discord.discordApply(alias, operations),
116
+ discordRevoke: alias => discord.discordRevoke(alias),
100
117
  capabilities: () => control.capabilities(),
101
118
  diagnose: () => control.diagnose(),
102
119
  manage: () => control.manage(),
@@ -112,7 +129,7 @@ export async function runService(platform = process.platform, env = process.env)
112
129
  audit: (limit) => control.audit(limit),
113
130
  update: () => control.update(),
114
131
  };
115
- const server = await startIpcServer(cfg.endpoint, api, { authorize: adapters.authorize });
132
+ const server = await startIpcServer(cfg.endpoint, api, { authorize: adapters.authorize, admission: updateGate, beforeRequest: () => coordinator.refreshGate(), afterResponse: (method, params) => coordinator.afterResponse(method, String(params.id ?? '')) });
116
133
  console.error(`U-Chan service listening on ${cfg.endpoint}`);
117
134
  return server;
118
135
  }
@@ -21,10 +21,21 @@ export class ManagementService {
21
21
  this.d.store.list(), bounded(() => this.d.vault.status(), 'unavailable'),
22
22
  bounded(() => this.d.audit.readTail(10), []), bounded(this.d.diagnose, null, SERVICE_DIAGNOSTIC_TIMEOUT_MS),
23
23
  ]);
24
- const all = entries.filter(e => e.domain === normalizeDomain(e.domain));
24
+ const website = entries.filter(e => e.domain === normalizeDomain(e.domain)).map(e => {
25
+ let label = e.expiresAt ? 'until ' + e.expiresAt : e.remainingUses !== undefined ? e.remainingUses + ' attempts remaining' : 'permanent';
26
+ try {
27
+ this.d.grants.check(e);
28
+ }
29
+ catch {
30
+ label = e.remainingUses === 0 ? 'exhausted' : 'expired';
31
+ }
32
+ return { target: e.domain, line: clean(e.domain) + ' — ' + label };
33
+ });
34
+ const discord = (await this.d.discordStore?.list() ?? []).map(e => ({ target: 'discord:' + e.alias, line: `Discord: ${e.alias} — server ${e.guildId} — metadata reads; protected approval for every write` }));
35
+ const all = [...website, ...discord];
25
36
  page = Math.min(page, Math.max(0, Math.ceil(all.length / 20) - 1));
26
37
  const grants = all.slice(page * 20, page * 20 + 20);
27
- const lines = ['Vault: ' + status, lastAction, 'Grant page ' + (page + 1) + ': ' + grants.length + ' of ' + entries.length + ' shown. Use Previous/Next for more.'];
38
+ const lines = ['Vault: ' + status, lastAction, 'Grant page ' + (page + 1) + ': ' + grants.length + ' of ' + all.length + ' shown. Use Previous/Next for more.'];
28
39
  lines.push('Service version: ' + (safeVersion(checks?.serviceVersion) ?? 'unknown'), 'Service browser: ' + (['pass', 'fail'].includes(checks?.browser ?? '') ? checks?.browser : 'unknown'));
29
40
  lines.push('Observed desktop browser: ' + (checks?.browserKind === 'edge' ? 'Edge' : checks?.browserKind === 'chrome' ? 'Chrome' : 'unknown'));
30
41
  if (status === 'locked')
@@ -33,24 +44,16 @@ export class ManagementService {
33
44
  lines.push('Next step: run u-chan doctor; repair from a trusted administrator terminal.');
34
45
  if (checks?.browser !== 'pass')
35
46
  lines.push('Next step: start the selected dedicated browser and run u-chan setup.');
36
- for (const e of grants) {
37
- let label = e.expiresAt ? 'until ' + e.expiresAt : e.remainingUses !== undefined ? e.remainingUses + ' attempts remaining' : 'permanent';
38
- try {
39
- this.d.grants.check(e);
40
- }
41
- catch {
42
- label = e.remainingUses === 0 ? 'exhausted' : 'expired';
43
- }
44
- lines.push(clean(e.domain) + ' — ' + label);
45
- }
47
+ for (const e of grants)
48
+ lines.push(e.line);
46
49
  lines.push('Revocation stops future logins. It does not log out existing browser sessions.', 'Revocation waits for any earlier login to finish.', 'Diagnostics: assets ' + (checks?.assets ?? 'unknown') + '; permissions ' + (checks?.permissions ?? 'unknown'), 'Recent activity:');
47
50
  for (const e of events.slice(-10)) {
48
51
  const ts = Number.isFinite(Date.parse(e.ts)) ? new Date(e.ts).toISOString() : 'unknown time';
49
- const action = ['login', 'request_allow', 'revoke', 'unlock', 'lock', 'mode', 'import', 'update'].includes(e.action) ? e.action : 'event';
52
+ const action = ['login', 'request_allow', 'revoke', 'unlock', 'lock', 'mode', 'import', 'update', 'discord_allow', 'discord_read', 'discord_write', 'discord_revoke'].includes(e.action) ? e.action : 'event';
50
53
  const result = e.result.startsWith('failed') ? 'failed' : e.result === 'denied' ? 'denied' : e.result.startsWith('approved') ? 'approved' : 'recorded';
51
54
  lines.push(ts + ' ' + action + ' ' + clean(e.domain).slice(0, 253) + ' ' + result);
52
55
  }
53
- return { title: 'U-Chan Secrets — Manage', phrase: await this.d.phrase(), lines: lines.filter(Boolean), domains: grants.map(e => e.domain) };
56
+ return { title: 'U-Chan Secrets — Manage', phrase: await this.d.phrase(), lines: lines.filter(Boolean), domains: grants.map(e => e.target) };
54
57
  });
55
58
  // Each native window closes before a protected password prompt can open.
56
59
  const action = await this.d.show(snapshot);
@@ -71,8 +74,9 @@ export class ManagementService {
71
74
  result = await this.d.lock();
72
75
  else if (action.action === 'unlock')
73
76
  result = await this.d.unlock();
74
- else if (action.action === 'revoke' && typeof action.domain === 'string' && snapshot.domains?.includes(action.domain))
75
- result = await this.d.revoke(action.domain);
77
+ else if (action.action === 'revoke' && typeof action.domain === 'string' && snapshot.domains?.includes(action.domain)) {
78
+ result = action.domain.startsWith('discord:') && this.d.discordRevoke ? await this.d.discordRevoke(action.domain.slice(8)) : await this.d.revoke(action.domain);
79
+ }
76
80
  else
77
81
  return { ok: false, message: 'invalid management action or target' };
78
82
  lastAction = result.ok ? 'Last action completed.' : 'Last action failed. Run u-chan doctor for guidance.';
@@ -1,5 +1,6 @@
1
1
  import { chmod, cp, lstat, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
3
4
  import { UserFacingError } from '../core/types.js';
4
5
  import { setAdminOnlyAcl } from '../platform/windows/acl.js';
5
6
  import { checkWindowsPermissions } from '../platform/windows/doctor.js';
@@ -86,3 +87,30 @@ export async function ensureV3State(home, secureDirectory = protect) {
86
87
  await writeFile(tmp, JSON.stringify({ schema: 3, minimumServiceMajor: 3 }), { mode: 0o600 });
87
88
  await rename(tmp, join(home, 'schema.json'));
88
89
  }
90
+ /** A protected metadata snapshot before the broker's first state mutation in this process. */
91
+ export async function backupDiscordState(home, secureDirectory = protect) {
92
+ await ensureV3State(home, secureDirectory);
93
+ await secureDirectory(home);
94
+ const parent = join(home, '#0 backup');
95
+ await mkdir(parent, { mode: 0o700, recursive: true });
96
+ await secureDirectory(parent);
97
+ const backup = join(parent, 'discord-before-' + randomUUID());
98
+ await mkdir(backup, { mode: 0o700 });
99
+ await secureDirectory(backup);
100
+ for (const name of ['discord-allowlist.json', 'allowlist.json', 'settings.json', 'schema.json']) {
101
+ const source = join(home, name);
102
+ let stat;
103
+ try {
104
+ stat = await lstat(source);
105
+ }
106
+ catch (e) {
107
+ if (e.code === 'ENOENT')
108
+ continue;
109
+ throw e;
110
+ }
111
+ if (!stat.isFile() || stat.isSymbolicLink())
112
+ throw new UserFacingError('protected backup refuses links and non-files');
113
+ await cp(source, join(backup, name), { force: false, errorOnExist: true });
114
+ }
115
+ await writeFile(join(backup, 'complete'), 'discord_broker', { mode: 0o600, flag: 'wx' });
116
+ }