ftown 0.20.9 → 0.21.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 (118) hide show
  1. package/dist/centrifugo-client.d.ts +15 -11
  2. package/dist/centrifugo-client.js +63 -70
  3. package/dist/centrifugo-client.js.map +1 -1
  4. package/dist/cli-client.d.ts +11 -1
  5. package/dist/cli-client.js +23 -3
  6. package/dist/cli-client.js.map +1 -1
  7. package/dist/command-rpc.js +4 -8
  8. package/dist/command-rpc.js.map +1 -1
  9. package/dist/identity-reset.d.ts +1 -0
  10. package/dist/identity-reset.js +31 -5
  11. package/dist/identity-reset.js.map +1 -1
  12. package/dist/index.js +184 -288
  13. package/dist/index.js.map +1 -1
  14. package/dist/ledger-pairing-client.d.ts +28 -0
  15. package/dist/ledger-pairing-client.js +152 -0
  16. package/dist/ledger-pairing-client.js.map +1 -0
  17. package/dist/ledger-relay.d.ts +124 -0
  18. package/dist/ledger-relay.js +453 -0
  19. package/dist/ledger-relay.js.map +1 -0
  20. package/dist/ledger-startup.d.ts +151 -0
  21. package/dist/ledger-startup.js +203 -0
  22. package/dist/ledger-startup.js.map +1 -0
  23. package/dist/relay-token.d.ts +39 -0
  24. package/dist/relay-token.js +130 -0
  25. package/dist/relay-token.js.map +1 -0
  26. package/dist/revocation-gating.d.ts +0 -13
  27. package/dist/revocation-gating.js +0 -16
  28. package/dist/revocation-gating.js.map +1 -1
  29. package/dist/security/bridge/identity.d.ts +2 -47
  30. package/dist/security/bridge/identity.js +3 -3
  31. package/dist/security/bridge/identity.js.map +1 -1
  32. package/dist/security/bridge/ledger-identity.d.ts +48 -0
  33. package/dist/security/bridge/ledger-identity.js +342 -0
  34. package/dist/security/bridge/ledger-identity.js.map +1 -0
  35. package/dist/security/bridge/ledger-store.d.ts +84 -0
  36. package/dist/security/bridge/ledger-store.js +485 -0
  37. package/dist/security/bridge/ledger-store.js.map +1 -0
  38. package/dist/security/bridge/metadata.d.ts +2 -2
  39. package/dist/security/bridge/permissions.js +2 -2
  40. package/dist/security/bridge/permissions.js.map +1 -1
  41. package/dist/security/bridge/policy.d.ts +42 -23
  42. package/dist/security/bridge/policy.js +214 -146
  43. package/dist/security/bridge/policy.js.map +1 -1
  44. package/dist/security/bridge/security.d.ts +84 -33
  45. package/dist/security/bridge/security.js +240 -166
  46. package/dist/security/bridge/security.js.map +1 -1
  47. package/dist/security/crypto.js +1 -1
  48. package/dist/security/crypto.js.map +1 -1
  49. package/dist/security/ledger/channels.d.ts +5 -0
  50. package/dist/security/ledger/channels.js +21 -0
  51. package/dist/security/ledger/channels.js.map +1 -0
  52. package/dist/security/ledger/ledger.d.ts +61 -0
  53. package/dist/security/ledger/ledger.js +597 -0
  54. package/dist/security/ledger/ledger.js.map +1 -0
  55. package/dist/security/ledger/pairing.d.ts +81 -0
  56. package/dist/security/ledger/pairing.js +414 -0
  57. package/dist/security/ledger/pairing.js.map +1 -0
  58. package/dist/security/ledger/proof.d.ts +24 -0
  59. package/dist/security/ledger/proof.js +161 -0
  60. package/dist/security/ledger/proof.js.map +1 -0
  61. package/dist/security/ledger/protocol.d.ts +100 -0
  62. package/dist/security/ledger/protocol.js +16 -0
  63. package/dist/security/ledger/protocol.js.map +1 -0
  64. package/dist/security/ledger/testing.d.ts +27 -0
  65. package/dist/security/ledger/testing.js +70 -0
  66. package/dist/security/ledger/testing.js.map +1 -0
  67. package/dist/security/ledger/webauthn.d.ts +12 -0
  68. package/dist/security/ledger/webauthn.js +147 -0
  69. package/dist/security/ledger/webauthn.js.map +1 -0
  70. package/dist/security/protocol.d.ts +5 -10
  71. package/dist/security-cli.d.ts +5 -0
  72. package/dist/security-cli.js +28 -16
  73. package/dist/security-cli.js.map +1 -1
  74. package/dist/token-provider.d.ts +39 -0
  75. package/dist/token-provider.js +101 -0
  76. package/dist/token-provider.js.map +1 -0
  77. package/dist/types.d.ts +1 -1
  78. package/package.json +2 -2
  79. package/skills/ftown/references/sessions.md +3 -2
  80. package/dist/bridge-auth.d.ts +0 -31
  81. package/dist/bridge-auth.js +0 -70
  82. package/dist/bridge-auth.js.map +0 -1
  83. package/dist/pairing-client.d.ts +0 -62
  84. package/dist/pairing-client.js +0 -469
  85. package/dist/pairing-client.js.map +0 -1
  86. package/dist/rotating-token-refresher.d.ts +0 -42
  87. package/dist/rotating-token-refresher.js +0 -91
  88. package/dist/rotating-token-refresher.js.map +0 -1
  89. package/dist/security/account-crypto.d.ts +0 -33
  90. package/dist/security/account-crypto.js +0 -649
  91. package/dist/security/account-crypto.js.map +0 -1
  92. package/dist/security/account-protocol.d.ts +0 -228
  93. package/dist/security/account-protocol.js +0 -2
  94. package/dist/security/account-protocol.js.map +0 -1
  95. package/dist/security/bridge/account-client.d.ts +0 -7
  96. package/dist/security/bridge/account-client.js +0 -101
  97. package/dist/security/bridge/account-client.js.map +0 -1
  98. package/dist/security/bridge/account-state.d.ts +0 -105
  99. package/dist/security/bridge/account-state.js +0 -79
  100. package/dist/security/bridge/account-state.js.map +0 -1
  101. package/dist/security/bridge/pending-approval.d.ts +0 -21
  102. package/dist/security/bridge/pending-approval.js +0 -80
  103. package/dist/security/bridge/pending-approval.js.map +0 -1
  104. package/dist/security/bridge/pending-enrollment.d.ts +0 -33
  105. package/dist/security/bridge/pending-enrollment.js +0 -177
  106. package/dist/security/bridge/pending-enrollment.js.map +0 -1
  107. package/dist/security/bridge/revocation.d.ts +0 -14
  108. package/dist/security/bridge/revocation.js +0 -40
  109. package/dist/security/bridge/revocation.js.map +0 -1
  110. package/dist/security/pending-approval-crypto.d.ts +0 -29
  111. package/dist/security/pending-approval-crypto.js +0 -716
  112. package/dist/security/pending-approval-crypto.js.map +0 -1
  113. package/dist/security/pending-approval-protocol.d.ts +0 -329
  114. package/dist/security/pending-approval-protocol.js +0 -2
  115. package/dist/security/pending-approval-protocol.js.map +0 -1
  116. package/dist/security/security-nudge.d.ts +0 -31
  117. package/dist/security/security-nudge.js +0 -98
  118. 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, 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,136 @@ 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
