@suveren/gateway 0.25.1 → 0.27.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.
@@ -127,6 +127,13 @@ var Vault = class {
127
127
  this.clearKey();
128
128
  this.lockedReason = "as-tls-mismatch";
129
129
  }
130
+ /** Lock because this is a managed gateway (IT policy `Team`) and the
131
+ * signed-in account's active workspace is no longer that team — see
132
+ * lib/managed-team.ts. Mirrors lockAsKeyMismatch() exactly. */
133
+ lockManagedWrongTeam() {
134
+ this.clearKey();
135
+ this.lockedReason = "managed-wrong-team";
136
+ }
130
137
  /**
131
138
  * Boot-time only: record that the gateway starts locked because the
132
139
  * resolved Authority Server URL differs from the one it was last paired
@@ -1299,7 +1306,7 @@ import { dirname as dirname4, join as join6 } from "path";
1299
1306
  import { existsSync as existsSync4, readFileSync as readFileSync6 } from "fs";
1300
1307
  import { join as join5 } from "path";
1301
1308
  import { spawnSync } from "child_process";
1302
- var POLICY_KEYS = ["AsUrl", "CaFile", "PinTls", "Proxy", "NoProxy", "Simulation", "InstallMethod"];
1309
+ var POLICY_KEYS = ["AsUrl", "CaFile", "PinTls", "Proxy", "NoProxy", "Simulation", "InstallMethod", "Team"];
1303
1310
  var DEFAULT_REGISTRY_BASE_KEY = "SOFTWARE\\Policies\\Suveren\\Gateway";
1304
1311
  function registryKeyPath(hive) {
1305
1312
  const baseKey = process.env.SUVEREN_POLICY_REGISTRY_KEY || DEFAULT_REGISTRY_BASE_KEY;
@@ -1335,6 +1342,11 @@ function validateCaFile(candidate) {
1335
1342
  if (!existsSync4(trimmed)) return { ok: false, error: `No file at "${trimmed}".` };
1336
1343
  return { ok: true, path: trimmed };
1337
1344
  }
1345
+ function validateTeamId(candidate) {
1346
+ const trimmed = candidate.trim();
1347
+ if (!trimmed) return { ok: false, error: "The team id is empty." };
1348
+ return { ok: true, id: trimmed };
1349
+ }
1338
1350
  function validateProxyUrl(candidate) {
1339
1351
  const trimmed = candidate.trim();
1340
1352
  if (!trimmed) return { ok: false, error: "The proxy URL is empty." };
@@ -1508,6 +1520,14 @@ function readPolicy() {
1508
1520
  policy.installMethod = "managed";
1509
1521
  locked.add("installMethod");
1510
1522
  }
1523
+ if (merged.Team !== void 0) {
1524
+ const v = validateTeamId(String(merged.Team));
1525
+ if (!v.ok) {
1526
+ throw new Error(`Invalid policy Team ("${String(merged.Team)}") from ${sourceOf.Team}: ${v.error}`);
1527
+ }
1528
+ policy.team = v.id;
1529
+ locked.add("team");
1530
+ }
1511
1531
  cached = { policy, locked };
1512
1532
  return cached;
1513
1533
  }
@@ -1624,151 +1644,443 @@ function clearPinTlsExpectedFingerprint(dataDir) {
1624
1644
  writeAsConfig(dataDir, { pinTlsExpectedFingerprint: void 0 });
1625
1645
  }
1626
1646
 
1627
- // src/routes/auth.ts
1628
- var DEFAULT_SP_URL = process.env.SUVEREN_AS_URL ?? "https://www.suveren.ai";
1629
- async function fetchAsPublicKey(asUrl) {
1630
- try {
1631
- const { res } = await fetchAs(`${asUrl}/api/as/pubkey`, { signal: AbortSignal.timeout(5e3) }, { enforce: false });
1632
- if (!res.ok) {
1633
- return {
1634
- ok: false,
1635
- message: `Could not reach the Authority Server at ${asUrl} to read its signing key (HTTP ${res.status}). Refusing to sign in.`
1636
- };
1637
- }
1638
- const body = await res.json().catch(() => null);
1639
- if (!body || typeof body.publicKey !== "string" || !body.publicKey) {
1640
- return {
1641
- ok: false,
1642
- message: `The Authority Server at ${asUrl} returned a malformed signing key. Refusing to sign in.`
1643
- };
1644
- }
1645
- return { ok: true, publicKeyHex: body.publicKey };
1646
- } catch (err) {
1647
- return {
1648
- ok: false,
1649
- message: `Could not reach the Authority Server at ${asUrl} to read its signing key \u2014 ${err instanceof Error ? err.message : String(err)}. Refusing to sign in.`
1647
+ // src/lib/managed-team.ts
1648
+ async function activeTeamId(asUrl, cookie, pinning) {
1649
+ const { res } = await fetchAs(
1650
+ `${asUrl}/api/groups/me`,
1651
+ { headers: { Cookie: cookie }, signal: AbortSignal.timeout(1e4) },
1652
+ pinning
1653
+ );
1654
+ if (!res.ok) throw new Error(`GET /api/groups/me answered ${res.status}`);
1655
+ const data = await res.json();
1656
+ return data.group?.id ?? null;
1657
+ }
1658
+ var ManagedTeamScheduler = class {
1659
+ constructor(check2, opts = {}) {
1660
+ this.check = check2;
1661
+ this.setIntervalFn = opts.setIntervalFn ?? setInterval;
1662
+ this.clearIntervalFn = opts.clearIntervalFn ?? clearInterval;
1663
+ this.intervalMs = opts.intervalMs ?? 5 * 6e4;
1664
+ }
1665
+ timer = null;
1666
+ setIntervalFn;
1667
+ clearIntervalFn;
1668
+ intervalMs;
1669
+ start() {
1670
+ if (this.timer) return;
1671
+ this.timer = this.setIntervalFn(() => {
1672
+ void this.check();
1673
+ }, this.intervalMs);
1674
+ }
1675
+ stop() {
1676
+ if (this.timer) this.clearIntervalFn(this.timer);
1677
+ this.timer = null;
1678
+ }
1679
+ /** Test-only: run the check immediately, bypassing the timer. */
1680
+ runNow() {
1681
+ return this.check();
1682
+ }
1683
+ };
1684
+
1685
+ // src/lib/event-bus.ts
1686
+ var EventBus = class {
1687
+ listeners = /* @__PURE__ */ new Set();
1688
+ /**
1689
+ * Subscribe to all bus events.
1690
+ * Returns an unsubscribe function — callers MUST call it on disconnect / unmount
1691
+ * to avoid listener leaks.
1692
+ */
1693
+ subscribe(handler) {
1694
+ this.listeners.add(handler);
1695
+ return () => {
1696
+ this.listeners.delete(handler);
1650
1697
  };
1651
1698
  }
1652
- }
1653
- async function checkAsKeyBeforeLogin(asUrl, dataDir) {
1654
- const existing = readPairing(dataDir);
1655
- const pinnedKey = existing && existing.asUrl === asUrl ? existing.publicKeyHex : void 0;
1656
- const existingTlsPin = (existing && existing.asUrl === asUrl ? existing.tlsSpkiPinHex : void 0) ?? resolvePinTlsExpectedFingerprint(dataDir);
1657
- let candidateKey;
1658
- if (pinnedKey) {
1659
- candidateKey = pinnedKey;
1660
- } else {
1661
- const fetched = await fetchAsPublicKey(asUrl);
1662
- if (!fetched.ok) {
1663
- return { ok: false, error: "as_unreachable", message: fetched.message };
1699
+ emit(type, payload) {
1700
+ const event = { type, payload };
1701
+ for (const handler of this.listeners) {
1702
+ try {
1703
+ handler(event);
1704
+ } catch (err) {
1705
+ console.error("[EventBus] subscriber threw:", err);
1706
+ }
1664
1707
  }
1665
- candidateKey = fetched.publicKeyHex;
1666
1708
  }
1667
- const pinning = {
1668
- enforce: resolvePinTls(dataDir),
1669
- pinnedSpkiHex: existingTlsPin,
1670
- capture: true
1671
- };
1672
- let capturedTlsSpkiHex;
1709
+ /** How many listeners are currently subscribed. Useful for tests and health checks. */
1710
+ get listenerCount() {
1711
+ return this.listeners.size;
1712
+ }
1713
+ };
1714
+ var eventBus = new EventBus();
1715
+
1716
+ // src/lib/desktop-notify.ts
1717
+ import { spawn, spawnSync as spawnSync2 } from "child_process";
1718
+
1719
+ // src/lib/notifier-icon.ts
1720
+ import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync4, existsSync as existsSync6 } from "fs";
1721
+ import { homedir as homedir2 } from "os";
1722
+ import { join as join7, dirname as dirname5 } from "path";
1723
+ var ICON_BASE64 = "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";
1724
+ function ensureNotifierIcon(dataDir) {
1673
1725
  try {
1674
- const result = await verifyAsHoldsKey(asUrl, candidateKey, pinning);
1675
- capturedTlsSpkiHex = result.capturedTlsSpkiHex;
1676
- } catch (err) {
1677
- if (err instanceof AsTlsMismatchError) {
1678
- return { ok: false, error: "as_tls_mismatch", message: err.message };
1679
- }
1680
- if (err instanceof AsChallengeUnreachableError) {
1681
- return { ok: false, error: "as_unverified", message: `${err.message}. Refusing to sign in.` };
1726
+ const dir = dataDir ?? process.env.SUVEREN_DATA_DIR ?? join7(homedir2(), ".suveren");
1727
+ const path = join7(dir, "notifier-icon.png");
1728
+ if (!existsSync6(path)) {
1729
+ mkdirSync4(dirname5(path), { recursive: true });
1730
+ writeFileSync4(path, Buffer.from(ICON_BASE64, "base64"));
1682
1731
  }
1683
- if (err instanceof AsChallengeInvalidError) {
1684
- if (pinnedKey) {
1732
+ return path;
1733
+ } catch {
1734
+ return null;
1735
+ }
1736
+ }
1737
+
1738
+ // src/lib/desktop-notify.ts
1739
+ function osaQuote(value) {
1740
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
1741
+ }
1742
+ function psQuote(value) {
1743
+ return `'${value.replace(/'/g, "''")}'`;
1744
+ }
1745
+ function xmlEscape(value) {
1746
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
1747
+ }
1748
+ var WINDOWS_AUMID = "Suveren.Gateway";
1749
+ function windowsToastScript(title, message, url, iconPath) {
1750
+ const toastXml = `<toast${url ? ` activationType="protocol" launch="${xmlEscape(url)}"` : ""}><visual><binding template="ToastGeneric"><text>${xmlEscape(title)}</text><text>${xmlEscape(message)}</text>` + (iconPath ? `<image placement="appLogoOverride" src="${xmlEscape(iconPath)}"/>` : "") + `</binding></visual><audio src="ms-winsoundevent:Notification.Default"/></toast>`;
1751
+ return `$ErrorActionPreference='SilentlyContinue'; $aumid=${psQuote(WINDOWS_AUMID)}; $reg="HKCU:\\Software\\Classes\\AppUserModelId\\$aumid"; if (-not (Test-Path $reg)) { New-Item -Path $reg -Force | Out-Null }; New-ItemProperty -Path $reg -Name DisplayName -Value 'Suveren' -PropertyType String -Force | Out-Null; ` + (iconPath ? `New-ItemProperty -Path $reg -Name IconUri -Value ${psQuote(iconPath)} -PropertyType String -Force | Out-Null; ` : "") + `[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime] | Out-Null; [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom, ContentType=WindowsRuntime] | Out-Null; $doc = New-Object Windows.Data.Xml.Dom.XmlDocument; $doc.LoadXml(${psQuote(toastXml)}); $toast = New-Object Windows.UI.Notifications.ToastNotification $doc; [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($aumid).Show($toast)`;
1752
+ }
1753
+ var MAC_SOUND = "Glass";
1754
+ function buildNotifyCommand(platform, title, message, options = {}) {
1755
+ const { url, hasTerminalNotifier: hasTerminalNotifier2 = false, iconPath } = options;
1756
+ switch (platform) {
1757
+ case "darwin":
1758
+ if (url && hasTerminalNotifier2) {
1685
1759
  return {
1686
- ok: false,
1687
- error: "as_key_mismatch",
1688
- message: `The Authority Server at ${asUrl} does not match the one pinned when this gateway last signed in (pinned fingerprint ${fingerprintOf(pinnedKey)}) \u2014 its signing-key challenge did not verify (${err.message}). Refusing to sign in. If the AS's key changed intentionally, an operator must clear the old pairing before signing in again.`
1760
+ cmd: "terminal-notifier",
1761
+ args: [
1762
+ "-title",
1763
+ title,
1764
+ "-message",
1765
+ message,
1766
+ "-open",
1767
+ url,
1768
+ "-sound",
1769
+ MAC_SOUND,
1770
+ ...iconPath ? ["-appIcon", iconPath] : [],
1771
+ "-group",
1772
+ "ai.suveren.gateway"
1773
+ ]
1689
1774
  };
1690
1775
  }
1691
1776
  return {
1692
- ok: false,
1693
- error: "as_unverified",
1694
- message: `The Authority Server at ${asUrl} could not prove it holds the signing key it presented \u2014 ${err.message}. Refusing to sign in.`
1777
+ cmd: "osascript",
1778
+ args: [
1779
+ "-e",
1780
+ `display notification ${osaQuote(message)} with title ${osaQuote(title)} sound name ${osaQuote(MAC_SOUND)}`
1781
+ ]
1695
1782
  };
1696
- }
1697
- throw err;
1783
+ case "win32":
1784
+ return {
1785
+ cmd: "powershell",
1786
+ args: ["-NoProfile", "-NonInteractive", "-Command", windowsToastScript(title, message, url, iconPath)]
1787
+ };
1788
+ case "linux":
1789
+ return {
1790
+ cmd: "notify-send",
1791
+ args: [
1792
+ "--app-name=Suveren",
1793
+ ...iconPath ? ["-i", iconPath] : [],
1794
+ title,
1795
+ message
1796
+ ]
1797
+ };
1798
+ default:
1799
+ return null;
1698
1800
  }
1801
+ }
1802
+ function lockedNotification(port2) {
1803
+ const url = `http://localhost:${port2}`;
1699
1804
  return {
1700
- ok: true,
1701
- publicKeyHex: candidateKey,
1702
- // A fresh capture (always present for https, unless pin-tls is on and
1703
- // an existing pin was enforced instead — see fetchAs) wins; otherwise
1704
- // whatever was already on file carries through unchanged.
1705
- tlsSpkiPinHex: capturedTlsSpkiHex ?? existingTlsPin
1805
+ title: "Suveren is running but locked",
1806
+ message: `Your agent has no authority until you unlock it. Open ${url} and enter your API key.`,
1807
+ url
1706
1808
  };
1707
1809
  }
1708
- function createAuthRouter(vault2, logoutAuth, loginRateLimit2, onSessionEstablished, pairing, lockAsTlsMismatch2 = () => {
1709
- }) {
1710
- const router = Router();
1711
- const SP_URL2 = pairing?.asUrl ?? DEFAULT_SP_URL;
1712
- router.post("/login", loginRateLimit2, async (req, res) => {
1713
- const apiKey = req.headers["x-api-key"] || req.body?.apiKey;
1714
- const confirmWipe = req.body?.confirmWipe === true;
1715
- if (!apiKey) {
1716
- res.status(400).json({ error: "Missing API key (X-API-Key header or body.apiKey)" });
1717
- return;
1718
- }
1719
- try {
1720
- let keyCheck = null;
1721
- if (pairing) {
1722
- keyCheck = await checkAsKeyBeforeLogin(pairing.asUrl, pairing.dataDir);
1723
- if (!keyCheck.ok) {
1724
- const status = keyCheck.error === "as_key_mismatch" || keyCheck.error === "as_tls_mismatch" ? 409 : 502;
1725
- res.status(status).json({
1726
- error: keyCheck.error,
1727
- message: keyCheck.message
1728
- });
1729
- return;
1730
- }
1731
- }
1732
- const sessionPinning = {
1733
- enforce: Boolean(pairing && resolvePinTls(pairing.dataDir)),
1734
- pinnedSpkiHex: keyCheck?.tlsSpkiPinHex
1735
- };
1736
- let spRes;
1737
- try {
1738
- const result = await fetchAs(
1739
- `${SP_URL2}/api/auth/session`,
1740
- {
1741
- method: "POST",
1742
- headers: {
1743
- "Content-Type": "application/json",
1744
- "X-API-Key": apiKey,
1745
- // The AS grants a 30-day gateway session ONLY to a login that
1746
- // identifies itself this way; without it, a 24-hour browser
1747
- // session (see doc at the top of this file).
1748
- ...clientVersionHeaders()
1749
- },
1750
- // Belt-and-suspenders on top of fetchAs's own default bound
1751
- // (as-tls-pin.ts): this is the exact call that hung in
1752
- // production (2026-10-02) with NO timeout at all — a dead
1753
- // keep-alive socket left the whole login request stuck forever.
1754
- signal: AbortSignal.timeout(1e4)
1755
- },
1756
- sessionPinning
1757
- );
1758
- spRes = result.res;
1759
- } catch (err) {
1760
- if (err instanceof AsTlsMismatchError) {
1761
- res.status(409).json({ error: "as_tls_mismatch", message: err.message });
1762
- return;
1763
- }
1764
- res.status(502).json({
1765
- error: "as_unreachable",
1766
- message: `Could not reach the Authority Server at ${SP_URL2} to sign in \u2014 ${err instanceof Error ? err.message : String(err)}. Refusing to sign in.`
1767
- });
1768
- return;
1769
- }
1770
- if (!spRes.ok) {
1771
- const err = await spRes.json().catch(() => ({ error: "Invalid API key" }));
1810
+ function sessionExpiredNotification(port2) {
1811
+ const url = `http://localhost:${port2}`;
1812
+ return {
1813
+ title: "Suveren Gateway locked",
1814
+ message: "Your sign-in has ended. Sign in again so your agents can act.",
1815
+ url
1816
+ };
1817
+ }
1818
+ var terminalNotifierCache = null;
1819
+ function hasTerminalNotifier() {
1820
+ if (terminalNotifierCache !== null) return terminalNotifierCache;
1821
+ try {
1822
+ const probe = spawnSync2("terminal-notifier", ["-help"], { stdio: "ignore" });
1823
+ terminalNotifierCache = probe.error === void 0;
1824
+ } catch {
1825
+ terminalNotifierCache = false;
1826
+ }
1827
+ return terminalNotifierCache;
1828
+ }
1829
+ function notifySpawnOptions(platform) {
1830
+ return {
1831
+ detached: platform !== "win32",
1832
+ stdio: ["ignore", "ignore", "pipe"]
1833
+ };
1834
+ }
1835
+ function notify(title, message, platform = process.platform, url) {
1836
+ const command = buildNotifyCommand(platform, title, message, {
1837
+ url,
1838
+ hasTerminalNotifier: platform === "darwin" ? hasTerminalNotifier() : false,
1839
+ // Windows and Linux show it on the toast itself; macOS only via
1840
+ // terminal-notifier. Failure to write it must never block the sound.
1841
+ iconPath: ensureNotifierIcon()
1842
+ });
1843
+ if (!command) return;
1844
+ try {
1845
+ const child = spawn(command.cmd, command.args, notifySpawnOptions(platform));
1846
+ let stderr = "";
1847
+ child.stderr?.on("data", (d) => {
1848
+ stderr += d.toString().slice(0, 500);
1849
+ });
1850
+ child.on("close", (code) => {
1851
+ if (code !== 0) {
1852
+ console.error(`[notify] ${command.cmd} exited ${code}${stderr ? `: ${stderr.trim()}` : ""}`);
1853
+ }
1854
+ });
1855
+ child.on("error", () => {
1856
+ });
1857
+ child.unref();
1858
+ } catch {
1859
+ }
1860
+ }
1861
+
1862
+ // src/lib/session-lock.ts
1863
+ var SESSION_LOCKED_MESSAGE = "Your sign-in ended after 30 days or was revoked. Sign in again.";
1864
+ var AS_KEY_MISMATCH_MESSAGE = "The Authority Server presented a signing key that does not match the one pinned at pairing. Sign in again to review.";
1865
+ var AS_TLS_MISMATCH_MESSAGE = "A connection to the Authority Server presented a TLS certificate that does not match the one pinned at pairing. The connection was refused before anything was sent. If the certificate changed intentionally (a new key, not just renewal), clear the pairing and sign in again to re-pin it.";
1866
+ function managedWrongTeamMessage(team) {
1867
+ return `This gateway is set up by your IT for team ${team}. Your account is no longer in this team. Sign in with your team account, or ask your IT.`;
1868
+ }
1869
+ function createSessionLock(deps) {
1870
+ const { vault: vault2, port: port2 } = deps;
1871
+ const notifyFn = deps.notifyFn ?? notify;
1872
+ const unconfigureSessionFn = deps.unconfigureSessionFn ?? unconfigureSession;
1873
+ const emit = deps.emit ?? eventBus.emit.bind(eventBus);
1874
+ return function lockExpiredSession2() {
1875
+ if (!vault2.isUnlocked()) return;
1876
+ vault2.lockExpired();
1877
+ console.error("[Control Plane] Authority Server session ended \u2014 gateway LOCKED");
1878
+ void unconfigureSessionFn().catch((err) => {
1879
+ console.error("[Control Plane] Failed to push cleared session to MCP:", err);
1880
+ });
1881
+ emit("session-locked", { reason: "expired", message: SESSION_LOCKED_MESSAGE });
1882
+ const { title, message, url } = sessionExpiredNotification(port2);
1883
+ notifyFn(title, message, process.platform, url);
1884
+ };
1885
+ }
1886
+ function createAsKeyMismatchLock(deps) {
1887
+ const { vault: vault2, port: port2 } = deps;
1888
+ const notifyFn = deps.notifyFn ?? notify;
1889
+ const unconfigureSessionFn = deps.unconfigureSessionFn ?? unconfigureSession;
1890
+ const emit = deps.emit ?? eventBus.emit.bind(eventBus);
1891
+ return function lockOnAsKeyMismatch() {
1892
+ if (!vault2.isUnlocked()) return;
1893
+ vault2.lockAsKeyMismatch();
1894
+ console.error("[Control Plane] Authority Server key mismatch \u2014 gateway LOCKED");
1895
+ void unconfigureSessionFn("as-key-mismatch").catch((err) => {
1896
+ console.error("[Control Plane] Failed to push cleared session to MCP:", err);
1897
+ });
1898
+ emit("session-locked", { reason: "as-key-mismatch", message: AS_KEY_MISMATCH_MESSAGE });
1899
+ const { title, url } = sessionExpiredNotification(port2);
1900
+ notifyFn(title, AS_KEY_MISMATCH_MESSAGE, process.platform, url);
1901
+ };
1902
+ }
1903
+ function createAsTlsMismatchLock(deps) {
1904
+ const { vault: vault2, port: port2 } = deps;
1905
+ const notifyFn = deps.notifyFn ?? notify;
1906
+ const unconfigureSessionFn = deps.unconfigureSessionFn ?? unconfigureSession;
1907
+ const emit = deps.emit ?? eventBus.emit.bind(eventBus);
1908
+ return function lockOnAsTlsMismatch() {
1909
+ if (!vault2.isUnlocked()) return;
1910
+ vault2.lockAsTlsMismatch();
1911
+ console.error("[Control Plane] Authority Server TLS pin mismatch \u2014 gateway LOCKED");
1912
+ void unconfigureSessionFn("as-tls-mismatch").catch((err) => {
1913
+ console.error("[Control Plane] Failed to push cleared session to MCP:", err);
1914
+ });
1915
+ emit("session-locked", { reason: "as-tls-mismatch", message: AS_TLS_MISMATCH_MESSAGE });
1916
+ const { title, url } = sessionExpiredNotification(port2);
1917
+ notifyFn(title, AS_TLS_MISMATCH_MESSAGE, process.platform, url);
1918
+ };
1919
+ }
1920
+ function createManagedWrongTeamLock(deps) {
1921
+ const { vault: vault2, port: port2 } = deps;
1922
+ const notifyFn = deps.notifyFn ?? notify;
1923
+ const unconfigureSessionFn = deps.unconfigureSessionFn ?? unconfigureSession;
1924
+ const emit = deps.emit ?? eventBus.emit.bind(eventBus);
1925
+ return function lockOnManagedWrongTeam(team) {
1926
+ if (!vault2.isUnlocked()) return;
1927
+ vault2.lockManagedWrongTeam();
1928
+ console.error("[Control Plane] Managed gateway: signed-in account is no longer in the locked team \u2014 gateway LOCKED");
1929
+ const message = managedWrongTeamMessage(team);
1930
+ void unconfigureSessionFn("managed-wrong-team").catch((err) => {
1931
+ console.error("[Control Plane] Failed to push cleared session to MCP:", err);
1932
+ });
1933
+ emit("session-locked", { reason: "managed-wrong-team", message });
1934
+ const { title, url } = sessionExpiredNotification(port2);
1935
+ notifyFn(title, message, process.platform, url);
1936
+ };
1937
+ }
1938
+
1939
+ // src/routes/auth.ts
1940
+ var DEFAULT_SP_URL = process.env.SUVEREN_AS_URL ?? "https://www.suveren.ai";
1941
+ async function fetchAsPublicKey(asUrl) {
1942
+ try {
1943
+ const { res } = await fetchAs(`${asUrl}/api/as/pubkey`, { signal: AbortSignal.timeout(5e3) }, { enforce: false });
1944
+ if (!res.ok) {
1945
+ return {
1946
+ ok: false,
1947
+ message: `Could not reach the Authority Server at ${asUrl} to read its signing key (HTTP ${res.status}). Refusing to sign in.`
1948
+ };
1949
+ }
1950
+ const body = await res.json().catch(() => null);
1951
+ if (!body || typeof body.publicKey !== "string" || !body.publicKey) {
1952
+ return {
1953
+ ok: false,
1954
+ message: `The Authority Server at ${asUrl} returned a malformed signing key. Refusing to sign in.`
1955
+ };
1956
+ }
1957
+ return { ok: true, publicKeyHex: body.publicKey };
1958
+ } catch (err) {
1959
+ return {
1960
+ ok: false,
1961
+ message: `Could not reach the Authority Server at ${asUrl} to read its signing key \u2014 ${err instanceof Error ? err.message : String(err)}. Refusing to sign in.`
1962
+ };
1963
+ }
1964
+ }
1965
+ async function checkAsKeyBeforeLogin(asUrl, dataDir) {
1966
+ const existing = readPairing(dataDir);
1967
+ const pinnedKey = existing && existing.asUrl === asUrl ? existing.publicKeyHex : void 0;
1968
+ const existingTlsPin = (existing && existing.asUrl === asUrl ? existing.tlsSpkiPinHex : void 0) ?? resolvePinTlsExpectedFingerprint(dataDir);
1969
+ let candidateKey;
1970
+ if (pinnedKey) {
1971
+ candidateKey = pinnedKey;
1972
+ } else {
1973
+ const fetched = await fetchAsPublicKey(asUrl);
1974
+ if (!fetched.ok) {
1975
+ return { ok: false, error: "as_unreachable", message: fetched.message };
1976
+ }
1977
+ candidateKey = fetched.publicKeyHex;
1978
+ }
1979
+ const pinning = {
1980
+ enforce: resolvePinTls(dataDir),
1981
+ pinnedSpkiHex: existingTlsPin,
1982
+ capture: true
1983
+ };
1984
+ let capturedTlsSpkiHex;
1985
+ try {
1986
+ const result = await verifyAsHoldsKey(asUrl, candidateKey, pinning);
1987
+ capturedTlsSpkiHex = result.capturedTlsSpkiHex;
1988
+ } catch (err) {
1989
+ if (err instanceof AsTlsMismatchError) {
1990
+ return { ok: false, error: "as_tls_mismatch", message: err.message };
1991
+ }
1992
+ if (err instanceof AsChallengeUnreachableError) {
1993
+ return { ok: false, error: "as_unverified", message: `${err.message}. Refusing to sign in.` };
1994
+ }
1995
+ if (err instanceof AsChallengeInvalidError) {
1996
+ if (pinnedKey) {
1997
+ return {
1998
+ ok: false,
1999
+ error: "as_key_mismatch",
2000
+ message: `The Authority Server at ${asUrl} does not match the one pinned when this gateway last signed in (pinned fingerprint ${fingerprintOf(pinnedKey)}) \u2014 its signing-key challenge did not verify (${err.message}). Refusing to sign in. If the AS's key changed intentionally, an operator must clear the old pairing before signing in again.`
2001
+ };
2002
+ }
2003
+ return {
2004
+ ok: false,
2005
+ error: "as_unverified",
2006
+ message: `The Authority Server at ${asUrl} could not prove it holds the signing key it presented \u2014 ${err.message}. Refusing to sign in.`
2007
+ };
2008
+ }
2009
+ throw err;
2010
+ }
2011
+ return {
2012
+ ok: true,
2013
+ publicKeyHex: candidateKey,
2014
+ // A fresh capture (always present for https, unless pin-tls is on and
2015
+ // an existing pin was enforced instead — see fetchAs) wins; otherwise
2016
+ // whatever was already on file carries through unchanged.
2017
+ tlsSpkiPinHex: capturedTlsSpkiHex ?? existingTlsPin
2018
+ };
2019
+ }
2020
+ function createAuthRouter(vault2, logoutAuth, loginRateLimit2, onSessionEstablished, pairing, lockAsTlsMismatch2 = () => {
2021
+ }, managedTeamId) {
2022
+ const router = Router();
2023
+ const SP_URL2 = pairing?.asUrl ?? DEFAULT_SP_URL;
2024
+ router.post("/login", loginRateLimit2, async (req, res) => {
2025
+ const apiKey = req.headers["x-api-key"] || req.body?.apiKey;
2026
+ const confirmWipe = req.body?.confirmWipe === true;
2027
+ if (!apiKey) {
2028
+ res.status(400).json({ error: "Missing API key (X-API-Key header or body.apiKey)" });
2029
+ return;
2030
+ }
2031
+ try {
2032
+ let keyCheck = null;
2033
+ if (pairing) {
2034
+ keyCheck = await checkAsKeyBeforeLogin(pairing.asUrl, pairing.dataDir);
2035
+ if (!keyCheck.ok) {
2036
+ const status = keyCheck.error === "as_key_mismatch" || keyCheck.error === "as_tls_mismatch" ? 409 : 502;
2037
+ res.status(status).json({
2038
+ error: keyCheck.error,
2039
+ message: keyCheck.message
2040
+ });
2041
+ return;
2042
+ }
2043
+ }
2044
+ const sessionPinning = {
2045
+ enforce: Boolean(pairing && resolvePinTls(pairing.dataDir)),
2046
+ pinnedSpkiHex: keyCheck?.tlsSpkiPinHex
2047
+ };
2048
+ let spRes;
2049
+ try {
2050
+ const result = await fetchAs(
2051
+ `${SP_URL2}/api/auth/session`,
2052
+ {
2053
+ method: "POST",
2054
+ headers: {
2055
+ "Content-Type": "application/json",
2056
+ "X-API-Key": apiKey,
2057
+ // The AS grants a 30-day gateway session ONLY to a login that
2058
+ // identifies itself this way; without it, a 24-hour browser
2059
+ // session (see doc at the top of this file).
2060
+ ...clientVersionHeaders()
2061
+ },
2062
+ // Belt-and-suspenders on top of fetchAs's own default bound
2063
+ // (as-tls-pin.ts): this is the exact call that hung in
2064
+ // production (2026-10-02) with NO timeout at all — a dead
2065
+ // keep-alive socket left the whole login request stuck forever.
2066
+ signal: AbortSignal.timeout(1e4)
2067
+ },
2068
+ sessionPinning
2069
+ );
2070
+ spRes = result.res;
2071
+ } catch (err) {
2072
+ if (err instanceof AsTlsMismatchError) {
2073
+ res.status(409).json({ error: "as_tls_mismatch", message: err.message });
2074
+ return;
2075
+ }
2076
+ res.status(502).json({
2077
+ error: "as_unreachable",
2078
+ message: `Could not reach the Authority Server at ${SP_URL2} to sign in \u2014 ${err instanceof Error ? err.message : String(err)}. Refusing to sign in.`
2079
+ });
2080
+ return;
2081
+ }
2082
+ if (!spRes.ok) {
2083
+ const err = await spRes.json().catch(() => ({ error: "Invalid API key" }));
1772
2084
  res.status(spRes.status).json(err);
1773
2085
  return;
1774
2086
  }
@@ -1788,6 +2100,25 @@ function createAuthRouter(vault2, logoutAuth, loginRateLimit2, onSessionEstablis
1788
2100
  const data = await spRes.json();
1789
2101
  const setCookieHeaders = spRes.headers.getSetCookie?.() ?? [];
1790
2102
  const sessionCookie = setCookieHeaders.join("; ");
2103
+ if (managedTeamId) {
2104
+ let activeTeam;
2105
+ try {
2106
+ activeTeam = await activeTeamId(SP_URL2, sessionCookie, sessionPinning);
2107
+ } catch (err) {
2108
+ res.status(502).json({
2109
+ error: "as_unreachable",
2110
+ message: `Could not confirm your team membership with the Authority Server \u2014 ${err instanceof Error ? err.message : String(err)}. Refusing to sign in.`
2111
+ });
2112
+ return;
2113
+ }
2114
+ if (activeTeam !== managedTeamId) {
2115
+ res.status(403).json({
2116
+ error: "managed_wrong_team",
2117
+ message: managedWrongTeamMessage(managedTeamId)
2118
+ });
2119
+ return;
2120
+ }
2121
+ }
1791
2122
  vault2.setSpCookie(sessionCookie);
1792
2123
  await vault2.deriveAndSetKey(apiKey);
1793
2124
  if (vault2.isVaultFromDifferentKey()) {
@@ -2561,17 +2892,17 @@ async function testAIConnectivity(config) {
2561
2892
  }
2562
2893
 
2563
2894
  // src/lib/agent-brief-store.ts
2564
- import { existsSync as existsSync6, readFileSync as readFileSync8 } from "fs";
2565
- import { homedir as homedir2 } from "os";
2566
- import { join as join7 } from "path";
2895
+ import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
2896
+ import { homedir as homedir3 } from "os";
2897
+ import { join as join8 } from "path";
2567
2898
  var AGENT_CONTEXT_MAX_BYTES = 16 * 1024;
2568
2899
  function agentBriefPath() {
2569
- const dataDir = process.env.SUVEREN_DATA_DIR ?? join7(homedir2(), ".suveren");
2570
- return join7(dataDir, "context.md");
2900
+ const dataDir = process.env.SUVEREN_DATA_DIR ?? join8(homedir3(), ".suveren");
2901
+ return join8(dataDir, "context.md");
2571
2902
  }
2572
2903
  function readAgentBrief() {
2573
2904
  const filePath = agentBriefPath();
2574
- if (!existsSync6(filePath)) return "";
2905
+ if (!existsSync7(filePath)) return "";
2575
2906
  return readFileSync8(filePath, "utf-8");
2576
2907
  }
2577
2908
 
@@ -2853,39 +3184,6 @@ async function backfillReadPolicyDefaults(data) {
2853
3184
 
2854
3185
  // src/routes/mcp.ts
2855
3186
  import { Router as Router5 } from "express";
2856
-
2857
- // src/lib/event-bus.ts
2858
- var EventBus = class {
2859
- listeners = /* @__PURE__ */ new Set();
2860
- /**
2861
- * Subscribe to all bus events.
2862
- * Returns an unsubscribe function — callers MUST call it on disconnect / unmount
2863
- * to avoid listener leaks.
2864
- */
2865
- subscribe(handler) {
2866
- this.listeners.add(handler);
2867
- return () => {
2868
- this.listeners.delete(handler);
2869
- };
2870
- }
2871
- emit(type, payload) {
2872
- const event = { type, payload };
2873
- for (const handler of this.listeners) {
2874
- try {
2875
- handler(event);
2876
- } catch (err) {
2877
- console.error("[EventBus] subscriber threw:", err);
2878
- }
2879
- }
2880
- }
2881
- /** How many listeners are currently subscribed. Useful for tests and health checks. */
2882
- get listenerCount() {
2883
- return this.listeners.size;
2884
- }
2885
- };
2886
- var eventBus = new EventBus();
2887
-
2888
- // src/routes/mcp.ts
2889
3187
  function createMCPRouter() {
2890
3188
  const router = Router5();
2891
3189
  router.get("/health", async (_req, res) => {
@@ -2958,7 +3256,7 @@ function createMCPRouter() {
2958
3256
  // src/routes/autostart.ts
2959
3257
  import { Router as Router6 } from "express";
2960
3258
  import { execFile } from "child_process";
2961
- import { existsSync as existsSync7 } from "fs";
3259
+ import { existsSync as existsSync8 } from "fs";
2962
3260
  import { dirname as pathDirname, resolve } from "path";
2963
3261
  import { fileURLToPath } from "url";
2964
3262
  import { promisify } from "util";
@@ -2979,7 +3277,7 @@ function findCli(dirname10 = MODULE_DIR) {
2979
3277
  // resolves differently between a shell and a login service — and it would
2980
3278
  // mask a genuinely missing CLI whenever the cwd happened to be the repo.
2981
3279
  ];
2982
- return candidates.find(existsSync7) ?? null;
3280
+ return candidates.find(existsSync8) ?? null;
2983
3281
  }
2984
3282
  async function cli(args) {
2985
3283
  const bin = findCli();
@@ -2998,208 +3296,62 @@ async function cli(args) {
2998
3296
  function safe(handler) {
2999
3297
  return (req, res) => {
3000
3298
  handler(req, res).catch((err) => {
3001
- const message = err instanceof Error ? err.message : String(err);
3002
- console.error("[Autostart] handler failed:", message);
3003
- if (!res.headersSent) res.status(500).json({ error: "autostart_failed", message });
3004
- });
3005
- };
3006
- }
3007
- function createAutostartRouter() {
3008
- const router = Router6();
3009
- router.get("/", safe(async (_req, res) => {
3010
- const platform = process.platform;
3011
- const supported = SUPPORTED.has(platform);
3012
- const bin = findCli();
3013
- if (!supported || !bin) {
3014
- res.json({
3015
- supported: false,
3016
- installed: false,
3017
- platform,
3018
- // Be specific about WHICH of the two reasons applies — "unavailable"
3019
- // with no cause is the kind of dead end that generates support mail.
3020
- reason: !supported ? `Autostart is not available on ${platform}.` : "Autostart needs the packaged gateway; it is unavailable when running from source."
3021
- });
3022
- return;
3023
- }
3024
- const { output } = await cli(["service", "status"]);
3025
- res.json({
3026
- supported: true,
3027
- installed: /Login service:\s*installed/i.test(output),
3028
- platform,
3029
- detail: output
3030
- });
3031
- }));
3032
- router.post("/", safe(async (_req, res) => {
3033
- if (!SUPPORTED.has(process.platform) || !findCli()) {
3034
- res.status(400).json({ error: "unsupported", message: "Autostart is not available here." });
3035
- return;
3036
- }
3037
- const { ok, output } = await cli(["service", "install"]);
3038
- if (!ok) {
3039
- res.status(500).json({ error: "install_failed", message: output });
3040
- return;
3041
- }
3042
- res.json({ ok: true, installed: true, detail: output });
3043
- }));
3044
- router.delete("/", safe(async (_req, res) => {
3045
- if (!SUPPORTED.has(process.platform) || !findCli()) {
3046
- res.status(400).json({ error: "unsupported", message: "Autostart is not available here." });
3047
- return;
3048
- }
3049
- const { ok, output } = await cli(["service", "uninstall"]);
3050
- if (!ok) {
3051
- res.status(500).json({ error: "uninstall_failed", message: output });
3052
- return;
3053
- }
3054
- res.json({ ok: true, installed: false, detail: output });
3055
- }));
3056
- return router;
3057
- }
3058
-
3059
- // src/lib/desktop-notify.ts
3060
- import { spawn, spawnSync as spawnSync2 } from "child_process";
3061
-
3062
- // src/lib/notifier-icon.ts
3063
- import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync4, existsSync as existsSync8 } from "fs";
3064
- import { homedir as homedir3 } from "os";
3065
- import { join as join8, dirname as dirname5 } from "path";
3066
- var ICON_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAYAAABccqhmAAAACXBIWXMAACxLAAAsSwGlPZapAAAaEElEQVR42u2deXxNV9fH7/O2z/vHk+HmJoIoQQxpBtVPi+LTIWZqeDQ6V0tRFUWIeagSRAheyasxVNUsWtRQSquoMTRi9mgJooZWhBqaSIX97nWa9CVN4ubes8/Z55zf+ny+H1rknrv3Wr+zh7XXttmMaY94e3vXdDgcL3JifHx8ZnE2cvbw/z7MyeBc5eRxGABukFfgS+RTh8nHCnxtFvke+SD3xRrkkzaYGPP19fXmDd6eM403eDoCG0gqFPu5j07ltCOfReS6YbwRn+ANGsd/TeW/5sPBgMG4U+C7Ezh1ENFOGB9K+fLG6skbbiccCJgJ7tPH+K9D/f39KyLS/z7Eb84baD2pJpwFWGBksN5utzezetz/o2BevwdOASxKOn/5vUKxYKnA51/8tYLVejgBAA7HQUsIgZ+fXzB/43+LDgegWLZzwk0X+AEBAf/iX2wMtu8AePgaASeRvyy9zLKd147zMzoWgDLtGpzjv7Y1cuw/WvDWv4sOBcAl7tFogMfSfxsq8vlDB3IF240OBEAVfuDbhkGGCH7+oB35A19DpwGgKlf5S7WD7PP9DzDkB0DolCBG1mH/UHQQAJosEMZLdTS34GgkOgcA7fiMFtplCP7P0RkA6DISWK5nHQLK5Z+DjgBAVxGYrdecfwI6AAApGKv1an9vNDoAUo0EorXc58dWHwBycZeO1wvP8ONko7EBkDNZiL+gq4mK/38ivRcA6acCe4WcHeA//H/QwMAM+Po6WKNQH9arlZ0ldPZmKwZ4sfQ4T/afKZ7s4gwP9tsnf0K/p/+3f4Kn8ncm879L/4b+Lf0MiUVgstqLfu0L0hDhQMCQVK3kYL1be7Pl0V7sXJIH+32ee9DPSOE/K6qVt/KzJUwZflHNYh5n4UTAaPjxt/TLje3s8/5e7OocT7eDviToZ5MYdOKfJcvIgOoJ8ArEnmq8/afAmYCRKOfnYN2a2dmBOHFBXxI0lXi3qV15BgnaYqK78/4wzh9wKmAU6C18ZJKH5oFflCOTPNlLjex6t0ceLzYa4k713q1wKmAEQqr5sAW9vXQP/KKsHujF6gT56Nk237tUbdjxZ+luOBeQnjeft7NLH3tIF/yF/Jrswbo21W80wHMDIl15+6NuP5Caiv4+bGYPL2kDvygfd/fiz6xLWx0o0yiAL/z9Gw4GZN/W2/qhp2GCv5AdH3mxoMq6tNmLZRGAXXAyICu1A33YD+ONF/yF0O5EaDUfrbcF9zh9USecDMjK41V92PEE4wZ/IfQd6LtovBbQxJm3/3o4GpB12E9puUYP/kLS+ChG4yzCdaUGf/ny5Ss4cEU3kHTBz4hz/oexZZSnlguDdyjGS9v6GwhnAzIy6z1v0wV/ITO6eclROIT/hYNwNiAbb0fYTRv8hbyrXZ5AWknBHw5nA7IRzrPoKJHG7AJAiUwa7gzUQZFPYAjoHL7Zg78QOqqsUbvGFrf6vxcOB2SiY0O7ZYK/EDrMpME6wK6ib387Jx9OB2Q60ivDqT6tORzvqcVR4js838e7aMUfOB6QBjrPb7XgL6RLE7u2qcFcAKbB6YBMNfv0KOYhU4KQ6MpCPOYT7p8CpMPxgCxQGS+rBn8hkeKLify1HfgI/4/bcDwgC1TDz+oCsLSv8B2B28rFot7e3jXhdEAWAgMcLHu2p+UFgAqNij4nwA8HVafhf1s4HpAFKt1t9eAv5P2WwqcBrUkAYuB4QBYoGQbBr800QDkXQPeLw/GALKv/alzaYRYyEz1E7wYkkwBshPMBGWjMr9xC4D9IwxAfkSOADSQAe+B8QAbo3j0E/YO818IuUgB2kgAcgfMBGaCLOhH0DxL/prfINj9kw71/QBZWDsACYFG+GCB0IfA0CUA2nA84Q+XKldnzzz/POnbsyFq2bMlCQ0NV/flp47H/X5R94zxF9mkWCUAenBuUFvQjRoxg6enpLD8/nxW1ixcvstmzZ7MGDRq4/Vk/ToUAFOU/UzzFZgPCyUFJREVFsezsbOaM3b17ly1dupSFhIS4/HkXZiDgi/Lz/3oI7WMIAPj7Wfxy5di8efOYK5aTk6P8e1c+99ocBPzfU4IhAEDTZBxftmTJEuaOVahQAQIAAQBGZPDgwcxdCwoKwhQAUwDgDvwCBxYREcHeeOMN1rVrV2Xl3Z35tTPUrl2b3bp1y20BeOKJJ7AIaIxFQAiAdIUweaCvWbOG5ebmFhtcp06dYhMnTmQ1atRQ/bOnTZvG1LDGjRtjG9AY24AQABnw9/dnPXr0YIcOHXI6yG7cuMFiYmKUObtac//z58+rIgAtWrRAIpAxEoEgAHry2GOPsWHDhrHMzEyXgy0lJcXlRbf7oX18teydd95BKrAxUoEhAHpQsWJFNnbsWHb16lVVAo5EwN2RAAWtWjZu3DgcBjLAYSAIgA7Qot7p06eZ2jZo0CC3nmvAgAGqPcuyZctceoZGOA78N54J8YEAmIGGDRuyrVu3MlF28+ZNtxYG+/Tpo9qzpKWluVwQhIpgIPALCoIkiS8PDgEQXeQyMJDNnDmT/fHHH0y0xcfHu/ycnTp1Uu05fvvtN5QEU4El4isDQwBEQifmzp49y7SyjIwMl5+1Vq1a7N69e6o9i6uHg6JaYSFQw6KgEABRufRjxozR5K1f1MLCwlx+7r1796r2HAMHDkRZcDfLglNbQAAMBmXB7dmzh+llNJR35/SfWrZ69WpMA+S+GAQCoDZdunRR5r96Wvfu3d1KSDp+/Lgqz3HlyhXm5+fn2noErgZjLzWyQwCMxPDhw5Uz8Xpb586d3foedP7g9u3bqjwL/SxXdwPSLXw56A8aXA4KAVAxjXfhwoVMFmvatKnb34mSgtRYv0hKSnL5Gd618PXg70TYNfNfCIAbVKlShW3evFma4Kc3N2UZqvHdaC3B2WpAJdmlS5dcLg5Szs/Bjkyy3ijgULwn8/N1QABk5/HHH2cHDx5kMtm6detU/Y7BwcFs5cqVbm0PRkZGuvz5/37GjmvBIQDyER4erun+vrP20ksvCfm+bdq0YQcOHHDpmZYvX+7WZ3/R3zqjgJRoL819GQLgQtEMOpMvm23btk3o96YV/b59+7LLly+X6bmorgG1maufG1bdh/2SbP7gv/SxBwup5gMBkBnKtT927Jh0wU9zdVer8LiS2kyLe3l5eU4/X2Jioluf2fkF808F3nrBRxefhgA4SdWqVZXa+LIZFQZp1aqV5u1Rr149tmHDBqee8ffff2c1a9Z06/M+7m7e5KDErl66+TUEwAkqVarEUlNTpQt+yv1/7rnndG0bWnc4ceKE0INKSg0Ffwf7bpT51gM2j/BkFfwdEACZWbVqlVSBf/36dTZ58mRFmGTJhejfvz/Lysoq8ZmvXbvmdh3DqpXMVTeQEn60yPeHALjBqFGjpAl82lf/6KOPlOmIrGskc+fOZXfu3Cn2+Slhyu3t16o+7HiC8UXg2GQPFhzoo3ufQQBKoUOHDiU6s5ZGuw6UaqxWko9oqCow7UoUd30YHZF2eyeGBw5VyzVq8FOasx4r/hCAMu71l3XLS8Qb//3331et8q8M5c9oF4WmDG4vylYy5prA5pH6D/shAE4U7dRzxZ/y8GfNmqVsuZmlACqVLCu00aNHq/Oz+eLZjG7G2B249akHS3rXS9cFPwiAk8yYMUO34N+xYwd76qmnTJk6TXcO0jSABE7Nrcs3nrezixJfK/ZrsrYHfCAAbtCsWTOWn5+veeDTWgNtlbl6eMYo0GnFffv2KZeQqHm7ES0OLugt32hg9UAvFh7kI21/QADugy7YUKsgRlmMLgZp3bq15cqjL1iwQPX1jY4N7exwvKcUp/raN7BL3w8QgPugvXWtjY4T07Fiq96M1L59e/XPLfDjtF2b2nXJGaC9fRrua3mkFwKgAs8++6zmRTwXL16s3AKMugpiBJCq6tDx2mX9vIQWGqWfTTX8qIyXr6+x2h4CUHDSTctVfzpfHxsbC+HV8n6GgD+vHqNApQs33A36s4meSt3+nrx0d5UA47YLBIBDN/NqGfyUNot21w96S9OVWxS8k97yVm7gpcQiyjC8wHcTrs35E/o9/b+9/M/o79BFnXRXn+jruiAAGkJDcDpUo5WptQcOAARABWJiYjQL/gkTJsDpAARAFgICApR0Wy0sOTkZDgcgADJB13dpYd9//70q+e8AQABULPLhbtlrZ+zcuXNuV8MBAAKgMv369dOkTr8aF3UAAAFQGVfLXJfFRo4cCScDEADZoLx70ZaWlmb6gz0AAmBIVqxYIXzo36hRIzgYgAA4JLzYoyw17V2xuLg4OBeAAMgIFdUUaRcuXDBM7T4ALCcA+/fvFyoAtLsAxwIQAAkJCQlx66ZbZ6r3IuEHQAAkZdCgQULf/t26dYNTAQiArBRXq14t+/nnn7HtByAAslK9enWhFX/oXAEcSj5IlKkCMY3+qOTb1KlT2YgRI1jHjh2Vw2AQAIt80ffee0/ovj/y/eWiVq1abPr06aVe7nLr1i2lLNvTTz8NATA7dGedKFu6dCmCTjKxp8tInTXKCxk/frxSGg4CYFIOHjwoTABoOInAk4OEhASX+3Ht2rVKaXgIgAmP/oq65PPq1auo7Oswz03Oy5YtgwCYjXbt2mH4b3JeeOEF1RZ5o6KiIACo/OOcde7cGQEoAbt371atT2nhkEaNEACTQHM7Uff50e02CEB9admypep9O2DAAAiAWTh58qQQAaCFRQSg/sycOVP1vt2+fTsEwGGKSyB8WW5urhAB+OSTTxCAJq3ulJOTY4lzHaYXgODgYGHz/169eiEAJeD69etC+rdu3boQAKPTvHlzYQJQr149BKAEI7y7d+8K6d8WLVpAAIwOndATYfn5+Tj6Kwk3b94U0sdvvfUWBAAVgEo+/Yfgk4MzZ84I6WMrXOJqegGYM2eOEOegfWcEnxzs27dPSB9PnDgRAmB0lixZIsQ5UlJSEHyS8NVXXwnpYzpABgFwoAR4cZaUlITgk4R58+YJ6WNKIIMAGJx169YJcQ4qLoHgk4P4+HghffzNN99AAIwOdaIIGzduHIJPEmJjY4X08aZNmyAARoeu5sa9fxAAV2zjxo0QAKOTmpoqxDkGDx6M4JMEGo2JsK+//hoCAAHACEB2qJyXCNuwYQMEwOjQPE6EUekpBJ8c0H69CFu/fj0EwOgsX74cJwGR7OWS0Q4SBADOUax98cUXCD6TF3xZuHAhBMDo0H69CNuyZQuCTxL27t2LXA8IQPHQYp0Iy8zMRPBJwrlz54T0cUxMDATA6PTp00eIc9AZdNQD1B+q408Xe6DgKwSgWOjSDlFGpagRhPoSEREhrH+bNWsGATA6oaGhwhyErqBCEOpLv379hPVveHg4BAA140q25ORkBKHO0HasCKNLRqxwTZglBCAtLU2Ikxw5cgRBaNJiIEePHmUoC46iIKXavXv3lGuoEYj6UL16dWF3PtK14RAAB+oCPsy6du2KYNSJHj16COvXoUOHQgDMwmuvvSbMUebPn49gNFmaN1mbNm0gAGahWrVqShlvEZaVlYXrwXWgXLly7MqVK8JyPCpXrgwBMBPp6enC3havv/46gtJE+R0//fQTw/XgJiMxMVGYw3z55ZcISo2hNhdln332GQTAbERGRgpzmNu3byvTDASmNtDOi6j0X7I333wTAmA2KlWqpASqKBsyZAiCUyNGjRolVMytdMbDZiXH2bFjhzDHoRNpWAwUD93HmJGRIawfN2/ebKn2tJQAjB07lom0Dz74AEEqmN69ewvtQ6uN5CwlAHXq1BF2lTTZyZMnmZ+fHwJV4NbfqVOnhArAk08+CQHANMB16969O4JVEH379hXad4cPH7Zcm1pOAEQVCGH3XRuOQiFiFnFFVf5hFr7rwXICEBgYyHJycoQ60tSpUxG0BsrjIMvNzVUOF0EALMCqVauEOhPtUdevXx+BqxKNGzdWzueLtKVLl1qybS0pAK+++ioTbdu2bWO+vr4IYDehRVVRZ/7vt9atW0MArLSaTCv2om306NEIYjf58MMPhffTiRMnLNu+Nqt+cdGLgYVlpZo3b45AdhF6K4se+ls9i9OyAkBZe6JXlQt3BYKCghDQLuT7X7x4UXj//PLLLywgIAACYEVI+bUwumeeUlgR2M5P0bZu3apJ31j9DIelBaBixYrKG0ALs8I9c2pAC6cLFizQpE8uXbpk6be/5QVAi/MB9xtdY40gL51p06Zp1h9WuPoLAvAQqPTThQsXNHE4qiIcHR2NQC+B4cOHaxb8tP5jhbr/EAAnoMq+WhmJADk62v1BqAqvyINaRa1Xr15odwjA/0PnwLW06dOno90L5vzx8fGatv3OnTuRpAUBeJAGDRoILTNVnM2ePVtZ8bZycY9FixZp2uZU8adevXrweQjA36G3stZGKcNWvF2oZs2amo+6yOLi4uDrEIDioWO858+f19wp6TNbtGhhmXZu1aqVLu38448/YuEPAlA67du3F3aJyMNOEI4YMcLUFYVo3k25/Vqk97JiLvto27YtfBwC8HAmTZrE9LJDhw6xiIgI07UpzbtpuqOXTZkyBb4NAXA+FZVWivUyGg1MnjxZyVQ0QyUfWlvR461faFQGzsqLrRAAFwgLCxN291xZklWoDp4RzxFQwNFee2Zmpq5t+Ouvv7Lg4GD4NASg7LzyyitK4o7eRkHUv39/Q7zFaA2DEqu0qLfwMKO1HLpDEL4MAXAZGorLYqdPn1aKjNAWmmztREeeKZuPnlEWi42NhQ9DANxfuV68eDGTySiZ5fPPP1d2LPQcFdBnv/zyy8pFnSKvXXPFUlJSkO0HAVCveMi3337LZLTs7GzF2bt06cKqVKmiSQIP3X2wZMkSzY5Sl9U2bdqEa9ogAOqfGkxPT2cyG+0e7N+/n82dO1e5poyq6bozQqDEqKZNmyrl05KTk5Xvr+WBHVcsNTVV2XmAz0IAVKd27dpSzXGZk3UJqSwZBcbKlSuVQKZ1jTFjxjxAQkIC+/TTT9natWuVLVD6nrIHe1E7fvy4JWv7QwA0hO6OO3PmDIPJZWfPnmUhISHwUQiAeGhf+ejRo4g6SYxy/MPDw+GbEADtoKGmFhdWwEo3WvOQcVsUAmCR04NbtmxBFOpkdLaAFmfhixAAXSsL08IZTFtbtmwZSq1DAORJFqKVdD2OEVvNqI2pujKSfCAA0kHnzenwCUyMZWVlsU6dOsHXIADyEhoaquy5w9S1Xbt2YZsPAmCc1GFKuDFaIo2MRm1IxTxwnh8CYMjad3T1NMw1y8jIwHFeCIDxRwO0QCjbaTnZ05epipAZKiJBAMBfNfG2b9+O6GYPv7TjmWeegc9AAMwHVcuJiorCWQJW/E29PXv2hJ9AAKwxLSAhoAMsVrfLly8rUySrX9MNAbAgdEkF1fqTtbAGE3wZCl2SisCHAOBMAT9TMHDgQEucMKQ6A/RdscAHAQDF0KRJEzZ//nyWk5NjmqCnHZDVq1crW3pI4YUAACdr79E1WnRTkFGNjktHR0ezwMBA9CkEALhKnTp12ODBg5Wjx1pfYV4Wy83NZd999x0bOXIkq1+/PvoOAgDUht6mdPHGokWL2LFjx3Q/gUh1+GbMmMEiIyOxoAcBAHosILZp00a5WXjFihXs1KlTQu7hu3HjhjKkp/WJIUOGsA4dOrAaNWqgDyAAQMaEIzo517x5c/b222+zYcOGsaSkJOUyEVqMI9asWaNU1CmEhu7057NmzWJxcXFs0KBBykiDLh+pW7cu2tXEApCHhgDAktwmAchGQwBgSbJIAM6iIQCwJKdtPj4+R9AQAFiSQyQAu9EQAFgPHvs7SQA2ojEAsKQAbCABmI3GAMCSJNMiYAwaAgBLjgCiSQDaojEAsCStbd7e3jXQEABYD7vdXs3G7RHKCEKDAGCp4X8uxT4JAE0D0tEoAFiKNFuhcTWYigYBwFIjgMn3C0A7NAoAlqLNXwLAj4968f9xB40CgCW4QzFvu9/4KCAVDQOARVKAixr/g/FoHAAsQWxxAhCGhgHAEoTbijP+BwfQOACYmh9sJRnOBQBg+vl/vxIFwMPDozx2AwAw7+o/v5i2gq004wqxHg0FgClZa3uY8QMCzdBQAJjy8E+EzRnjo4BdaDAATDX332Nz1vhfbo9GA8Ckqb9O2D/4P9iPRgPAFBygmC6LANj4Pe6voOEAMMXcv6PNBaNRwBY0IACGZluZ3/73jQJC+Q/4A40IgCHJ4zEcYnPHqHAAGhIAQzLB5q4FBAT8i/+gM2hMAAxFZoUKFTxsapjjz9Lh99CoABiCe0rJbzWNTwWmoGEBMETST7xNgD2KDEEApA/+VB6r/xQhADQVqMK5goYGQEqucgGoahNp/AM68A+6i8YGQCryOS/atDD+Qb3Q4ABINfTva9PS+IeORcMDIEXwj7bpYfzDk9ABAOga/LNsOtoj/AGWoyMA0CX4U/664FNnEZiJDgFAU+bR1rxNFuMPNBSdAoBxE33U2CLsjS1CAIRu9fWyyWwF5cSy0VkAqMoVzfb51cgYpAsI0WkAqDLk38er+lS3Gcwe5Q8/BlMCANw61ZcoLLdfo9HAi3Q2GZ0JQJk4q/qRXr2soKgIjQZuo2MBKBUqwZfo7+/vaTObeXl51eLzmU3oZACKZRsnzGZ24wsakfyLpqPDAVDY72rpbkMb/+LPcr6DAwCLru7vom1zl8t2m2hE0IQ3yDoHriYH5od8fB35vA32oHl7e/vyxumJHAJgwrf9MUqXL1++fAVEunPTgzBOLG+43RgZACO+6QtqaI6lC3YQ0W6Yn5+fF+UTFFQmTsN2IpAQ8skfuI8mkK+achtPInuEz6GCKFGC/9qfjiNzvi5Q3IOcjILzCHlwTOAmeQW+RD51kKan5Gv898n81+gCH6Q03f8yYiD9H4Lpi7OZQSaFAAAAAElFTkSuQmCC";
3067
- function ensureNotifierIcon(dataDir) {
3068
- try {
3069
- const dir = dataDir ?? process.env.SUVEREN_DATA_DIR ?? join8(homedir3(), ".suveren");
3070
- const path = join8(dir, "notifier-icon.png");
3071
- if (!existsSync8(path)) {
3072
- mkdirSync4(dirname5(path), { recursive: true });
3073
- writeFileSync4(path, Buffer.from(ICON_BASE64, "base64"));
3074
- }
3075
- return path;
3076
- } catch {
3077
- return null;
3078
- }
3079
- }
3080
-
3081
- // src/lib/desktop-notify.ts
3082
- function osaQuote(value) {
3083
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
3084
- }
3085
- function psQuote(value) {
3086
- return `'${value.replace(/'/g, "''")}'`;
3087
- }
3088
- function xmlEscape(value) {
3089
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
3090
- }
3091
- var WINDOWS_AUMID = "Suveren.Gateway";
3092
- function windowsToastScript(title, message, url, iconPath) {
3093
- const toastXml = `<toast${url ? ` activationType="protocol" launch="${xmlEscape(url)}"` : ""}><visual><binding template="ToastGeneric"><text>${xmlEscape(title)}</text><text>${xmlEscape(message)}</text>` + (iconPath ? `<image placement="appLogoOverride" src="${xmlEscape(iconPath)}"/>` : "") + `</binding></visual><audio src="ms-winsoundevent:Notification.Default"/></toast>`;
3094
- return `$ErrorActionPreference='SilentlyContinue'; $aumid=${psQuote(WINDOWS_AUMID)}; $reg="HKCU:\\Software\\Classes\\AppUserModelId\\$aumid"; if (-not (Test-Path $reg)) { New-Item -Path $reg -Force | Out-Null }; New-ItemProperty -Path $reg -Name DisplayName -Value 'Suveren' -PropertyType String -Force | Out-Null; ` + (iconPath ? `New-ItemProperty -Path $reg -Name IconUri -Value ${psQuote(iconPath)} -PropertyType String -Force | Out-Null; ` : "") + `[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime] | Out-Null; [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom, ContentType=WindowsRuntime] | Out-Null; $doc = New-Object Windows.Data.Xml.Dom.XmlDocument; $doc.LoadXml(${psQuote(toastXml)}); $toast = New-Object Windows.UI.Notifications.ToastNotification $doc; [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($aumid).Show($toast)`;
3095
- }
3096
- var MAC_SOUND = "Glass";
3097
- function buildNotifyCommand(platform, title, message, options = {}) {
3098
- const { url, hasTerminalNotifier: hasTerminalNotifier2 = false, iconPath } = options;
3099
- switch (platform) {
3100
- case "darwin":
3101
- if (url && hasTerminalNotifier2) {
3102
- return {
3103
- cmd: "terminal-notifier",
3104
- args: [
3105
- "-title",
3106
- title,
3107
- "-message",
3108
- message,
3109
- "-open",
3110
- url,
3111
- "-sound",
3112
- MAC_SOUND,
3113
- ...iconPath ? ["-appIcon", iconPath] : [],
3114
- "-group",
3115
- "ai.suveren.gateway"
3116
- ]
3117
- };
3118
- }
3119
- return {
3120
- cmd: "osascript",
3121
- args: [
3122
- "-e",
3123
- `display notification ${osaQuote(message)} with title ${osaQuote(title)} sound name ${osaQuote(MAC_SOUND)}`
3124
- ]
3125
- };
3126
- case "win32":
3127
- return {
3128
- cmd: "powershell",
3129
- args: ["-NoProfile", "-NonInteractive", "-Command", windowsToastScript(title, message, url, iconPath)]
3130
- };
3131
- case "linux":
3132
- return {
3133
- cmd: "notify-send",
3134
- args: [
3135
- "--app-name=Suveren",
3136
- ...iconPath ? ["-i", iconPath] : [],
3137
- title,
3138
- message
3139
- ]
3140
- };
3141
- default:
3142
- return null;
3143
- }
3144
- }
3145
- function lockedNotification(port2) {
3146
- const url = `http://localhost:${port2}`;
3147
- return {
3148
- title: "Suveren is running but locked",
3149
- message: `Your agent has no authority until you unlock it. Open ${url} and enter your API key.`,
3150
- url
3151
- };
3152
- }
3153
- function sessionExpiredNotification(port2) {
3154
- const url = `http://localhost:${port2}`;
3155
- return {
3156
- title: "Suveren Gateway locked",
3157
- message: "Your sign-in has ended. Sign in again so your agents can act.",
3158
- url
3159
- };
3160
- }
3161
- var terminalNotifierCache = null;
3162
- function hasTerminalNotifier() {
3163
- if (terminalNotifierCache !== null) return terminalNotifierCache;
3164
- try {
3165
- const probe = spawnSync2("terminal-notifier", ["-help"], { stdio: "ignore" });
3166
- terminalNotifierCache = probe.error === void 0;
3167
- } catch {
3168
- terminalNotifierCache = false;
3169
- }
3170
- return terminalNotifierCache;
3171
- }
3172
- function notifySpawnOptions(platform) {
3173
- return {
3174
- detached: platform !== "win32",
3175
- stdio: ["ignore", "ignore", "pipe"]
3176
- };
3177
- }
3178
- function notify(title, message, platform = process.platform, url) {
3179
- const command = buildNotifyCommand(platform, title, message, {
3180
- url,
3181
- hasTerminalNotifier: platform === "darwin" ? hasTerminalNotifier() : false,
3182
- // Windows and Linux show it on the toast itself; macOS only via
3183
- // terminal-notifier. Failure to write it must never block the sound.
3184
- iconPath: ensureNotifierIcon()
3185
- });
3186
- if (!command) return;
3187
- try {
3188
- const child = spawn(command.cmd, command.args, notifySpawnOptions(platform));
3189
- let stderr = "";
3190
- child.stderr?.on("data", (d) => {
3191
- stderr += d.toString().slice(0, 500);
3192
- });
3193
- child.on("close", (code) => {
3194
- if (code !== 0) {
3195
- console.error(`[notify] ${command.cmd} exited ${code}${stderr ? `: ${stderr.trim()}` : ""}`);
3196
- }
3299
+ const message = err instanceof Error ? err.message : String(err);
3300
+ console.error("[Autostart] handler failed:", message);
3301
+ if (!res.headersSent) res.status(500).json({ error: "autostart_failed", message });
3197
3302
  });
3198
- child.on("error", () => {
3303
+ };
3304
+ }
3305
+ function createAutostartRouter() {
3306
+ const router = Router6();
3307
+ router.get("/", safe(async (_req, res) => {
3308
+ const platform = process.platform;
3309
+ const supported = SUPPORTED.has(platform);
3310
+ const bin = findCli();
3311
+ if (!supported || !bin) {
3312
+ res.json({
3313
+ supported: false,
3314
+ installed: false,
3315
+ platform,
3316
+ // Be specific about WHICH of the two reasons applies — "unavailable"
3317
+ // with no cause is the kind of dead end that generates support mail.
3318
+ reason: !supported ? `Autostart is not available on ${platform}.` : "Autostart needs the packaged gateway; it is unavailable when running from source."
3319
+ });
3320
+ return;
3321
+ }
3322
+ const { output } = await cli(["service", "status"]);
3323
+ res.json({
3324
+ supported: true,
3325
+ installed: /Login service:\s*installed/i.test(output),
3326
+ platform,
3327
+ detail: output
3199
3328
  });
3200
- child.unref();
3201
- } catch {
3202
- }
3329
+ }));
3330
+ router.post("/", safe(async (_req, res) => {
3331
+ if (!SUPPORTED.has(process.platform) || !findCli()) {
3332
+ res.status(400).json({ error: "unsupported", message: "Autostart is not available here." });
3333
+ return;
3334
+ }
3335
+ const { ok, output } = await cli(["service", "install"]);
3336
+ if (!ok) {
3337
+ res.status(500).json({ error: "install_failed", message: output });
3338
+ return;
3339
+ }
3340
+ res.json({ ok: true, installed: true, detail: output });
3341
+ }));
3342
+ router.delete("/", safe(async (_req, res) => {
3343
+ if (!SUPPORTED.has(process.platform) || !findCli()) {
3344
+ res.status(400).json({ error: "unsupported", message: "Autostart is not available here." });
3345
+ return;
3346
+ }
3347
+ const { ok, output } = await cli(["service", "uninstall"]);
3348
+ if (!ok) {
3349
+ res.status(500).json({ error: "uninstall_failed", message: output });
3350
+ return;
3351
+ }
3352
+ res.json({ ok: true, installed: false, detail: output });
3353
+ }));
3354
+ return router;
3203
3355
  }
3204
3356
 
3205
3357
  // src/routes/encrypt-intent.ts
@@ -3426,6 +3578,106 @@ function createEvidenceExportRouter(spUrl, vault2, dataDir, lockAsTlsMismatch2)
3426
3578
  // src/routes/proposal-preview.ts
3427
3579
  import { Router as Router10 } from "express";
3428
3580
 
3581
+ // src/lib/content-crypto.ts
3582
+ import { createHash as createHash4 } from "crypto";
3583
+ import { canonicalize as canonicalize2 } from "@hap/core";
3584
+ var CONTENT_ALG = "HPKE-X25519-HKDF-SHA256-AES256GCM";
3585
+ function computeProposalContentHash(toolArgs) {
3586
+ return `sha256:${createHash4("sha256").update(canonicalize2(toolArgs)).digest("hex")}`;
3587
+ }
3588
+ function toB64(bytes) {
3589
+ return Buffer.from(bytes).toString("base64");
3590
+ }
3591
+ function keysToWire(encryptedKeys) {
3592
+ const keys = {};
3593
+ for (const [userId, wrap] of Object.entries(encryptedKeys)) {
3594
+ keys[userId] = { ct: toB64(wrap.ct), enc: toB64(wrap.enc) };
3595
+ }
3596
+ return keys;
3597
+ }
3598
+ async function fetchUserPubkey(userId, deps) {
3599
+ const pin = readPairing(deps.dataDir);
3600
+ const pinning = {
3601
+ enforce: resolvePinTls(deps.dataDir),
3602
+ pinnedSpkiHex: pin && pin.asUrl === deps.spUrl ? pin.tlsSpkiPinHex : void 0
3603
+ };
3604
+ try {
3605
+ const { res } = await fetchAs(
3606
+ `${deps.spUrl}/api/users/${encodeURIComponent(userId)}/pubkey`,
3607
+ { headers: { Cookie: deps.cookie }, signal: AbortSignal.timeout(5e3) },
3608
+ pinning
3609
+ );
3610
+ if (!res.ok) return null;
3611
+ const data = await res.json().catch(() => ({}));
3612
+ return typeof data.pubkey === "string" ? data.pubkey : null;
3613
+ } catch {
3614
+ return null;
3615
+ }
3616
+ }
3617
+ async function resolveRecipientPubkeys(recipientUserIds, deps) {
3618
+ const ids = Array.from(/* @__PURE__ */ new Set([deps.selfUserId, ...recipientUserIds]));
3619
+ const resolved = [];
3620
+ const unreadableFor = [];
3621
+ for (const userId of ids) {
3622
+ const pubkeyB64 = await fetchUserPubkey(userId, deps);
3623
+ if (!pubkeyB64) {
3624
+ unreadableFor.push(userId);
3625
+ continue;
3626
+ }
3627
+ try {
3628
+ const bytes = new Uint8Array(Buffer.from(pubkeyB64, "base64"));
3629
+ if (bytes.length !== 32) {
3630
+ unreadableFor.push(userId);
3631
+ continue;
3632
+ }
3633
+ resolved.push({ userId, publicKey: bytes });
3634
+ } catch {
3635
+ unreadableFor.push(userId);
3636
+ }
3637
+ }
3638
+ return { resolved, unreadableFor };
3639
+ }
3640
+ async function encryptProposalContent(toolArgs, recipientUserIds, deps) {
3641
+ const canonical = canonicalize2(toolArgs);
3642
+ const hash = computeProposalContentHash(toolArgs);
3643
+ const { resolved, unreadableFor } = await resolveRecipientPubkeys(recipientUserIds, deps);
3644
+ if (resolved.length === 0) {
3645
+ throw new Error("No recipient (including the submitter) has a registered end-to-end key \u2014 cannot encrypt this proposal.");
3646
+ }
3647
+ const encrypted = await encryptForRecipients(canonical, resolved);
3648
+ return {
3649
+ content: { v: 1, alg: CONTENT_ALG, ciphertext: toB64(encrypted.intentCiphertext), keys: keysToWire(encrypted.encryptedKeys), hash },
3650
+ unreadableFor
3651
+ };
3652
+ }
3653
+ async function encryptProposalNote(note, recipientUserIds, deps) {
3654
+ const { resolved, unreadableFor } = await resolveRecipientPubkeys(recipientUserIds, deps);
3655
+ if (resolved.length === 0) {
3656
+ throw new Error("No recipient (including the submitter) has a registered end-to-end key \u2014 cannot encrypt this note.");
3657
+ }
3658
+ const encrypted = await encryptForRecipients(note, resolved);
3659
+ return {
3660
+ noteEncrypted: { ciphertext: toB64(encrypted.intentCiphertext), keys: keysToWire(encrypted.encryptedKeys) },
3661
+ unreadableFor
3662
+ };
3663
+ }
3664
+ async function decryptProposalContent(wire, myUserId, myPrivateKey) {
3665
+ const wrap = wire.keys[myUserId];
3666
+ if (!wrap) {
3667
+ throw new Error("No key wrap for this account in this content \u2014 this device cannot read it.");
3668
+ }
3669
+ const encrypted = {
3670
+ intentCiphertext: new Uint8Array(Buffer.from(wire.ciphertext, "base64")),
3671
+ encryptedKeys: {
3672
+ [myUserId]: {
3673
+ ct: new Uint8Array(Buffer.from(wrap.ct, "base64")),
3674
+ enc: new Uint8Array(Buffer.from(wrap.enc, "base64"))
3675
+ }
3676
+ }
3677
+ };
3678
+ return decryptIntent(encrypted, myUserId, myPrivateKey);
3679
+ }
3680
+
3429
3681
  // src/lib/execution-journal-reader.ts
3430
3682
  import { existsSync as existsSync10, readFileSync as readFileSync10 } from "fs";
3431
3683
  import { join as join10 } from "path";
@@ -3492,8 +3744,25 @@ function createProposalStatusRouter(spUrl, vault2, dataDir, lockAsTlsMismatch2)
3492
3744
  res.status(502).json({ error: `Authority Server unreachable: ${err instanceof Error ? err.message : String(err)}` });
3493
3745
  return;
3494
3746
  }
3747
+ let toolArgs;
3748
+ if (proposal.toolArgs) {
3749
+ toolArgs = proposal.toolArgs;
3750
+ } else if (proposal.content) {
3751
+ try {
3752
+ const kp = await loadOrGenerateKeyPair(vault2);
3753
+ const me = vault2.getSessionUser();
3754
+ const plaintext = await decryptProposalContent(proposal.content, me?.id ?? "", kp.privateKey);
3755
+ toolArgs = JSON.parse(plaintext);
3756
+ } catch (err) {
3757
+ res.json({ status: "unavailable", reason: "connector_error", message: `Could not decrypt this proposal's content on this device: ${err instanceof Error ? err.message : String(err)}` });
3758
+ return;
3759
+ }
3760
+ } else {
3761
+ res.status(502).json({ error: `Proposal ${proposalId} carries neither tool_args nor content.` });
3762
+ return;
3763
+ }
3495
3764
  try {
3496
- const result = await getToolPreview({ proposalId, tool: proposal.tool, toolArgs: proposal.toolArgs });
3765
+ const result = await getToolPreview({ proposalId, tool: proposal.tool, toolArgs });
3497
3766
  if (result.status !== "ok") {
3498
3767
  res.json(result);
3499
3768
  return;
@@ -3539,7 +3808,7 @@ function createProposalStatusRouter(spUrl, vault2, dataDir, lockAsTlsMismatch2)
3539
3808
  // src/routes/evidence.ts
3540
3809
  import { Router as Router11 } from "express";
3541
3810
  import { createPublicKey as createPublicKey2, verify as verifyEd25519 } from "crypto";
3542
- import { verifyTicketSignature, encodeDidKey, canonicalize as canonicalize2 } from "@hap/core";
3811
+ import { verifyTicketSignature, encodeDidKey, canonicalize as canonicalize3 } from "@hap/core";
3543
3812
  function issuerFromPublicKeyHex(publicKeyHex) {
3544
3813
  return encodeDidKey(Buffer.from(publicKeyHex, "hex"));
3545
3814
  }
@@ -3558,7 +3827,7 @@ function verifyPreV07TicketSignature(receipt, asPublicKeyHex) {
3558
3827
  });
3559
3828
  const sig = Buffer.from(signature, "base64");
3560
3829
  if (sig.length !== 64) return false;
3561
- return verifyEd25519(null, Buffer.from(canonicalize2(signed), "utf8"), key, sig);
3830
+ return verifyEd25519(null, Buffer.from(canonicalize3(signed), "utf8"), key, sig);
3562
3831
  } catch {
3563
3832
  return false;
3564
3833
  }
@@ -3770,6 +4039,161 @@ function createDecryptIntentRouter(vault2) {
3770
4039
  return router;
3771
4040
  }
3772
4041
 
4042
+ // src/routes/decrypt-proposal-content.ts
4043
+ import { Router as Router14 } from "express";
4044
+ function createDecryptProposalContentRouter(vault2) {
4045
+ const router = Router14();
4046
+ router.post("/", async (req, res) => {
4047
+ const { ciphertext, keys } = req.body;
4048
+ if (typeof ciphertext !== "string" || ciphertext.length === 0) {
4049
+ res.status(400).json({ error: "Missing or invalid field: ciphertext (base64 string)" });
4050
+ return;
4051
+ }
4052
+ if (typeof keys !== "object" || keys === null) {
4053
+ res.status(400).json({ error: "Missing or invalid field: keys (object)" });
4054
+ return;
4055
+ }
4056
+ const me = vault2.getSessionUser();
4057
+ if (!me) {
4058
+ res.status(401).json({ error: "No Authority Server session." });
4059
+ return;
4060
+ }
4061
+ try {
4062
+ const kp = await loadOrGenerateKeyPair(vault2);
4063
+ const plaintext = await decryptProposalContent(
4064
+ { ciphertext, keys },
4065
+ me.id,
4066
+ kp.privateKey
4067
+ );
4068
+ res.json({ plaintext });
4069
+ } catch (err) {
4070
+ console.error("[Control Plane] decrypt-proposal-content error:", err);
4071
+ const msg = err instanceof Error ? err.message : "Decryption failed";
4072
+ res.status(msg.includes("No key wrap") ? 403 : 500).json({ error: msg });
4073
+ }
4074
+ });
4075
+ return router;
4076
+ }
4077
+
4078
+ // src/routes/encrypt-proposal-note.ts
4079
+ import { Router as Router15 } from "express";
4080
+ var MAX_NOTE_LENGTH = 500;
4081
+ function createEncryptProposalNoteRouter(vault2, spUrl, dataDir) {
4082
+ const router = Router15();
4083
+ router.post("/", async (req, res) => {
4084
+ const { note, recipientUserIds } = req.body;
4085
+ if (typeof note !== "string" || note.length === 0 || note.length > MAX_NOTE_LENGTH) {
4086
+ res.status(400).json({ error: `note must be a non-empty string up to ${MAX_NOTE_LENGTH} characters` });
4087
+ return;
4088
+ }
4089
+ if (!Array.isArray(recipientUserIds) || !recipientUserIds.every((id) => typeof id === "string")) {
4090
+ res.status(400).json({ error: "recipientUserIds must be a string array" });
4091
+ return;
4092
+ }
4093
+ const me = vault2.getSessionUser();
4094
+ const cookie = vault2.getSpCookie();
4095
+ if (!me || !cookie) {
4096
+ res.status(401).json({ error: "No Authority Server session." });
4097
+ return;
4098
+ }
4099
+ try {
4100
+ const { noteEncrypted, unreadableFor } = await encryptProposalNote(
4101
+ note,
4102
+ recipientUserIds,
4103
+ { spUrl, cookie, dataDir, selfUserId: me.id }
4104
+ );
4105
+ res.json({ noteEncrypted, unreadableFor });
4106
+ } catch (err) {
4107
+ console.error("[Control Plane] encrypt-proposal-note error:", err);
4108
+ res.status(500).json({ error: err instanceof Error ? err.message : "Encryption failed" });
4109
+ }
4110
+ });
4111
+ return router;
4112
+ }
4113
+
4114
+ // src/routes/internal-content-crypto.ts
4115
+ import { Router as Router16 } from "express";
4116
+ import { timingSafeEqual as timingSafeEqual2 } from "crypto";
4117
+ var MAX_RECIPIENTS = 50;
4118
+ function secretMatches(provided, expected) {
4119
+ if (!expected || !provided) return false;
4120
+ const a = Buffer.from(provided);
4121
+ const b = Buffer.from(expected);
4122
+ return a.length === b.length && timingSafeEqual2(a, b);
4123
+ }
4124
+ function createInternalContentCryptoRouter(getSecret, vault2, spUrl, dataDir) {
4125
+ const router = Router16();
4126
+ router.post("/encrypt-content", async (req, res) => {
4127
+ if (!secretMatches(req.header("X-Internal-Secret") ?? void 0, getSecret())) {
4128
+ res.status(401).json({ error: "unauthorized" });
4129
+ return;
4130
+ }
4131
+ const { toolArgs, recipientUserIds } = req.body ?? {};
4132
+ if (typeof toolArgs !== "object" || toolArgs === null || Array.isArray(toolArgs)) {
4133
+ res.status(400).json({ error: "toolArgs must be an object" });
4134
+ return;
4135
+ }
4136
+ if (!Array.isArray(recipientUserIds) || !recipientUserIds.every((id) => typeof id === "string")) {
4137
+ res.status(400).json({ error: "recipientUserIds must be a string array" });
4138
+ return;
4139
+ }
4140
+ if (recipientUserIds.length > MAX_RECIPIENTS) {
4141
+ res.status(400).json({ error: `too many recipients (max ${MAX_RECIPIENTS})` });
4142
+ return;
4143
+ }
4144
+ const me = vault2.getSessionUser();
4145
+ const cookie = vault2.getSpCookie();
4146
+ if (!me || !cookie) {
4147
+ res.status(401).json({ error: "no Authority Server session" });
4148
+ return;
4149
+ }
4150
+ try {
4151
+ const { content, unreadableFor } = await encryptProposalContent(
4152
+ toolArgs,
4153
+ recipientUserIds,
4154
+ { spUrl, cookie, dataDir, selfUserId: me.id }
4155
+ );
4156
+ res.json({ content, unreadableFor });
4157
+ } catch (err) {
4158
+ console.error("[Control Plane] /internal/encrypt-content failed:", err);
4159
+ res.status(500).json({ error: err instanceof Error ? err.message : "encryption failed" });
4160
+ }
4161
+ });
4162
+ router.post("/decrypt-content", async (req, res) => {
4163
+ if (!secretMatches(req.header("X-Internal-Secret") ?? void 0, getSecret())) {
4164
+ res.status(401).json({ error: "unauthorized" });
4165
+ return;
4166
+ }
4167
+ const { ciphertext, keys } = req.body ?? {};
4168
+ if (typeof ciphertext !== "string" || ciphertext.length === 0) {
4169
+ res.status(400).json({ error: "ciphertext (base64 string) is required" });
4170
+ return;
4171
+ }
4172
+ if (typeof keys !== "object" || keys === null) {
4173
+ res.status(400).json({ error: "keys (object) is required" });
4174
+ return;
4175
+ }
4176
+ const me = vault2.getSessionUser();
4177
+ if (!me) {
4178
+ res.status(401).json({ error: "no Authority Server session" });
4179
+ return;
4180
+ }
4181
+ try {
4182
+ const kp = await loadOrGenerateKeyPair(vault2);
4183
+ const plaintext = await decryptProposalContent(
4184
+ { ciphertext, keys },
4185
+ me.id,
4186
+ kp.privateKey
4187
+ );
4188
+ res.json({ plaintext });
4189
+ } catch (err) {
4190
+ console.error("[Control Plane] /internal/decrypt-content failed:", err);
4191
+ res.status(500).json({ error: err instanceof Error ? err.message : "decryption failed" });
4192
+ }
4193
+ });
4194
+ return router;
4195
+ }
4196
+
3773
4197
  // src/lib/update-checker.ts
3774
4198
  import { execSync } from "child_process";
3775
4199
  import { dirname as dirname6 } from "path";
@@ -3908,7 +4332,7 @@ data: ${data}
3908
4332
  }
3909
4333
 
3910
4334
  // src/routes/gateway-settings.ts
3911
- import { Router as Router14 } from "express";
4335
+ import { Router as Router17 } from "express";
3912
4336
 
3913
4337
  // src/lib/gateway-settings.ts
3914
4338
  import { readFileSync as readFileSync11, writeFileSync as writeFileSync6, mkdirSync as mkdirSync6 } from "fs";
@@ -3944,7 +4368,7 @@ function writeSettings(patch, dataDir) {
3944
4368
 
3945
4369
  // src/routes/gateway-settings.ts
3946
4370
  function createGatewaySettingsRouter() {
3947
- const router = Router14();
4371
+ const router = Router17();
3948
4372
  router.get("/", (_req, res) => {
3949
4373
  res.json(readSettings());
3950
4374
  });
@@ -3967,7 +4391,7 @@ function createGatewaySettingsRouter() {
3967
4391
  }
3968
4392
 
3969
4393
  // src/routes/archived-mandates.ts
3970
- import { Router as Router15 } from "express";
4394
+ import { Router as Router18 } from "express";
3971
4395
 
3972
4396
  // src/lib/archived-mandates-store.ts
3973
4397
  import { readFileSync as readFileSync12, writeFileSync as writeFileSync7, mkdirSync as mkdirSync7, renameSync } from "fs";
@@ -4010,7 +4434,7 @@ function unarchiveMandate(id, dataDir) {
4010
4434
 
4011
4435
  // src/routes/archived-mandates.ts
4012
4436
  function createArchivedMandatesRouter(dataDir) {
4013
- const router = Router15();
4437
+ const router = Router18();
4014
4438
  router.get("/", (_req, res) => {
4015
4439
  res.json({ archived: readArchived(dataDir) });
4016
4440
  });
@@ -4032,24 +4456,24 @@ function createArchivedMandatesRouter(dataDir) {
4032
4456
  }
4033
4457
 
4034
4458
  // src/routes/internal-events.ts
4035
- import { Router as Router16 } from "express";
4036
- import { timingSafeEqual as timingSafeEqual2 } from "crypto";
4459
+ import { Router as Router19 } from "express";
4460
+ import { timingSafeEqual as timingSafeEqual3 } from "crypto";
4037
4461
  var ALLOWED = ["proposal-added", "action-approval-needed"];
4038
4462
  var SESSION_EXPIRED = "session-expired";
4039
4463
  var AS_KEY_MISMATCH = "as-key-mismatch";
4040
4464
  var AS_TLS_MISMATCH = "as-tls-mismatch";
4041
- function secretMatches(provided, expected) {
4465
+ function secretMatches2(provided, expected) {
4042
4466
  if (!expected) return false;
4043
4467
  if (!provided) return false;
4044
4468
  const a = Buffer.from(provided);
4045
4469
  const b = Buffer.from(expected);
4046
4470
  if (a.length !== b.length) return false;
4047
- return timingSafeEqual2(a, b);
4471
+ return timingSafeEqual3(a, b);
4048
4472
  }
4049
4473
  function createInternalEventsRouter(getSecret, onSessionExpired, onAsKeyMismatch, onAsTlsMismatch) {
4050
- const router = Router16();
4474
+ const router = Router19();
4051
4475
  router.post("/event", (req, res) => {
4052
- if (!secretMatches(req.headers["x-internal-secret"], getSecret())) {
4476
+ if (!secretMatches2(req.headers["x-internal-secret"], getSecret())) {
4053
4477
  res.status(401).json({ error: "Unauthorized" });
4054
4478
  return;
4055
4479
  }
@@ -4081,8 +4505,8 @@ function createInternalEventsRouter(getSecret, onSessionExpired, onAsKeyMismatch
4081
4505
  }
4082
4506
 
4083
4507
  // src/routes/internal-mandate.ts
4084
- import { Router as Router17 } from "express";
4085
- import { timingSafeEqual as timingSafeEqual3, randomUUID } from "crypto";
4508
+ import { Router as Router20 } from "express";
4509
+ import { timingSafeEqual as timingSafeEqual4, randomUUID } from "crypto";
4086
4510
 
4087
4511
  // src/lib/mandate-ceremony.ts
4088
4512
  import {
@@ -4256,16 +4680,16 @@ async function createMandate(req, deps) {
4256
4680
  }
4257
4681
 
4258
4682
  // src/routes/internal-mandate.ts
4259
- function secretMatches2(provided, expected) {
4683
+ function secretMatches3(provided, expected) {
4260
4684
  if (!expected || !provided) return false;
4261
4685
  const a = Buffer.from(provided);
4262
4686
  const b = Buffer.from(expected);
4263
- return a.length === b.length && timingSafeEqual3(a, b);
4687
+ return a.length === b.length && timingSafeEqual4(a, b);
4264
4688
  }
4265
4689
  function createInternalMandateRouter(getSecret, deps) {
4266
- const router = Router17();
4690
+ const router = Router20();
4267
4691
  router.post("/mandate", async (req, res) => {
4268
- if (!secretMatches2(req.header("X-Internal-Secret") ?? void 0, getSecret())) {
4692
+ if (!secretMatches3(req.header("X-Internal-Secret") ?? void 0, getSecret())) {
4269
4693
  res.status(401).json({ error: "unauthorized" });
4270
4694
  return;
4271
4695
  }
@@ -4380,62 +4804,6 @@ function startNotificationDispatcher(opts = {}) {
4380
4804
  return new NotificationDispatcher(opts).start();
4381
4805
  }
4382
4806
 
4383
- // src/lib/session-lock.ts
4384
- var SESSION_LOCKED_MESSAGE = "Your sign-in ended after 30 days or was revoked. Sign in again.";
4385
- var AS_KEY_MISMATCH_MESSAGE = "The Authority Server presented a signing key that does not match the one pinned at pairing. Sign in again to review.";
4386
- var AS_TLS_MISMATCH_MESSAGE = "A connection to the Authority Server presented a TLS certificate that does not match the one pinned at pairing. The connection was refused before anything was sent. If the certificate changed intentionally (a new key, not just renewal), clear the pairing and sign in again to re-pin it.";
4387
- function createSessionLock(deps) {
4388
- const { vault: vault2, port: port2 } = deps;
4389
- const notifyFn = deps.notifyFn ?? notify;
4390
- const unconfigureSessionFn = deps.unconfigureSessionFn ?? unconfigureSession;
4391
- const emit = deps.emit ?? eventBus.emit.bind(eventBus);
4392
- return function lockExpiredSession2() {
4393
- if (!vault2.isUnlocked()) return;
4394
- vault2.lockExpired();
4395
- console.error("[Control Plane] Authority Server session ended \u2014 gateway LOCKED");
4396
- void unconfigureSessionFn().catch((err) => {
4397
- console.error("[Control Plane] Failed to push cleared session to MCP:", err);
4398
- });
4399
- emit("session-locked", { reason: "expired", message: SESSION_LOCKED_MESSAGE });
4400
- const { title, message, url } = sessionExpiredNotification(port2);
4401
- notifyFn(title, message, process.platform, url);
4402
- };
4403
- }
4404
- function createAsKeyMismatchLock(deps) {
4405
- const { vault: vault2, port: port2 } = deps;
4406
- const notifyFn = deps.notifyFn ?? notify;
4407
- const unconfigureSessionFn = deps.unconfigureSessionFn ?? unconfigureSession;
4408
- const emit = deps.emit ?? eventBus.emit.bind(eventBus);
4409
- return function lockOnAsKeyMismatch() {
4410
- if (!vault2.isUnlocked()) return;
4411
- vault2.lockAsKeyMismatch();
4412
- console.error("[Control Plane] Authority Server key mismatch \u2014 gateway LOCKED");
4413
- void unconfigureSessionFn("as-key-mismatch").catch((err) => {
4414
- console.error("[Control Plane] Failed to push cleared session to MCP:", err);
4415
- });
4416
- emit("session-locked", { reason: "as-key-mismatch", message: AS_KEY_MISMATCH_MESSAGE });
4417
- const { title, url } = sessionExpiredNotification(port2);
4418
- notifyFn(title, AS_KEY_MISMATCH_MESSAGE, process.platform, url);
4419
- };
4420
- }
4421
- function createAsTlsMismatchLock(deps) {
4422
- const { vault: vault2, port: port2 } = deps;
4423
- const notifyFn = deps.notifyFn ?? notify;
4424
- const unconfigureSessionFn = deps.unconfigureSessionFn ?? unconfigureSession;
4425
- const emit = deps.emit ?? eventBus.emit.bind(eventBus);
4426
- return function lockOnAsTlsMismatch() {
4427
- if (!vault2.isUnlocked()) return;
4428
- vault2.lockAsTlsMismatch();
4429
- console.error("[Control Plane] Authority Server TLS pin mismatch \u2014 gateway LOCKED");
4430
- void unconfigureSessionFn("as-tls-mismatch").catch((err) => {
4431
- console.error("[Control Plane] Failed to push cleared session to MCP:", err);
4432
- });
4433
- emit("session-locked", { reason: "as-tls-mismatch", message: AS_TLS_MISMATCH_MESSAGE });
4434
- const { title, url } = sessionExpiredNotification(port2);
4435
- notifyFn(title, AS_TLS_MISMATCH_MESSAGE, process.platform, url);
4436
- };
4437
- }
4438
-
4439
4807
  // src/lib/session-expiry-scheduler.ts
4440
4808
  var MAX_TIMEOUT_MS = 2147483647;
4441
4809
  var SessionExpiryScheduler = class {
@@ -4555,6 +4923,7 @@ var SP_URL = resolveAsUrl(DATA_DIR);
4555
4923
  validatePinTlsForUrl(resolvePinTls(DATA_DIR), SP_URL);
4556
4924
  var POLICY_LOCKED = [...readPolicy().locked];
4557
4925
  var POLICY_LOCKED_INSTALL_METHOD = readPolicy().policy.installMethod;
4926
+ var POLICY_LOCKED_TEAM = readPolicy().policy.team;
4558
4927
  var port = parseInt(process.env.SUVEREN_CP_PORT ?? "3402", 10);
4559
4928
  var HAP_MODE = process.env.HAP_MODE ?? "personal";
4560
4929
  var UI_DIST = process.env.HAP_UI_DIST ?? join14(import.meta.dirname ?? __dirname, "../../ui/dist");
@@ -4594,6 +4963,7 @@ function loginRateLimit(req, res, next) {
4594
4963
  var lockExpiredSession = createSessionLock({ vault, port });
4595
4964
  var lockAsKeyMismatch = createAsKeyMismatchLock({ vault, port });
4596
4965
  var lockAsTlsMismatch = createAsTlsMismatchLock({ vault, port });
4966
+ var lockManagedWrongTeam = createManagedWrongTeamLock({ vault, port });
4597
4967
  var sessionExpiryScheduler = new SessionExpiryScheduler(
4598
4968
  () => vault.getSessionExpiresAt(),
4599
4969
  lockExpiredSession
@@ -4603,8 +4973,26 @@ app.use("/auth", jsonParser, createAuthRouter(
4603
4973
  requireAuth(vault),
4604
4974
  loginRateLimit,
4605
4975
  () => sessionExpiryScheduler.reschedule(),
4606
- { asUrl: SP_URL, dataDir: DATA_DIR }
4976
+ { asUrl: SP_URL, dataDir: DATA_DIR },
4977
+ lockAsTlsMismatch,
4978
+ POLICY_LOCKED_TEAM
4607
4979
  ));
4980
+ var managedTeamScheduler = POLICY_LOCKED_TEAM ? new ManagedTeamScheduler(async () => {
4981
+ if (!vault.isUnlocked()) return;
4982
+ const cookie = vault.getSpCookie();
4983
+ if (!cookie) return;
4984
+ try {
4985
+ const pin = readPairing(DATA_DIR);
4986
+ const team = await activeTeamId(SP_URL, cookie, {
4987
+ enforce: resolvePinTls(DATA_DIR),
4988
+ pinnedSpkiHex: pin && pin.asUrl === SP_URL ? pin.tlsSpkiPinHex : void 0
4989
+ });
4990
+ if (team !== POLICY_LOCKED_TEAM) lockManagedWrongTeam(POLICY_LOCKED_TEAM);
4991
+ } catch (err) {
4992
+ console.error("[Control Plane] Managed-team check failed (will retry):", err);
4993
+ }
4994
+ }) : null;
4995
+ managedTeamScheduler?.start();
4608
4996
  function resolveOrigin(req) {
4609
4997
  const fwdHost = req.get("x-forwarded-host");
4610
4998
  if (fwdHost) return `${req.protocol}://${fwdHost}`;
@@ -4838,6 +5226,7 @@ app.use("/internal", jsonParser, createInternalMandateRouter(() => internalSecre
4838
5226
  encrypt: encryptIntentForRecipients,
4839
5227
  deliverGateContent: (args) => pushGateContent(args)
4840
5228
  })));
5229
+ app.use("/internal", jsonParser, createInternalContentCryptoRouter(() => internalSecret2, vault, SP_URL, DATA_DIR));
4841
5230
  app.use("/vault", jsonParser, authGuard, createVaultRouter(vault));
4842
5231
  app.use("/ai", jsonParser, authGuard, createAIRouter(vault));
4843
5232
  app.use("/ai-prompts", jsonParser, authGuard, createAIPromptsRouter());
@@ -4851,6 +5240,8 @@ app.use("/proposal-status", authGuard, createProposalStatusRouter(SP_URL, vault,
4851
5240
  app.use("/api/evidence", authGuard, createEvidenceRouter());
4852
5241
  app.use("/api/report", jsonParser, authGuard, createReportRouter(() => RUNNING_VERSION));
4853
5242
  app.use("/api/decrypt-intent", jsonParser, authGuard, createDecryptIntentRouter(vault));
5243
+ app.use("/api/decrypt-proposal-content", jsonParser, authGuard, createDecryptProposalContentRouter(vault));
5244
+ app.use("/api/encrypt-proposal-note", jsonParser, authGuard, createEncryptProposalNoteRouter(vault, SP_URL, DATA_DIR));
4854
5245
  app.use("/api/approved-intents", jsonParser, authGuard, createApprovedIntentsRouter(vault));
4855
5246
  app.put("/integrations/:id/read-policy", jsonParser, authGuard, async (req, res) => {
4856
5247
  const id = req.params.id;
@@ -5141,8 +5532,13 @@ app.use(
5141
5532
  eventBus.emit("proposal-approved");
5142
5533
  } else if (method === "POST" && /^\/api\/proposals\/[^/]+\/reject$/.test(path)) {
5143
5534
  eventBus.emit("proposal-rejected");
5144
- } else if (method === "POST" && (path === "/api/groups" || path === "/api/groups/join" || /^\/api\/groups\/[^/]+\/(leave|disable|reactivate|admin-transfer)$/.test(path))) {
5535
+ } else if (method === "POST" && (path === "/api/groups" || path === "/api/groups/join" || /^\/api\/groups\/[^/]+\/(leave|disable|reactivate|admin-transfer)$/.test(path)) || // WS0 (work-plan "Added 2026-10-11"): withdrawing a pending join
5536
+ // request — the account stays Personal, but the onboarding UI's
5537
+ // waiting state ends, same as every other membership-affecting
5538
+ // call here.
5539
+ method === "DELETE" && path === "/api/groups/join") {
5145
5540
  eventBus.emit("team-membership-changed");
5541
+ if (managedTeamScheduler) void managedTeamScheduler.runNow();
5146
5542
  }
5147
5543
  }
5148
5544
  }