ftown 0.20.9 → 0.24.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 (154) hide show
  1. package/dist/attention-events.d.ts +1 -1
  2. package/dist/attention-store.d.ts +3 -0
  3. package/dist/attention-store.js +25 -1
  4. package/dist/attention-store.js.map +1 -1
  5. package/dist/centrifugo-client.d.ts +28 -11
  6. package/dist/centrifugo-client.js +84 -70
  7. package/dist/centrifugo-client.js.map +1 -1
  8. package/dist/cli-client.d.ts +11 -1
  9. package/dist/cli-client.js +23 -3
  10. package/dist/cli-client.js.map +1 -1
  11. package/dist/command-rpc.d.ts +2 -0
  12. package/dist/command-rpc.js +59 -17
  13. package/dist/command-rpc.js.map +1 -1
  14. package/dist/identity-reset.d.ts +4 -1
  15. package/dist/identity-reset.js +54 -6
  16. package/dist/identity-reset.js.map +1 -1
  17. package/dist/index.js +194 -288
  18. package/dist/index.js.map +1 -1
  19. package/dist/ledger-pairing-client.d.ts +28 -0
  20. package/dist/ledger-pairing-client.js +154 -0
  21. package/dist/ledger-pairing-client.js.map +1 -0
  22. package/dist/ledger-relay.d.ts +159 -0
  23. package/dist/ledger-relay.js +595 -0
  24. package/dist/ledger-relay.js.map +1 -0
  25. package/dist/ledger-reoffer.d.ts +56 -0
  26. package/dist/ledger-reoffer.js +210 -0
  27. package/dist/ledger-reoffer.js.map +1 -0
  28. package/dist/ledger-startup.d.ts +176 -0
  29. package/dist/ledger-startup.js +233 -0
  30. package/dist/ledger-startup.js.map +1 -0
  31. package/dist/local-api-server.d.ts +3 -0
  32. package/dist/local-api-server.js +68 -6
  33. package/dist/local-api-server.js.map +1 -1
  34. package/dist/mail-delivery.d.ts +26 -3
  35. package/dist/mail-delivery.js +65 -7
  36. package/dist/mail-delivery.js.map +1 -1
  37. package/dist/preview-protocol.d.ts +19 -3
  38. package/dist/preview-protocol.js +14 -0
  39. package/dist/preview-protocol.js.map +1 -1
  40. package/dist/relay-token.d.ts +45 -0
  41. package/dist/relay-token.js +138 -0
  42. package/dist/relay-token.js.map +1 -0
  43. package/dist/revocation-gating.d.ts +0 -13
  44. package/dist/revocation-gating.js +0 -16
  45. package/dist/revocation-gating.js.map +1 -1
  46. package/dist/security/bridge/identity.d.ts +2 -47
  47. package/dist/security/bridge/identity.js +3 -3
  48. package/dist/security/bridge/identity.js.map +1 -1
  49. package/dist/security/bridge/ledger-identity.d.ts +48 -0
  50. package/dist/security/bridge/ledger-identity.js +342 -0
  51. package/dist/security/bridge/ledger-identity.js.map +1 -0
  52. package/dist/security/bridge/ledger-rpc.d.ts +34 -0
  53. package/dist/security/bridge/ledger-rpc.js +58 -0
  54. package/dist/security/bridge/ledger-rpc.js.map +1 -0
  55. package/dist/security/bridge/ledger-store.d.ts +153 -0
  56. package/dist/security/bridge/ledger-store.js +825 -0
  57. package/dist/security/bridge/ledger-store.js.map +1 -0
  58. package/dist/security/bridge/metadata.d.ts +2 -2
  59. package/dist/security/bridge/permissions.js +4 -3
  60. package/dist/security/bridge/permissions.js.map +1 -1
  61. package/dist/security/bridge/policy.d.ts +43 -23
  62. package/dist/security/bridge/policy.js +214 -146
  63. package/dist/security/bridge/policy.js.map +1 -1
  64. package/dist/security/bridge/security.d.ts +212 -33
  65. package/dist/security/bridge/security.js +437 -165
  66. package/dist/security/bridge/security.js.map +1 -1
  67. package/dist/security/crypto.js +1 -1
  68. package/dist/security/crypto.js.map +1 -1
  69. package/dist/security/ledger/announce.d.ts +32 -0
  70. package/dist/security/ledger/announce.js +101 -0
  71. package/dist/security/ledger/announce.js.map +1 -0
  72. package/dist/security/ledger/channels.d.ts +5 -0
  73. package/dist/security/ledger/channels.js +21 -0
  74. package/dist/security/ledger/channels.js.map +1 -0
  75. package/dist/security/ledger/ledger.d.ts +154 -0
  76. package/dist/security/ledger/ledger.js +878 -0
  77. package/dist/security/ledger/ledger.js.map +1 -0
  78. package/dist/security/ledger/pairing.d.ts +81 -0
  79. package/dist/security/ledger/pairing.js +414 -0
  80. package/dist/security/ledger/pairing.js.map +1 -0
  81. package/dist/security/ledger/proof.d.ts +36 -0
  82. package/dist/security/ledger/proof.js +193 -0
  83. package/dist/security/ledger/proof.js.map +1 -0
  84. package/dist/security/ledger/protocol.d.ts +113 -0
  85. package/dist/security/ledger/protocol.js +16 -0
  86. package/dist/security/ledger/protocol.js.map +1 -0
  87. package/dist/security/ledger/testing.d.ts +27 -0
  88. package/dist/security/ledger/testing.js +70 -0
  89. package/dist/security/ledger/testing.js.map +1 -0
  90. package/dist/security/ledger/webauthn.d.ts +12 -0
  91. package/dist/security/ledger/webauthn.js +147 -0
  92. package/dist/security/ledger/webauthn.js.map +1 -0
  93. package/dist/security/protocol.d.ts +5 -10
  94. package/dist/security-cli.d.ts +5 -0
  95. package/dist/security-cli.js +28 -16
  96. package/dist/security-cli.js.map +1 -1
  97. package/dist/session-controller.d.ts +9 -3
  98. package/dist/session-controller.js +120 -20
  99. package/dist/session-controller.js.map +1 -1
  100. package/dist/session-inbox-protocol.d.ts +2 -0
  101. package/dist/session-preview.d.ts +20 -5
  102. package/dist/session-preview.js +185 -37
  103. package/dist/session-preview.js.map +1 -1
  104. package/dist/session-store.js +19 -1
  105. package/dist/session-store.js.map +1 -1
  106. package/dist/sessions-commands.js +41 -7
  107. package/dist/sessions-commands.js.map +1 -1
  108. package/dist/token-provider.d.ts +39 -0
  109. package/dist/token-provider.js +101 -0
  110. package/dist/token-provider.js.map +1 -0
  111. package/dist/types.d.ts +1 -1
  112. package/opencode-plugin/ftown.js +15 -2
  113. package/package.json +2 -2
  114. package/skills/ftown/SKILL.md +18 -7
  115. package/skills/ftown/references/sessions.md +38 -12
  116. package/dist/bridge-auth.d.ts +0 -31
  117. package/dist/bridge-auth.js +0 -70
  118. package/dist/bridge-auth.js.map +0 -1
  119. package/dist/pairing-client.d.ts +0 -62
  120. package/dist/pairing-client.js +0 -469
  121. package/dist/pairing-client.js.map +0 -1
  122. package/dist/rotating-token-refresher.d.ts +0 -42
  123. package/dist/rotating-token-refresher.js +0 -91
  124. package/dist/rotating-token-refresher.js.map +0 -1
  125. package/dist/security/account-crypto.d.ts +0 -33
  126. package/dist/security/account-crypto.js +0 -649
  127. package/dist/security/account-crypto.js.map +0 -1
  128. package/dist/security/account-protocol.d.ts +0 -228
  129. package/dist/security/account-protocol.js +0 -2
  130. package/dist/security/account-protocol.js.map +0 -1
  131. package/dist/security/bridge/account-client.d.ts +0 -7
  132. package/dist/security/bridge/account-client.js +0 -101
  133. package/dist/security/bridge/account-client.js.map +0 -1
  134. package/dist/security/bridge/account-state.d.ts +0 -105
  135. package/dist/security/bridge/account-state.js +0 -79
  136. package/dist/security/bridge/account-state.js.map +0 -1
  137. package/dist/security/bridge/pending-approval.d.ts +0 -21
  138. package/dist/security/bridge/pending-approval.js +0 -80
  139. package/dist/security/bridge/pending-approval.js.map +0 -1
  140. package/dist/security/bridge/pending-enrollment.d.ts +0 -33
  141. package/dist/security/bridge/pending-enrollment.js +0 -177
  142. package/dist/security/bridge/pending-enrollment.js.map +0 -1
  143. package/dist/security/bridge/revocation.d.ts +0 -14
  144. package/dist/security/bridge/revocation.js +0 -40
  145. package/dist/security/bridge/revocation.js.map +0 -1
  146. package/dist/security/pending-approval-crypto.d.ts +0 -29
  147. package/dist/security/pending-approval-crypto.js +0 -716
  148. package/dist/security/pending-approval-crypto.js.map +0 -1
  149. package/dist/security/pending-approval-protocol.d.ts +0 -329
  150. package/dist/security/pending-approval-protocol.js +0 -2
  151. package/dist/security/pending-approval-protocol.js.map +0 -1
  152. package/dist/security/security-nudge.d.ts +0 -31
  153. package/dist/security/security-nudge.js +0 -98
  154. package/dist/security/security-nudge.js.map +0 -1