+ const registrar = createEndpointRegistrar({
499
+ register: token => registerBridgeEndpoint({ apiUrl: opts.apiUrl, token, endpoint: bound.endpoint, bridgeId, label: osHostname() }),
500
+ tokens: relay.tokens, bridgeRevoked: () => bridgeRevoked, log: line => console.warn(line),
534
501
  });
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);
502
+ accountRegistrar = registrar;
503
+ // Step 5: a refusal exits; a temporary failure starts offline (local API, sessions) and keeps retrying in the background.
504
+ const relayStart = await startRelay({ getToken: () => relay.tokens.getToken(), relayUrl: () => relay.relayUrl(),
505
+ bridgeRevoked: () => bridgeRevoked, apiUrl: opts.apiUrl });
506
+ if (relayStart.kind === 'revoked') {
507
+ await revokedRestart();
508
+ return;
547
509
  }
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 })) {
510
+ if (relayStart.kind === 'exit')
511
+ return fail(relayStart.message);
512
+ if (relayStart.kind === 'online') {
513
+ const registration = await registrar.registerNow(relayStart.token);
514
+ if (registration.kind === 'revoked') {
551
515
  await revokedRestart();
552
516
  return;
553
517
  }
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();
518
+ if (registration.kind === 'exit')
519
+ return fail(registration.message);
520
+ if (registration.kind === 'retry')
521
+ registrar.retryInBackground();
558
522
  }
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');
523
+ else {
524
+ console.warn(`[Bridge] Could not reach the ftown server (${relayStart.reason}). Starting offline with the last relay URL; reconnecting in the background.`);
525
+ registrar.retryInBackground();
526
+ }
527
+ auth = { ownerId: bound.accountId, userId: bound.accountId, token: relayStart.kind === 'online' ? relayStart.token : '',
528
+ centrifugoUrl: relayStart.relayUrl };
643
529
  }
644
530
  const userId = auth.userId;
645
531
  const centrifugoUrl = auth.centrifugoUrl;
@@ -660,25 +546,20 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
660
546
  info: { bridgeId, hostname: osHostname() },
661
547
  });