package/dist/index.js CHANGED
@@ -6,7 +6,6 @@ import { hostname as osHostname, networkInterfaces, homedir } from 'node:os';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { mkdirSync, readFileSync, writeFileSync, unlinkSync, realpathSync, chmodSync } from 'node:fs';
8
8
  import { randomBytes } from 'node:crypto';
9
- import { createInterface } from 'node:readline/promises';
10
9
  import { cleanup as ndcCleanup } from 'node-datachannel';
11
10
  import { configureUpdateOptions } from './update-options.js';
12
11
  import { CentrifugoClient } from './centrifugo-client.js';
@@ -27,12 +26,8 @@ import { assertSetupReady } from './setup-state.js';
27
26
  import { canonicalNotifyPath } from './install-notify-script.js';
28
27
  import { unregisterSession, configureSessionRegistryHome } from './session-registry.js';
29
28
  import { loadOrCreateIdentity, readBoundBridgeIdentity } from './security/bridge/identity.js';
30
- import { bindPendingBridgeIdentity, prepareBridgeEnrollment, preparePendingBridgeEnrollment, previousPendingBridgeAttempt, savePairingVerificationUrl } from './security/bridge/pending-enrollment.js';
31
- import { selectBridgePairingMode, waitForPendingBridgeDecision, writePendingBridgeStatus } from './security/bridge/pending-approval.js';
32
- import { readAccountHistory } from './security/bridge/account-client.js';
33
- import { openAccountApproval } from './security/account-crypto.js';
29
+ import { bindLedgerIdentity } from './security/bridge/ledger-identity.js';
34
30
  import { BridgeSecurityService, applicationJson } from './security/bridge/security.js';
35
- import { readPinnedAccountCheckpoint } from './security/bridge/policy.js';
36
31
  import { createCommandHandler } from './command-rpc.js';
37
32
  import { PullRequestStatusService } from './pull-request-status.js';
38
33
  import { createPrReviewService } from './pull-request-review.js';
@@ -43,14 +38,13 @@ import { TerminalPump } from './terminal-pump.js';
43
38
  import { collectSessionUsage } from './usage-collector.js';
44
39
  import { AgentSessionIdPersister } from './session-ids.js';
45
40
  import { HookUsagePersister } from './hook-usage.js';
46
- import { BridgeRevokedError, fetchBridgeToken, refreshBridgeToken } from './bridge-auth.js';
47
- import { beginIdentityReset, completePendingIdentityReset } from './identity-reset.js';
48
41
  import { createRevokedRestart } from './revoked-restart.js';
49
- import { bridgeMode, canReexecAfterRevocation, handlesRevocation, restartForPinnedRevocation, restartForRefreshError } from './revocation-gating.js';
50
- import { RotatingTokenRefresher } from './rotating-token-refresher.js';
42
+ import { bridgeMode, canReexecAfterRevocation } from './revocation-gating.js';
43
+ import { createEndpointRegistrar, heldHistoryNotice, resetIdentityForStart, startAccountIdentity, startRelay, usesLedger } from './ledger-startup.js';
44
+ import { createAccountRelay, createLedgerServer, readSavedRelayUrl, registerBridgeEndpoint, saveRelayUrl, subscribeApprovalRequired } from './ledger-relay.js';
45
+ import { runLedgerPairing } from './ledger-pairing-client.js';
51
46
  import { resolveDefaultDataDir, resolveFtownHome } from './ftown-home.js';
52
47
  import { SessionController } from './session-controller.js';
53
- import { cancelPreviousPendingPairing, runPairing } from './pairing-client.js';
54
48
  import { createServer } from 'node:http';
55
49
  import { generateAccessKey, mintHubJwt, sha256Hex } from './solo/solo-auth.js';
56
50
  import { DEFAULT_SOLO_PORT, SOLO_USER_ID } from './solo/contract.js';
@@ -102,20 +96,13 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
102
96
  .option('--solo', 'Single-port LAN deployment: no account service, managed hub + panel children, key-based auth')
103
97
  .option('--port <port>', 'Public port for --solo front (default: see DEFAULT_SOLO_PORT)')
104
98
  .option('--rotate-key', 'With --solo: regenerate the access key, print the new banner, exit (offline)')
105
- .option('--token <jwt>', 'Bridge bootstrap token from the ftown dashboard (short-lived; used once to onboard, then a rotating refresh token is stored). Optional: with no token and no stored refresh token, the bridge runs interactive device pairing instead.')
106
- .option('--account <email>', 'Request approval in this account’s trusted-device inbox; compare and confirm on both devices')
107
- .option('--bootstrap', 'Explicit first-computer setup using a private approval link')
108
99
  .option('--api-url <url>', 'ftown UI API URL', DEFAULT_API_URL)
109
100
  .option('--data-dir <path>', 'Directory for session data (default: ~/.ftown/data)')
110
101
  .option('--bridge-id <id>', 'Bridge instance ID (default: persisted per data dir)')
111
- .option('--new-identity', 'Forget this computer’s account identity and credentials, then pair it again as a new computer with a private link (sessions are kept)')
102
+ .option('--new-identity', 'Forget this computer’s account identity, then pair it again as a new computer with a passkey link (sessions are kept)')
112
103
  .action(async (opts) => {
113
104
  if (opts.local && opts.solo)
114
105
  program.error('--local and --solo cannot be combined');
115
- if (opts.account !== undefined && opts.bootstrap)
116
- program.error('--account and --bootstrap cannot be combined');
117
- if ((opts.local || opts.solo) && (opts.account !== undefined || opts.bootstrap))
118
- program.error('--account and --bootstrap require cloud mode');
119
106
  if ((opts.local || opts.solo) && opts.newIdentity)
120
107
  program.error('--new-identity requires cloud mode');
121
108
  const apiUrl = new URL(opts.apiUrl);
@@ -135,10 +122,26 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
135
122
  const dataDir = opts.dataDir ? resolve(opts.dataDir) : defaultDataDir;
136
123
  // No bridge starts, and no identity is reset, while an update holds the reservation.
137
124
  assertUpdateReservationAllowed(homedir());
138
- if (opts.newIdentity)
139
- beginIdentityReset(dataDir, 'manual');
140
- // Also finishes a reset that a revoked restart or a crash left pending.
141
- const identityReset = completePendingIdentityReset(dataDir);
125
+ const runMode = bridgeMode(opts);
126
+ // Restart context is authenticated by the private helper channel before any mutation. It is read before any reset because a
127
+ // helper-started bridge has no visible terminal: it must never print a pairing link (an invalid context matters only below).
128
+ let launchContext = null;
129
+ let launchContextError = null;
130
+ try {
131
+ launchContext = readUpdateRestartContext();
132
+ }
133
+ catch (err) {
134
+ launchContextError = err;
135
+ }
136
+ const launchedByUpdate = launchContext !== null || launchContextError !== null;
137
+ // Account mode: --new-identity or v1 account state starts a reset. Every mode finishes one a revoked restart or a crash left pending.
138
+ const resetOutcome = resetIdentityForStart({ dataDir, mode: runMode, newIdentity: Boolean(opts.newIdentity), updateRestart: launchedByUpdate,
139
+ log: line => console.log(line) });
140
+ if (resetOutcome.kind === 'exit') {
141
+ console.log(resetOutcome.message);
142
+ return process.exit(1);
143
+ }
144
+ const identityReset = resetOutcome.reset;
142
145
  let updateRestart = null;
143
146
  if (identityReset) {
144
147
  console.log(`[Bridge] New identity for this computer: ${identityReset.oldBridgeId ?? 'unknown'} -> ${identityReset.newBridgeId}`);
@@ -147,25 +150,17 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
147
150
  opts.bridgeId = undefined;
148
151
  }
149
152
  // A reset bridge is a fresh install; an update context names the identity it just discarded.
150
- try {
151
- if (readUpdateRestartContext())
152
- console.warn('[Bridge] Ignoring the update restart context: this computer has a new identity.');
153
- }
154
- catch (err) {
155
- console.warn('[Bridge] Ignoring an invalid update restart context after the identity reset:', err instanceof Error ? err.message : String(err));
156
- }
157
- if (!opts.local && !opts.solo) {
158
- if (opts.token !== undefined || opts.account !== undefined)
159
- console.warn('[Bridge] Ignoring --token and --account: a new identity pairs with a private link.');
160
- opts.token = undefined;
161
- opts.account = undefined;
162
- opts.bootstrap = true;
153
+ if (launchContextError !== null) {
154
+ console.warn('[Bridge] Ignoring an invalid update restart context after the identity reset:', launchContextError instanceof Error ? launchContextError.message : String(launchContextError));
163
155
  }
156
+ else if (launchContext)
157
+ console.warn('[Bridge] Ignoring the update restart context: this computer has a new identity.');
164
158
  }
165
159
  else {
166
- // Restart context is authenticated by the private helper channel before any mutation.
167
- updateRestart = readUpdateRestartContext();
168
- if (updateRestart && (updateRestart.targetVersion !== BRIDGE_VERSION || opts.token || opts.account || opts.bootstrap || opts.solo))
160
+ if (launchContextError !== null)
161
+ throw launchContextError;
162
+ updateRestart = launchContext;
163
+ if (updateRestart && (updateRestart.targetVersion !== BRIDGE_VERSION || opts.solo))
169
164
  throw new Error('Invalid update restart context');
170
165
  }
171
166
  // Every child/local CLI must resolve this exact instance, including custom --data-dir bridges.
@@ -213,12 +208,6 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
213
208
  shutdown: () => releaseForRestart(), log: message => console.log(message), exit: code => process.exit(code),
214
209
  canReexec: () => canReexecAfterRevocation({ updateRestart: updateRestart !== null, updatePhase }),
215
210
  });