662
548
  }
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;
549
+ // --local never connects, so it never asks.
550
+ if (!accountRelay)
551
+ throw new Error('No relay in --local mode');
552
+ // centrifuge-js polls every 5-10 s during a cooldown; stay quiet then. A key_revoked answer is evidence for the security
553
+ // service (relay token hooks); only a verified revocation restarts this bridge.
554
+ const quiet = accountRelay.tokens.cooldownRemainingMs() > 0;
668
555
  try {
669
- token = await tokenRefresher.getToken();
556
+ return await accountRelay.connectionToken();
670
557
  }
671
558
  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
- }
559
+ if (!quiet)
560
+ console.warn('[Bridge] Relay token unavailable:', err instanceof Error ? err.message : String(err));
678
561
  throw err;
679
562
  }
680
- console.log('[Bridge] Token refreshed successfully');
681
- return token;
682
563
  }
683
564
  const store = new SessionStore(dataDir);
684
565
  // Migrate pre-existing session.json/terminal.log/inbox.jsonl off 0644.
@@ -693,7 +574,14 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
693
574
  updateAdmission.freeze();
694
575
  const updateEffects = new UpdateEffects(updateAdmission);
695
576
  let resurrectionComplete = false;
577
+ // Account mode only: the ledger channel's records and (re)subscriptions feed the replica.
578
+ const ledgerRelay = accountRelay;
579
+ const ledgerOptions = ledgerRelay ? {
580
+ onLedgerMessage: (message) => ledgerRelay.onLedgerMessage(message),
581
+ onLedgerSubscribed: () => ledgerRelay.onLedgerSubscribed(),
582
+ } : {};
696
583
  const centrifugo = new CentrifugoClient(centrifugoUrl, auth.token, getToken, {
584
+ ...ledgerOptions,
697
585
  disabled: opts.local,
698
586
  // On a transport reconnect, re-publish the session snapshot. The UI does
699
587
  // not re-request its list on reconnect, so without this its session list
@@ -717,9 +605,9 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
717
605
  },
718
606
  });
719
607
  centrifugo.setSecurity(security);
720
- centrifugo.onSecurityNudge(scope => { if (scope === 'account')
721
- security.requestAccountRefresh(); });
722
- centrifugo.onSecurityNudgeHealth(healthy => security.setAccountPushHealthy(healthy));
608
+ // After a refusal centrifuge-js stops for good; the relay reconnects it once the key is accepted or a ledger is adopted.
609
+ if (ledgerRelay)
610
+ reconnectRelay = () => centrifugo.connect();
723
611
  centrifugo.onLocalPublication((channel, data) => localApiServer.recordPublication(channel, data));
724
612
  // Direct transport (WebRTC DataChannel) data plane. Peers attach per session;
725
613
  // terminal output/screen fan out to them directly, and to Centrifugo only when
@@ -833,10 +721,11 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
833
721
  console.log(`[Bridge] Loopback WS rung ready at ws://127.0.0.1:${hookPort}/ws`);
834
722
  }
835
723
  localApiServer.setDependencies(store, runner, centrifugo, userId, terminalManager);
836
- const pushNotifier = opts.local || solo ? undefined : new BridgePushNotifier({
724
+ // Push events authenticate with the relay token (Bearer), reused until it nears expiry.
725
+ const pushNotifier = !ledgerRelay ? undefined : new BridgePushNotifier({
837
726
  apiUrl: opts.apiUrl,
838
727
  bridgeId,
839
- getToken: () => currentBridgeToken,
728
+ getToken: () => ledgerRelay.tokens.getToken(),
840
729
  getSession: (sessionId) => store.loadSession(sessionId),
841
730
  });
842
731
  const attentionStore = new AttentionStore((sessionId) => store.sessionDir(sessionId), 500, pushNotifier ? (sessionId, notice) => pushNotifier.notify(sessionId, notice) : undefined, {
@@ -1092,8 +981,13 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
1092
981
  if (!updateRestart) {
1093
982
  ready = true;
1094
983
  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.');
984
+ if (bridgePointerPublished) {
985
+ // Invitations are for Solo and --local only; an account bridge trusts the browsers its passkey ledger lists.
986
+ if (opts.local || opts.solo)
987
+ console.log('[Bridge] Encrypted endpoint ready. Run ftown security invite to pair a browser.');
988
+ else
989
+ console.log('[Bridge] Encrypted endpoint ready. Browsers signed in to your account with a passkey can reach this computer.');
990
+ }
1097
991
  }
1098
992
  try {
1099
993
  await resurrection.resurrectSessions();
@@ -1128,6 +1022,8 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
1128
1022
  attentionStore.stopNotifications();
1129
1023
  stopPushSessionObserver();
1130
1024
  pushNotifier?.stop();
1025
+ ledgerRelay?.stop();
1026
+ accountRegistrar?.stop();
1131
1027
  // Solo L2 ordering: stop accepting first, then children (panel before
1132
1028
  // hub), then the rest of the bridge teardown.
1133
1029
  if (solo) {