216
- const securityDir = join(dataDir, 'security');
217
- const runMode = bridgeMode(opts);
218
- if (restartForPinnedRevocation(runMode, readPinnedAccountCheckpoint(securityDir), bridgeId)) {
219
- await revokedRestart();
220
- return;
221
- }
222
211
  // L4: --rotate-key short-circuits BEFORE any listener binds (exit 0,
223
212
  // nothing started). Without --solo it is an argument error.
224
213
  if (opts.rotateKey && !opts.solo) {
@@ -407,239 +396,143 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
407
396
  };
408
397
  }
409
398
  // ----- END SOLO MODE -----
410
- // The bootstrap token (--token) is single-use and short-lived (F1). Once a
411
- // bridge has onboarded it persists its rotating refresh token (F3) and
412
- // resumes from that on restart, so the bootstrap token is never reused.
413
- const local = { localPort: hookPort, localNonce };
414
- const refreshTokenPath = join(dataDir, 'refresh-token');
415
- let persistedRefreshToken;
416
- try {
417
- persistedRefreshToken = readFileSync(refreshTokenPath, 'utf8').trim() || undefined;
418
- }
419
- catch {
420
- persistedRefreshToken = undefined;
421
- }
422
- const persistRefreshToken = (token) => {
423
- try {
424
- writeFileSync(refreshTokenPath, `${token}\n`, { mode: 0o600 });
399
+ const browserOrigin = new URL(opts.apiUrl).origin;
400
+ const localOrigin = `http://127.0.0.1:${hookPort}`;
401
+ /** Account startup failures end the process (the local API server would otherwise keep it alive). */
402
+ const fail = (message) => {
403
+ if (message)
404
+ console.error(message);
405
+ return process.exit(1);
406
+ };
407
+ /** The relay identity of this bridge: account mode uses the accountId (relay JWT `sub`), never an email. */
408
+ let auth;
409
+ let security;
410
+ let accountRelay;
411
+ let accountRegistrar;
412
+ /** Bound once the relay client exists: a relay refused earlier reconnects when the key is accepted again. */
413
+ let reconnectRelay = () => { };
414
+ if (!usesLedger(runMode)) {
415
+ // Solo and --local: identity is synthesized locally (contract S2/S10); no account, no ledger, no relay token by proof.
416
+ const boundAtStartup = await readBoundBridgeIdentity(dataDir);
417
+ if (updateRestart && (!boundAtStartup || boundAtStartup.identity.endpoint.id !== updateRestart.endpointId ||
418
+ boundAtStartup.identity.endpoint.ownerId !== updateRestart.ownerId))
419
+ throw new Error('Update restart identity is unavailable');
420
+ if (opts.local) {
421
+ auth = { ownerId: `solo:${bridgeId}`, userId: 'local', token: '', centrifugoUrl: 'ws://127.0.0.1/disabled' };
425
422
  }
426
- catch (err) {
427
- console.error('[Bridge] Failed to persist refresh token:', err instanceof Error ? err.message : String(err));
423
+ else {
424
+ // getToken() mints a fresh hub JWT on demand. The bridge's own connection token carries `info` (matching the cloud
425
+ // minter's shape) so the panel's presence-based bridge list sees it — browser tokens from /api/solo/bootstrap|token
426
+ // deliberately omit it.
427
+ const soloRuntime = solo;
428
+ auth = {
429
+ ownerId: `solo:${bridgeId}`,
430
+ userId: SOLO_USER_ID,
431
+ token: mintHubJwt({ secret: soloRuntime.config.hubSecret, info: { bridgeId, hostname: osHostname() } }),
432
+ centrifugoUrl: `ws://127.0.0.1:${soloRuntime.config.hubPort}/connection/websocket`,
433
+ };
428
434
  }
429
- };
430
- // Onboarding precedence (P7): stored refresh token → else --token exchange →
431
- // else interactive device pairing. Pairing yields the SAME token bundle shape
432
- // as /api/auth/bridge, so all three paths converge on `auth` and the identical
433
- // refresh-token persist/resume logic below.
434
- const boundAtStartup = await readBoundBridgeIdentity(dataDir);
435
- if (updateRestart && (!boundAtStartup || boundAtStartup.identity.endpoint.id !== updateRestart.endpointId ||
436
- boundAtStartup.identity.endpoint.ownerId !== updateRestart.ownerId || (!opts.local && !persistedRefreshToken)))
437
- throw new Error('Update restart identity is unavailable');
438
- let pendingEnrollment;
439
- let pendingVerified;
440
- const onboard = async () => {
441
- if (updateRestart)
442
- throw new Error('Update restart requires existing authentication');
443
- if (opts.token) {
444
- console.log('[Bridge] Authenticating with API...');
445
- return fetchBridgeToken(opts.apiUrl, opts.token, bridgeId, local);
435
+ if (boundAtStartup && boundAtStartup.identity.endpoint.ownerId !== auth.ownerId)
436
+ throw new Error('wrong_context');
437
+ const identity = boundAtStartup?.identity ?? await loadOrCreateIdentity(dataDir, auth.ownerId, 'bridge');
438
+ if (identity.endpoint.ownerId !== auth.ownerId)
439
+ throw new Error('wrong_context');
440
+ const localIdentity = await loadOrCreateIdentity(dataDir, auth.ownerId, 'local-cli');
441
+ const localSecurity = new BridgeSecurityService(identity, bridgeId, dataDir, { relayUserId: auth.userId, relayUrl: auth.centrifugoUrl, browserOrigin, localOrigin });
442
+ security = localSecurity;
443
+ releaseForRestart = async () => { localApiServer.stop(); await localSecurity.close(); };
444
+ await localSecurity.policy.provisionLocalEndpoint(localIdentity.endpoint);
445
+ }
446
+ else {
447
+ // ----- ACCOUNT MODE: the passkey ledger (CONTRACT C5) -----
448
+ const server = createLedgerServer({ apiUrl: opts.apiUrl });
449
+ // Steps 3-4: pair when unpaired, recover the replica when needed, then this bridge's own status, before any network identity use.
450
+ const startup = await startAccountIdentity({
451
+ // A helper-started bridge (even one whose context a reset discarded) has no terminal for the pairing link.
452
+ dataDir, bridgeId, allowPairing: !launchedByUpdate,
453
+ pair: (keys, store) => runLedgerPairing({ apiUrl: opts.apiUrl, dataDir, bridgeId, label: osHostname(), identity: keys, store,
454
+ bindIdentity: accountId => bindLedgerIdentity(dataDir, accountId), print: line => console.log(line) }),
455
+ fetchLedger: accountId => server.fetchLedger(accountId),
456
+ });
457
+ if (startup.kind === 'revoked') {
458
+ await revokedRestart();
459
+ return;
446
460
  }
447
- console.log('[Bridge] No bootstrap token or stored session — starting device pairing...');
448
- if (!boundAtStartup) {
449
- const interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY);
450
- const previous = await previousPendingBridgeAttempt(dataDir);
451
- const mode = await selectBridgePairingMode({ account: opts.account, bootstrap: opts.bootstrap, interactive, previousPending: Boolean(previous) }, async () => {
452
- const input = createInterface({ input: process.stdin, output: process.stderr });
453
- try {
454
- return await input.question('Account email (leave blank only for first-computer private-link setup): ');
455
- }
456
- finally {
457
- input.close();
458
- }
459
- });
460
- if (previous)
461
- await cancelPreviousPendingPairing(opts.apiUrl, previous, AbortSignal.timeout(15000));
462
- if (mode.method === 'pending-v1') {
463
- const enrollment = await preparePendingBridgeEnrollment(dataDir, bridgeId, osHostname());
464
- const lifetime = new AbortController();
465
- const abort = () => lifetime.abort(new Error('Computer approval interrupted.'));
466
- process.once('SIGINT', abort);
467
- process.once('SIGTERM', abort);
468
- try {
469
- const result = await runPairing({ apiUrl: opts.apiUrl, bridgeId, hostname: osHostname(), platform: process.platform,
470
- localPort: local.localPort, localNonce: local.localNonce, accountHint: mode.accountHint,
471
- pending: { identity: enrollment.identity, request: enrollment.request, candidateToken: enrollment.candidateToken,
472
- signal: lifetime.signal, pinOffer: enrollment.pinOffer, pinHello: enrollment.pinHello,
473
- onLocalStatus: state => writePendingBridgeStatus(dataDir, state),
474
- waitForLocalDecision: async (state, signal) => {
475
- if (!interactive || state.phase !== 'comparing')
476
- return waitForPendingBridgeDecision(dataDir, state, signal);
477
- const localAbort = new AbortController();
478
- const combined = AbortSignal.any([signal, localAbort.signal]);
479
- const input = createInterface({ input: process.stdin, output: process.stderr });
480
- const answer = input.question(`Verification code: ${state.comparisonCode}\nDoes it match the code on your trusted browser? [y/N] `, { signal: combined })
481
- .then(value => ({ version: 1, requestId: state.requestId, processNonce: state.processNonce, expiresAt: state.expiresAt,
482
- ...(value.trim().toLowerCase() === 'y' || value.trim().toLowerCase() === 'yes'
483
- ? { decision: 'confirm', offerHash: state.offerHash, comparisonCode: state.comparisonCode }
484
- : { decision: 'reject', offerHash: state.offerHash }) }));
485
- try {
486
- return await Promise.race([answer, waitForPendingBridgeDecision(dataDir, state, combined)]);
487
- }
488
- finally {
489
- localAbort.abort();
490
- input.close();
491
- }
492
- } },
493
- });
494
- if (result.approvalMethod !== 'pending-v1' || !result.verifiedAccountTrust || result.lastServerReadAt === undefined)
495
- throw new Error('account_approval_required');
496
- pendingVerified = result;
497
- return result;
498
- }
499
- finally {
500
- lifetime.abort();
501
- process.removeListener('SIGINT', abort);
502
- process.removeListener('SIGTERM', abort);
503
- }
504
- }
505
- if (!interactive && !opts.bootstrap)
506
- console.log('[Bridge] Private-link setup selected. Use --account <email> to request approval without copying a link.');
507
- pendingEnrollment = await prepareBridgeEnrollment(dataDir, bridgeId, osHostname(), Date.now(), Boolean(previous) || Boolean(opts.bootstrap));
461
+ if (startup.kind === 'exit')
462
+ return fail(startup.message);
463
+ const bound = startup.identity;
464
+ if (updateRestart && (bound.endpoint.id !== updateRestart.endpointId || bound.accountId !== updateRestart.ownerId))
465
+ throw new Error('Update restart identity is unavailable');
466
+ const localIdentity = await loadOrCreateIdentity(dataDir, bound.accountId, 'local-cli');
467
+ const ledgerSecurity = new BridgeSecurityService({ endpoint: bound.endpoint, keyPair: bound.keyPair }, bridgeId, dataDir, {
468
+ relayUserId: bound.accountId, browserOrigin, localOrigin,
469
+ ledger: { store: startup.store, bridgeId, authPublicKey: bound.authPublicKey, fetchLedger: () => server.fetchLedger(bound.accountId) },
470
+ });
471
+ security = ledgerSecurity;
472
+ releaseForRestart = async () => { localApiServer.stop(); await ledgerSecurity.close(); };
473
+ // Subscribed before startLedger: its read may prove the revocation.
474
+ let bridgeRevoked = false;
475
+ ledgerSecurity.onBridgeRevoked(() => { bridgeRevoked = true; void revokedRestart(); });
476
+ await ledgerSecurity.policy.provisionLocalEndpoint(localIdentity.endpoint);
477
+ await ledgerSecurity.startLedger();
478
+ if (bridgeRevoked) {
479
+ await revokedRestart();
480
+ return;
508
481
  }
509
- return runPairing({
510
- apiUrl: opts.apiUrl,
511
- bridgeId,
512
- hostname: osHostname(),
513
- platform: process.platform,
514
- localPort: local.localPort,
515
- localNonce: local.localNonce,
516
- log: (msg) => console.log(msg),
517
- enrollment: pendingEnrollment?.link,
518
- onPairingCode: async (code) => { await savePairingVerificationUrl(dataDir, opts.apiUrl, code); },
482
+ const relay = createAccountRelay({
483
+ apiUrl: opts.apiUrl, accountId: bound.accountId, bridgeId, authPrivateKey: bound.authPrivateKey, security: ledgerSecurity,
484
+ store: startup.store, server, hostname: () => osHostname(), localPort: () => hookPort,
485
+ relayUrlOverride: process.env.FTOWN_CENTRIFUGO_URL,
486
+ // The last relay URL the server named lets a later start come up while the server is unreachable.
487
+ savedRelayUrl: readSavedRelayUrl(dataDir, opts.apiUrl), onRelayUrl: url => saveRelayUrl(dataDir, url),
488
+ reconnect: () => reconnectRelay(),
519
489
  });
520
- };
521
- let auth;
522
- if (opts.local) {
523
- auth = { ownerId: `solo:${bridgeId}`, userId: 'local', token: '', refreshToken: '', centrifugoUrl: 'ws://127.0.0.1/disabled' };
524
- }
525
- else if (solo) {
526
- // Solo mode: identity is synthesized locally (contract S2/S10). No
527
- // refresh token exists; getToken() mints a fresh hub JWT on demand.
528
- // The bridge's own connection token carries `info` (matching the cloud
529
- // minter's shape) so the panel's presence-based bridge list sees it —
530
- // browser tokens from /api/solo/bootstrap|token deliberately omit it.
531
- const token = mintHubJwt({
532
- secret: solo.config.hubSecret,
533
- info: { bridgeId, hostname: osHostname() },
490
+ accountRelay = relay;
491
+ releaseForRestart = async () => { relay.stop(); localApiServer.stop(); await ledgerSecurity.close(); };
492
+ // A browser that enrolled moments ago may not be in this replica yet: its refused request pulls the server ledger (rate-limited),
493
+ // so the browser's retry succeeds without waiting for the relay.
494
+ if (!subscribeApprovalRequired(ledgerSecurity, () => relay.onUnknownEndpoint())) {
495
+ console.warn('[Bridge] This security service has no approval-required signal; new browsers wait for the relay ledger update.');
496
+ }
497
+ // Only this bridge's own ledger endpoint is published, authenticated by its relay token.
498
+ // An active member of its own history may hold the only copy of it: a refusal never makes it exit or advise a reset.
499
+ const holdsHistory = () => !bridgeRevoked && ledgerSecurity.ledgerStatus().own === 'active';
500
+ const registrar = createEndpointRegistrar({
501
+ register: token => registerBridgeEndpoint({ apiUrl: opts.apiUrl, token, endpoint: bound.endpoint, bridgeId, label: osHostname() }),
502
+ tokens: relay.tokens, bridgeRevoked: () => bridgeRevoked, held: holdsHistory, log: line => console.warn(line),
534
503
  });
535
- auth = {
536
- ownerId: `solo:${bridgeId}`,
537
- userId: SOLO_USER_ID,
538
- token,
539
- refreshToken: '',
540
- centrifugoUrl: `ws://127.0.0.1:${solo.config.hubPort}/connection/websocket`,
541
- };
542
- }
543
- else if (persistedRefreshToken) {
544
- console.log('[Bridge] Resuming from stored refresh token...');
545
- try {
546
- auth = await refreshBridgeToken(opts.apiUrl, persistedRefreshToken, bridgeId, local);
504
+ accountRegistrar = registrar;
505
+ // Step 5: a refusal of a computer that holds its own history starts offline (with the held-history notice); any other refusal exits;
506
+ // a temporary failure starts offline (local API, sessions) and keeps retrying in the background.
507
+ const relayStart = await startRelay({ getToken: () => relay.tokens.getToken(), relayUrl: () => relay.relayUrl(),
508
+ bridgeRevoked: () => bridgeRevoked, apiUrl: opts.apiUrl, held: () => holdsHistory() ? heldHistoryNotice(ledgerSecurity.uniqueHistory()) : null });
509
+ if (relayStart.kind === 'revoked') {
510
+ await revokedRestart();
511
+ return;
547
512
  }
548
- catch (err) {
549
- // Only a refusal whose signed history verifies against our own pin resets; anything else keeps today's path.
550
- if (await restartForRefreshError({ mode: runMode, error: err, pinned: readPinnedAccountCheckpoint(securityDir), bridgeId })) {
513
+ if (relayStart.kind === 'exit')
514
+ return fail(relayStart.message);
515
+ if (relayStart.kind === 'online') {
516
+ const registration = await registrar.registerNow(relayStart.token);
517
+ if (registration.kind === 'revoked') {
551
518
  await revokedRestart();
552
519
  return;
553
520
  }
554
- if (updateRestart)
555
- throw new Error('Update restart authentication failed');
556
- console.warn('[Bridge] Stored refresh token rejected, falling back to bootstrap token / pairing:', err instanceof Error ? err.message : String(err));
557
- auth = await onboard();
521
+ if (registration.kind === 'exit')
522
+ return fail(registration.message);
523
+ if (registration.kind === 'retry')
524
+ registrar.retryInBackground();
558
525
  }
559
- }
560
- else {
561
- auth = await onboard();
562
- }
563
- if (boundAtStartup && boundAtStartup.identity.endpoint.ownerId !== auth.ownerId)
564
- throw new Error('wrong_context');
565
- const currentRefreshToken = auth.refreshToken;
566
- let currentBridgeToken = auth.token;
567
- // A virgin enrollment saves its same-point normalized identity/root before credentials.
568
- if (!solo && !opts.local && boundAtStartup)
569
- persistRefreshToken(currentRefreshToken);
570
- const tokenRefresher = solo || opts.local
571
- ? null
572
- : new RotatingTokenRefresher({
573
- initialRefreshToken: currentRefreshToken,
574
- refresh: async (refreshToken) => {
575
- const refreshed = await refreshBridgeToken(opts.apiUrl, refreshToken, bridgeId, local);
576
- currentBridgeToken = refreshed.token;
577
- return refreshed;
578
- },
579
- loadPersistedRefreshToken: () => {
580
- try {
581
- return readFileSync(refreshTokenPath, 'utf8').trim() || undefined;
582
- }
583
- catch {
584
- return undefined;
585
- }
586
- },
587
- persistRefreshToken,
588
- onPersistedTokenRecovery: () => {
589
- console.warn('[Bridge] In-memory refresh token was stale; retrying with the newer persisted token.');
590
- },
591
- });
592
- if (!auth.ownerId)
593
- throw new Error('security_setup_required: bridge auth must provide immutable ownerId');
594
- let identity;
595
- let accountBootstrap = boundAtStartup?.bootstrap;
596
- if (pendingVerified) {
597
- accountBootstrap = pendingVerified.verifiedAccountTrust;
598
- if (accountBootstrap.checkpoint.ownerId !== auth.ownerId)
599
- throw new Error('wrong_context');
600
- identity = await bindPendingBridgeIdentity(dataDir, accountBootstrap);
601
- }
602
- else if (pendingEnrollment) {
603
- if (!auth.accountApproval || !auth.accountHistory)
604
- throw new Error('account_approval_required');
605
- const history = await readAccountHistory(opts.apiUrl, currentBridgeToken);
606
- if (!history)
607
- throw new Error('account_approval_required');
608
- accountBootstrap = await openAccountApproval(pendingEnrollment.identity, pendingEnrollment.link, auth.accountApproval, history);
609
- if (accountBootstrap.checkpoint.ownerId !== auth.ownerId)
610
- throw new Error('wrong_context');
611
- identity = await bindPendingBridgeIdentity(dataDir, accountBootstrap);
612
- }
613
- else {
614
- identity = boundAtStartup?.identity ?? await loadOrCreateIdentity(dataDir, auth.ownerId, 'bridge');
615
- if (identity.endpoint.ownerId !== auth.ownerId)
616
- throw new Error('wrong_context');
617
- }
618
- if (!solo && !opts.local)
619
- persistRefreshToken(currentRefreshToken);
620
- const localIdentity = await loadOrCreateIdentity(dataDir, auth.ownerId, 'local-cli');
621
- const security = new BridgeSecurityService(identity, bridgeId, dataDir, {
622
- relayUserId: auth.userId, relayUrl: auth.centrifugoUrl, browserOrigin: new URL(opts.apiUrl).origin,
623
- localOrigin: `http://127.0.0.1:${hookPort}`,
624
- readAccountHistory: !solo && !opts.local ? signal => readAccountHistory(opts.apiUrl, currentBridgeToken, signal) : undefined,
625
- });
626
- releaseForRestart = async () => { localApiServer.stop(); await security.close(); };
627
- let bridgeRevoked = false;
628
- if (handlesRevocation(runMode))
629
- security.onBridgeRevoked(() => { bridgeRevoked = true; void revokedRestart(); });
630
- await security.policy.provisionLocalEndpoint(localIdentity.endpoint);
631
- await security.startAccount(accountBootstrap);
632
- // The startup read may prove the revocation; stop here instead of registering a revoked endpoint.
633
- if (bridgeRevoked) {
634
- await revokedRestart();
635
- return;
636
- }
637
- if (!solo && !opts.local) {
638
- const registration = await fetch(`${opts.apiUrl.replace(/\/$/, '')}/api/security/endpoints`, { method: 'PUT',
639
- headers: { Authorization: `Bearer ${currentBridgeToken}`, 'Content-Type': 'application/json' },
640
- body: JSON.stringify({ endpoint: identity.endpoint, bridgeId, label: osHostname(), protocolVersion: 1 }) });
641
- if (!registration.ok)
642
- throw new Error('security_setup_required: endpoint directory registration failed');
526
+ else {
527
+ if (relayStart.notice)
528
+ for (const line of relayStart.notice)
529
+ console.warn(line);
530
+ else
531
+ console.warn(`[Bridge] Could not reach the ftown server (${relayStart.reason}). Starting offline with the last relay URL; reconnecting in the background.`);
532
+ registrar.retryInBackground();
533
+ }
534
+ auth = { ownerId: bound.accountId, userId: bound.accountId, token: relayStart.kind === 'online' ? relayStart.token : '',
535
+ centrifugoUrl: relayStart.relayUrl };
643
536
  }
644
537
  const userId = auth.userId;
645
538
  const centrifugoUrl = auth.centrifugoUrl;
@@ -660,25 +553,20 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
660
553
  info: { bridgeId, hostname: osHostname() },
661
554
  });
662
555
  }
663
- // centrifuge-js polls every 5-10 s during a cooldown; stay quiet.
664
- if (tokenRefresher.cooldownRemainingMs() <= 0) {
665
- console.log('[Bridge] Refreshing Centrifugo token...');
666
- }
667
- let token;
556
+ // --local never connects, so it never asks.
557
+ if (!accountRelay)
558
+ throw new Error('No relay in --local mode');
559
+ // centrifuge-js polls every 5-10 s during a cooldown; stay quiet then. A key_revoked answer is evidence for the security
560
+ // service (relay token hooks); only a verified revocation restarts this bridge.
561
+ const quiet = accountRelay.tokens.cooldownRemainingMs() > 0;
668
562
  try {
669
- token = await tokenRefresher.getToken();
563
+ return await accountRelay.connectionToken();
670
564
  }
671
565
  catch (err) {
672
- // The refusal is unverified; the security service checks it against the pinned account state.
673
- if (err instanceof BridgeRevokedError) {
674
- security.confirmRevocationEvidence(err.evidence).catch((confirmErr) => {
675
- console.error('[Bridge] Failed to verify revocation evidence:', confirmErr instanceof Error ? confirmErr.message : String(confirmErr));
676
- });
677
- }
566
+ if (!quiet)
567
+ console.warn('[Bridge] Relay token unavailable:', err instanceof Error ? err.message : String(err));
678
568
  throw err;
679
569
  }
680
- console.log('[Bridge] Token refreshed successfully');
681
- return token;
682
570
  }
683
571
  const store = new SessionStore(dataDir);
684
572
  // Migrate pre-existing session.json/terminal.log/inbox.jsonl off 0644.
@@ -693,7 +581,14 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
693
581
  updateAdmission.freeze();
694
582
  const updateEffects = new UpdateEffects(updateAdmission);
695
583
  let resurrectionComplete = false;
584
+ // Account mode only: the ledger channel's records and (re)subscriptions feed the replica.
585
+ const ledgerRelay = accountRelay;
586
+ const ledgerOptions = ledgerRelay ? {
587
+ onLedgerMessage: (message) => ledgerRelay.onLedgerMessage(message),
588
+ onLedgerSubscribed: () => ledgerRelay.onLedgerSubscribed(),
589
+ } : {};
696
590
  const centrifugo = new CentrifugoClient(centrifugoUrl, auth.token, getToken, {
591
+ ...ledgerOptions,
697
592
  disabled: opts.local,
698
593
  // On a transport reconnect, re-publish the session snapshot. The UI does
699
594
  // not re-request its list on reconnect, so without this its session list
@@ -717,9 +612,11 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
717
612
  },
718
613
  });
719
614
  centrifugo.setSecurity(security);
720
- centrifugo.onSecurityNudge(scope => { if (scope === 'account')
721
- security.requestAccountRefresh(); });
722
- centrifugo.onSecurityNudgeHealth(healthy => security.setAccountPushHealthy(healthy));
615
+ // After a refusal centrifuge-js stops for good; the relay reconnects it once the key is accepted or a ledger is adopted.
616
+ if (ledgerRelay)
617
+ reconnectRelay = () => centrifugo.connect();
618
+ if (ledgerRelay)
619
+ ledgerRelay.setLedgerPublisher(announcement => centrifugo.publishLedgerAnnouncement(userId, announcement));
723
620
  centrifugo.onLocalPublication((channel, data) => localApiServer.recordPublication(channel, data));
724
621
  // Direct transport (WebRTC DataChannel) data plane. Peers attach per session;
725
622
  // terminal output/screen fan out to them directly, and to Centrifugo only when
@@ -833,10 +730,11 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
833
730
  console.log(`[Bridge] Loopback WS rung ready at ws://127.0.0.1:${hookPort}/ws`);
834
731
  }
835
732
  localApiServer.setDependencies(store, runner, centrifugo, userId, terminalManager);
836
- const pushNotifier = opts.local || solo ? undefined : new BridgePushNotifier({
733
+ // Push events authenticate with the relay token (Bearer), reused until it nears expiry.
734
+ const pushNotifier = !ledgerRelay ? undefined : new BridgePushNotifier({
837
735
  apiUrl: opts.apiUrl,
838
736
  bridgeId,
839
- getToken: () => currentBridgeToken,
737
+ getToken: () => ledgerRelay.tokens.getToken(),
840
738
  getSession: (sessionId) => store.loadSession(sessionId),
841
739
  });
842
740
  const attentionStore = new AttentionStore((sessionId) => store.sessionDir(sessionId), 500, pushNotifier ? (sessionId, notice) => pushNotifier.notify(sessionId, notice) : undefined, {
@@ -1060,6 +958,7 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
1060
958
  await createCommandHandler({ bridgeId, security, sessionController, attentionStore, updates, updateEffects, pullRequestStatuses, pullRequestReview,
1061
959
  enqueueSessionMessage: (session, message) => localApiServer.enqueueSessionMessage(session, message),
1062
960
  acceptSessionMail: (session, body) => localApiServer.acceptSessionMail(session, body),
961
+ publishAttentionChanged: (sessionId) => { localApiServer.emit('event', { sessionId, eventName: 'AttentionSnapshot', data: {} }); },
1063
962
  publishCommandResponse: async (response) => { result = response; },
1064
963
  })(message.application.data, message);
1065
964
  return applicationJson({ type: 'command_response', response: result ?? { requestId: route.requestId, success: false, error: 'bridge_error' } });
@@ -1092,8 +991,13 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
1092
991
  if (!updateRestart) {
1093
992
  ready = true;
1094
993
  console.log('[Bridge] Ready and listening for commands');
1095
- if (bridgePointerPublished)
1096
- console.log('[Bridge] Encrypted endpoint ready. Run ftown security invite to pair a browser.');
994
+ if (bridgePointerPublished) {
995
+ // Invitations are for Solo and --local only; an account bridge trusts the browsers its passkey ledger lists.
996
+ if (opts.local || opts.solo)
997
+ console.log('[Bridge] Encrypted endpoint ready. Run ftown security invite to pair a browser.');
998
+ else
999
+ console.log('[Bridge] Encrypted endpoint ready. Browsers signed in to your account with a passkey can reach this computer.');
1000
+ }
1097
1001
  }
1098
1002
  try {
1099
1003
  await resurrection.resurrectSessions();
@@ -1128,6 +1032,8 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
1128
1032
  attentionStore.stopNotifications();
1129
1033
  stopPushSessionObserver();
1130
1034
  pushNotifier?.stop();
1035
+ ledgerRelay?.stop();
1036
+ accountRegistrar?.stop();
1131
1037
  // Solo L2 ordering: stop accepting first, then children (panel before
1132
1038
  // hub), then the rest of the bridge teardown.
1133
1039
  if (solo) {