ftown 0.20.8 → 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 +11 -0
  10. package/dist/identity-reset.js +192 -0
  11. package/dist/identity-reset.js.map +1 -0
  12. package/dist/index.js +276 -279
  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 +15 -0
  27. package/dist/revocation-gating.js +16 -0
  28. package/dist/revocation-gating.js.map +1 -0
  29. package/dist/revoked-restart.d.ts +21 -0
  30. package/dist/revoked-restart.js +55 -0
  31. package/dist/revoked-restart.js.map +1 -0
  32. package/dist/security/bridge/identity.d.ts +2 -47
  33. package/dist/security/bridge/identity.js +3 -3
  34. package/dist/security/bridge/identity.js.map +1 -1
  35. package/dist/security/bridge/ledger-identity.d.ts +48 -0
  36. package/dist/security/bridge/ledger-identity.js +342 -0
  37. package/dist/security/bridge/ledger-identity.js.map +1 -0
  38. package/dist/security/bridge/ledger-store.d.ts +84 -0
  39. package/dist/security/bridge/ledger-store.js +485 -0
  40. package/dist/security/bridge/ledger-store.js.map +1 -0
  41. package/dist/security/bridge/metadata.d.ts +2 -2
  42. package/dist/security/bridge/permissions.js +2 -2
  43. package/dist/security/bridge/permissions.js.map +1 -1
  44. package/dist/security/bridge/policy.d.ts +42 -21
  45. package/dist/security/bridge/policy.js +217 -135
  46. package/dist/security/bridge/policy.js.map +1 -1
  47. package/dist/security/bridge/security.d.ts +91 -28
  48. package/dist/security/bridge/security.js +274 -117
  49. package/dist/security/bridge/security.js.map +1 -1
  50. package/dist/security/crypto.js +1 -1
  51. package/dist/security/crypto.js.map +1 -1
  52. package/dist/security/ledger/channels.d.ts +5 -0
  53. package/dist/security/ledger/channels.js +21 -0
  54. package/dist/security/ledger/channels.js.map +1 -0
  55. package/dist/security/ledger/ledger.d.ts +61 -0
  56. package/dist/security/ledger/ledger.js +597 -0
  57. package/dist/security/ledger/ledger.js.map +1 -0
  58. package/dist/security/ledger/pairing.d.ts +81 -0
  59. package/dist/security/ledger/pairing.js +414 -0
  60. package/dist/security/ledger/pairing.js.map +1 -0
  61. package/dist/security/ledger/proof.d.ts +24 -0
  62. package/dist/security/ledger/proof.js +161 -0
  63. package/dist/security/ledger/proof.js.map +1 -0
  64. package/dist/security/ledger/protocol.d.ts +100 -0
  65. package/dist/security/ledger/protocol.js +16 -0
  66. package/dist/security/ledger/protocol.js.map +1 -0
  67. package/dist/security/ledger/testing.d.ts +27 -0
  68. package/dist/security/ledger/testing.js +70 -0
  69. package/dist/security/ledger/testing.js.map +1 -0
  70. package/dist/security/ledger/webauthn.d.ts +12 -0
  71. package/dist/security/ledger/webauthn.js +147 -0
  72. package/dist/security/ledger/webauthn.js.map +1 -0
  73. package/dist/security/protocol.d.ts +5 -10
  74. package/dist/security-cli.d.ts +5 -0
  75. package/dist/security-cli.js +28 -16
  76. package/dist/security-cli.js.map +1 -1
  77. package/dist/token-provider.d.ts +39 -0
  78. package/dist/token-provider.js +101 -0
  79. package/dist/token-provider.js.map +1 -0
  80. package/dist/types.d.ts +1 -1
  81. package/package.json +2 -2
  82. package/skills/ftown/references/sessions.md +4 -0
  83. package/dist/bridge-auth.d.ts +0 -25
  84. package/dist/bridge-auth.js +0 -56
  85. package/dist/bridge-auth.js.map +0 -1
  86. package/dist/pairing-client.d.ts +0 -60
  87. package/dist/pairing-client.js +0 -460
  88. package/dist/pairing-client.js.map +0 -1
  89. package/dist/rotating-token-refresher.d.ts +0 -42
  90. package/dist/rotating-token-refresher.js +0 -91
  91. package/dist/rotating-token-refresher.js.map +0 -1
  92. package/dist/security/account-crypto.d.ts +0 -33
  93. package/dist/security/account-crypto.js +0 -649
  94. package/dist/security/account-crypto.js.map +0 -1
  95. package/dist/security/account-protocol.d.ts +0 -228
  96. package/dist/security/account-protocol.js +0 -2
  97. package/dist/security/account-protocol.js.map +0 -1
  98. package/dist/security/bridge/account-client.d.ts +0 -7
  99. package/dist/security/bridge/account-client.js +0 -87
  100. package/dist/security/bridge/account-client.js.map +0 -1
  101. package/dist/security/bridge/account-state.d.ts +0 -105
  102. package/dist/security/bridge/account-state.js +0 -79
  103. package/dist/security/bridge/account-state.js.map +0 -1
  104. package/dist/security/bridge/pending-approval.d.ts +0 -21
  105. package/dist/security/bridge/pending-approval.js +0 -80
  106. package/dist/security/bridge/pending-approval.js.map +0 -1
  107. package/dist/security/bridge/pending-enrollment.d.ts +0 -33
  108. package/dist/security/bridge/pending-enrollment.js +0 -177
  109. package/dist/security/bridge/pending-enrollment.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,10 +26,7 @@ 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
31
  import { createCommandHandler } from './command-rpc.js';
36
32
  import { PullRequestStatusService } from './pull-request-status.js';
@@ -42,11 +38,13 @@ import { TerminalPump } from './terminal-pump.js';
42
38
  import { collectSessionUsage } from './usage-collector.js';
43
39
  import { AgentSessionIdPersister } from './session-ids.js';
44
40
  import { HookUsagePersister } from './hook-usage.js';
45
- import { fetchBridgeToken, refreshBridgeToken } from './bridge-auth.js';
46
- import { RotatingTokenRefresher } from './rotating-token-refresher.js';
41
+ import { createRevokedRestart } from './revoked-restart.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';
47
46
  import { resolveDefaultDataDir, resolveFtownHome } from './ftown-home.js';
48
47
  import { SessionController } from './session-controller.js';
49
- import { cancelPreviousPendingPairing, runPairing } from './pairing-client.js';
50
48
  import { createServer } from 'node:http';
51
49
  import { generateAccessKey, mintHubJwt, sha256Hex } from './solo/solo-auth.js';
52
50
  import { DEFAULT_SOLO_PORT, SOLO_USER_ID } from './solo/contract.js';
@@ -98,19 +96,15 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
98
96
  .option('--solo', 'Single-port LAN deployment: no account service, managed hub + panel children, key-based auth')
99
97
  .option('--port <port>', 'Public port for --solo front (default: see DEFAULT_SOLO_PORT)')
100
98
  .option('--rotate-key', 'With --solo: regenerate the access key, print the new banner, exit (offline)')
101
- .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.')
102
- .option('--account <email>', 'Request approval in this account’s trusted-device inbox; compare and confirm on both devices')
103
- .option('--bootstrap', 'Explicit first-computer setup using a private approval link')
104
99
  .option('--api-url <url>', 'ftown UI API URL', DEFAULT_API_URL)
105
100
  .option('--data-dir <path>', 'Directory for session data (default: ~/.ftown/data)')
106
101
  .option('--bridge-id <id>', 'Bridge instance ID (default: persisted per data dir)')
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)')
107
103
  .action(async (opts) => {
108
104
  if (opts.local && opts.solo)
109
105
  program.error('--local and --solo cannot be combined');
110
- if (opts.account !== undefined && opts.bootstrap)
111
- program.error('--account and --bootstrap cannot be combined');
112
- if ((opts.local || opts.solo) && (opts.account !== undefined || opts.bootstrap))
113
- program.error('--account and --bootstrap require cloud mode');
106
+ if ((opts.local || opts.solo) && opts.newIdentity)
107
+ program.error('--new-identity requires cloud mode');
114
108
  const apiUrl = new URL(opts.apiUrl);
115
109
  const isLocalHost = apiUrl.hostname === 'localhost' ||
116
110
  apiUrl.hostname === '127.0.0.1' ||
@@ -121,16 +115,54 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
121
115
  'Bridge tokens would otherwise cross the network unencrypted. ' +
122
116
  'Set FTOWN_ALLOW_INSECURE_API=1 to override for trusted LAN setups.');
123
117
  }
124
- // Restart context is authenticated by the private helper channel before any mutation.
125
- assertUpdateReservationAllowed(homedir());
126
- const updateRestart = readUpdateRestartContext();
127
- if (updateRestart && (updateRestart.targetVersion !== BRIDGE_VERSION || opts.token || opts.account || opts.bootstrap || opts.solo))
128
- throw new Error('Invalid update restart context');
129
118
  // Explicit setup must finish before any migration, identity, network or auth side effect.
130
119
  assertSetupReady(BRIDGE_VERSION);
131
120
  // Read-only default resolution; explicit setup owns legacy data migration.
132
121
  const defaultDataDir = resolveDefaultDataDir();
133
122
  const dataDir = opts.dataDir ? resolve(opts.dataDir) : defaultDataDir;
123
+ // No bridge starts, and no identity is reset, while an update holds the reservation.
124
+ assertUpdateReservationAllowed(homedir());
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;
145
+ let updateRestart = null;
146
+ if (identityReset) {
147
+ console.log(`[Bridge] New identity for this computer: ${identityReset.oldBridgeId ?? 'unknown'} -> ${identityReset.newBridgeId}`);
148
+ if (opts.bridgeId !== undefined) {
149
+ console.warn('[Bridge] Ignoring --bridge-id: this computer has a new identity.');
150
+ opts.bridgeId = undefined;
151
+ }
152
+ // A reset bridge is a fresh install; an update context names the identity it just discarded.
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));
155
+ }
156
+ else if (launchContext)
157
+ console.warn('[Bridge] Ignoring the update restart context: this computer has a new identity.');
158
+ }
159
+ else {
160
+ if (launchContextError !== null)
161
+ throw launchContextError;
162
+ updateRestart = launchContext;
163
+ if (updateRestart && (updateRestart.targetVersion !== BRIDGE_VERSION || opts.solo))
164
+ throw new Error('Invalid update restart context');
165
+ }
134
166
  // Every child/local CLI must resolve this exact instance, including custom --data-dir bridges.
135
167
  process.env.FTOWN_DATA_DIR = dataDir;
136
168
  // Instance-scoped ".ftown home" for pointer and registry files. The DEFAULT
@@ -166,6 +198,16 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
166
198
  catch (err) {
167
199
  console.error('[Bridge] Failed to persist bridge id:', err instanceof Error ? err.message : String(err));
168
200
  }
201
+ // Signed revocation of this bridgeId restarts the process as a fresh install, at most once.
202
+ // The re-exec keeps this PID, so whatever startup has acquired so far is released first.
203
+ let releaseForRestart = async () => { };
204
+ // Mirrors the update coordinator's receipts; an active operation holds the reservation under this PID.
205
+ let updatePhase = null;
206
+ const revokedRestart = createRevokedRestart({
207
+ dataDir, scriptPath: process.argv[1], argv: process.argv.slice(2),
208
+ shutdown: () => releaseForRestart(), log: message => console.log(message), exit: code => process.exit(code),
209
+ canReexec: () => canReexecAfterRevocation({ updateRestart: updateRestart !== null, updatePhase }),
210
+ });
169
211
  // L4: --rotate-key short-circuits BEFORE any listener binds (exit 0,
170
212
  // nothing started). Without --solo it is an argument error.
171
213
  if (opts.rotateKey && !opts.solo) {
@@ -208,6 +250,7 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
208
250
  localApiServer.stop();
209
251
  throw new Error('Update restart endpoint is unavailable');
210
252
  }
253
+ releaseForRestart = async () => { localApiServer.stop(); };
211
254
  writeFileSync(localPortPath, `${hookPort}\n`, { mode: 0o600 });
212
255
  console.log(`[Bridge] Local API server started on port ${hookPort}`);
213
256
  const localNonce = randomBytes(16).toString('hex');
@@ -353,225 +396,136 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
353
396
  };
354
397
  }
355
398
  // ----- END SOLO MODE -----
356
- // The bootstrap token (--token) is single-use and short-lived (F1). Once a
357
- // bridge has onboarded it persists its rotating refresh token (F3) and
358
- // resumes from that on restart, so the bootstrap token is never reused.
359
- const local = { localPort: hookPort, localNonce };
360
- const refreshTokenPath = join(dataDir, 'refresh-token');
361
- let persistedRefreshToken;
362
- try {
363
- persistedRefreshToken = readFileSync(refreshTokenPath, 'utf8').trim() || undefined;
364
- }
365
- catch {
366
- persistedRefreshToken = undefined;
367
- }
368
- const persistRefreshToken = (token) => {
369
- try {
370
- writeFileSync(refreshTokenPath, `${token}\n`, { mode: 0o600 });
371
- }
372
- catch (err) {
373
- console.error('[Bridge] Failed to persist refresh token:', err instanceof Error ? err.message : String(err));
374
- }
375
- };
376
- // Onboarding precedence (P7): stored refresh token → else --token exchange →
377
- // else interactive device pairing. Pairing yields the SAME token bundle shape
378
- // as /api/auth/bridge, so all three paths converge on `auth` and the identical
379
- // refresh-token persist/resume logic below.
380
- const boundAtStartup = await readBoundBridgeIdentity(dataDir);
381
- if (updateRestart && (!boundAtStartup || boundAtStartup.identity.endpoint.id !== updateRestart.endpointId ||
382
- boundAtStartup.identity.endpoint.ownerId !== updateRestart.ownerId || (!opts.local && !persistedRefreshToken)))
383
- throw new Error('Update restart identity is unavailable');
384
- let pendingEnrollment;
385
- let pendingVerified;
386
- const onboard = async () => {
387
- if (updateRestart)
388
- throw new Error('Update restart requires existing authentication');
389
- if (opts.token) {
390
- console.log('[Bridge] Authenticating with API...');
391
- return fetchBridgeToken(opts.apiUrl, opts.token, bridgeId, local);
392
- }
393
- console.log('[Bridge] No bootstrap token or stored session — starting device pairing...');
394
- if (!boundAtStartup) {
395
- const interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY);
396
- const previous = await previousPendingBridgeAttempt(dataDir);
397
- const mode = await selectBridgePairingMode({ account: opts.account, bootstrap: opts.bootstrap, interactive, previousPending: Boolean(previous) }, async () => {
398
- const input = createInterface({ input: process.stdin, output: process.stderr });
399
- try {
400
- return await input.question('Account email (leave blank only for first-computer private-link setup): ');
401
- }
402
- finally {
403
- input.close();
404
- }
405
- });
406
- if (previous)
407
- await cancelPreviousPendingPairing(opts.apiUrl, previous, AbortSignal.timeout(15000));
408
- if (mode.method === 'pending-v1') {
409
- const enrollment = await preparePendingBridgeEnrollment(dataDir, bridgeId, osHostname());
410
- const lifetime = new AbortController();
411
- const abort = () => lifetime.abort(new Error('Computer approval interrupted.'));
412
- process.once('SIGINT', abort);
413
- process.once('SIGTERM', abort);
414
- try {
415
- const result = await runPairing({ apiUrl: opts.apiUrl, bridgeId, hostname: osHostname(), platform: process.platform,
416
- localPort: local.localPort, localNonce: local.localNonce, accountHint: mode.accountHint,
417
- pending: { identity: enrollment.identity, request: enrollment.request, candidateToken: enrollment.candidateToken,
418
- signal: lifetime.signal, pinOffer: enrollment.pinOffer, pinHello: enrollment.pinHello,
419
- onLocalStatus: state => writePendingBridgeStatus(dataDir, state),
420
- waitForLocalDecision: async (state, signal) => {
421
- if (!interactive || state.phase !== 'comparing')
422
- return waitForPendingBridgeDecision(dataDir, state, signal);
423
- const localAbort = new AbortController();
424
- const combined = AbortSignal.any([signal, localAbort.signal]);
425
- const input = createInterface({ input: process.stdin, output: process.stderr });
426
- const answer = input.question(`Verification code: ${state.comparisonCode}\nDoes it match the code on your trusted browser? [y/N] `, { signal: combined })
427
- .then(value => ({ version: 1, requestId: state.requestId, processNonce: state.processNonce, expiresAt: state.expiresAt,
428
- ...(value.trim().toLowerCase() === 'y' || value.trim().toLowerCase() === 'yes'
429
- ? { decision: 'confirm', offerHash: state.offerHash, comparisonCode: state.comparisonCode }
430
- : { decision: 'reject', offerHash: state.offerHash }) }));
431
- try {
432
- return await Promise.race([answer, waitForPendingBridgeDecision(dataDir, state, combined)]);
433
- }
434
- finally {
435
- localAbort.abort();
436
- input.close();
437
- }
438
- } },
439
- });
440
- if (result.approvalMethod !== 'pending-v1' || !result.verifiedAccountTrust || result.lastServerReadAt === undefined)
441
- throw new Error('account_approval_required');
442
- pendingVerified = result;
443
- return result;
444
- }
445
- finally {
446
- lifetime.abort();
447
- process.removeListener('SIGINT', abort);
448
- process.removeListener('SIGTERM', abort);
449
- }
450
- }
451
- if (!interactive && !opts.bootstrap)
452
- console.log('[Bridge] Private-link setup selected. Use --account <email> to request approval without copying a link.');
453
- pendingEnrollment = await prepareBridgeEnrollment(dataDir, bridgeId, osHostname(), Date.now(), Boolean(previous) || Boolean(opts.bootstrap));
454
- }
455
- return runPairing({
456
- apiUrl: opts.apiUrl,
457
- bridgeId,
458
- hostname: osHostname(),
459
- platform: process.platform,
460
- localPort: local.localPort,
461
- localNonce: local.localNonce,
462
- log: (msg) => console.log(msg),
463
- enrollment: pendingEnrollment?.link,
464
- onPairingCode: async (code) => { await savePairingVerificationUrl(dataDir, opts.apiUrl, code); },
465
- });
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);
466
406
  };
407
+ /** The relay identity of this bridge: account mode uses the accountId (relay JWT `sub`), never an email. */
467
408
  let auth;
468
- if (opts.local) {
469
- auth = { ownerId: `solo:${bridgeId}`, userId: 'local', token: '', refreshToken: '', centrifugoUrl: 'ws://127.0.0.1/disabled' };
470
- }
471
- else if (solo) {
472
- // Solo mode: identity is synthesized locally (contract S2/S10). No
473
- // refresh token exists; getToken() mints a fresh hub JWT on demand.
474
- // The bridge's own connection token carries `info` (matching the cloud
475
- // minter's shape) so the panel's presence-based bridge list sees it —
476
- // browser tokens from /api/solo/bootstrap|token deliberately omit it.
477
- const token = mintHubJwt({
478
- secret: solo.config.hubSecret,
479
- info: { bridgeId, hostname: osHostname() },
480
- });
481
- auth = {
482
- ownerId: `solo:${bridgeId}`,
483
- userId: SOLO_USER_ID,
484
- token,
485
- refreshToken: '',
486
- centrifugoUrl: `ws://127.0.0.1:${solo.config.hubPort}/connection/websocket`,
487
- };
488
- }
489
- else if (persistedRefreshToken) {
490
- console.log('[Bridge] Resuming from stored refresh token...');
491
- try {
492
- auth = await refreshBridgeToken(opts.apiUrl, persistedRefreshToken, bridgeId, local);
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' };
493
422
  }
494
- catch (err) {
495
- if (updateRestart)
496
- throw new Error('Update restart authentication failed');
497
- console.warn('[Bridge] Stored refresh token rejected, falling back to bootstrap token / pairing:', err instanceof Error ? err.message : String(err));
498
- auth = await onboard();
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
+ };
499
434
  }
500
- }
501
- else {
502
- auth = await onboard();
503
- }
504
- if (boundAtStartup && boundAtStartup.identity.endpoint.ownerId !== auth.ownerId)
505
- throw new Error('wrong_context');
506
- const currentRefreshToken = auth.refreshToken;
507
- let currentBridgeToken = auth.token;
508
- // A virgin enrollment saves its same-point normalized identity/root before credentials.
509
- if (!solo && !opts.local && boundAtStartup)
510
- persistRefreshToken(currentRefreshToken);
511
- const tokenRefresher = solo || opts.local
512
- ? null
513
- : new RotatingTokenRefresher({
514
- initialRefreshToken: currentRefreshToken,
515
- refresh: async (refreshToken) => {
516
- const refreshed = await refreshBridgeToken(opts.apiUrl, refreshToken, bridgeId, local);
517
- currentBridgeToken = refreshed.token;
518
- return refreshed;
519
- },
520
- loadPersistedRefreshToken: () => {
521
- try {
522
- return readFileSync(refreshTokenPath, 'utf8').trim() || undefined;
523
- }
524
- catch {
525
- return undefined;
526
- }
527
- },
528
- persistRefreshToken,
529
- onPersistedTokenRecovery: () => {
530
- console.warn('[Bridge] In-memory refresh token was stale; retrying with the newer persisted token.');
531
- },
532
- });
533
- if (!auth.ownerId)
534
- throw new Error('security_setup_required: bridge auth must provide immutable ownerId');
535
- let identity;
536
- let accountBootstrap = boundAtStartup?.bootstrap;
537
- if (pendingVerified) {
538
- accountBootstrap = pendingVerified.verifiedAccountTrust;
539
- if (accountBootstrap.checkpoint.ownerId !== auth.ownerId)
540
- throw new Error('wrong_context');
541
- identity = await bindPendingBridgeIdentity(dataDir, accountBootstrap);
542
- }
543
- else if (pendingEnrollment) {
544
- if (!auth.accountApproval || !auth.accountHistory)
545
- throw new Error('account_approval_required');
546
- const history = await readAccountHistory(opts.apiUrl, currentBridgeToken);
547
- if (!history)
548
- throw new Error('account_approval_required');
549
- accountBootstrap = await openAccountApproval(pendingEnrollment.identity, pendingEnrollment.link, auth.accountApproval, history);
550
- if (accountBootstrap.checkpoint.ownerId !== auth.ownerId)
435
+ if (boundAtStartup && boundAtStartup.identity.endpoint.ownerId !== auth.ownerId)
551
436
  throw new Error('wrong_context');
552
- identity = await bindPendingBridgeIdentity(dataDir, accountBootstrap);
553
- }
554
- else {
555
- identity = boundAtStartup?.identity ?? await loadOrCreateIdentity(dataDir, auth.ownerId, 'bridge');
437
+ const identity = boundAtStartup?.identity ?? await loadOrCreateIdentity(dataDir, auth.ownerId, 'bridge');
556
438
  if (identity.endpoint.ownerId !== auth.ownerId)
557
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);
558
445
  }
559
- if (!solo && !opts.local)
560
- persistRefreshToken(currentRefreshToken);
561
- const localIdentity = await loadOrCreateIdentity(dataDir, auth.ownerId, 'local-cli');
562
- const security = new BridgeSecurityService(identity, bridgeId, dataDir, {
563
- relayUserId: auth.userId, relayUrl: auth.centrifugoUrl, browserOrigin: new URL(opts.apiUrl).origin,
564
- localOrigin: `http://127.0.0.1:${hookPort}`,
565
- readAccountHistory: !solo && !opts.local ? signal => readAccountHistory(opts.apiUrl, currentBridgeToken, signal) : undefined,
566
- });
567
- await security.policy.provisionLocalEndpoint(localIdentity.endpoint);
568
- await security.startAccount(accountBootstrap);
569
- if (!solo && !opts.local) {
570
- const registration = await fetch(`${opts.apiUrl.replace(/\/$/, '')}/api/security/endpoints`, { method: 'PUT',
571
- headers: { Authorization: `Bearer ${currentBridgeToken}`, 'Content-Type': 'application/json' },
572
- body: JSON.stringify({ endpoint: identity.endpoint, bridgeId, label: osHostname(), protocolVersion: 1 }) });
573
- if (!registration.ok)
574
- throw new Error('security_setup_required: endpoint directory registration failed');
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;
460
+ }
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;
481
+ }
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(),
489
+ });
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),
501
+ });
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;
509
+ }
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') {
515
+ await revokedRestart();
516
+ return;
517
+ }
518
+ if (registration.kind === 'exit')
519
+ return fail(registration.message);
520
+ if (registration.kind === 'retry')
521
+ registrar.retryInBackground();
522
+ }
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 };
575
529
  }
576
530
  const userId = auth.userId;
577
531
  const centrifugoUrl = auth.centrifugoUrl;
@@ -592,13 +546,20 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
592
546
  info: { bridgeId, hostname: osHostname() },
593
547
  });
594
548
  }
595
- // centrifuge-js polls every 5-10 s during a cooldown; stay quiet.
596
- if (tokenRefresher.cooldownRemainingMs() <= 0) {
597
- console.log('[Bridge] Refreshing Centrifugo 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;
555
+ try {
556
+ return await accountRelay.connectionToken();
557
+ }
558
+ catch (err) {
559
+ if (!quiet)
560
+ console.warn('[Bridge] Relay token unavailable:', err instanceof Error ? err.message : String(err));
561
+ throw err;
598
562
  }
599
- const token = await tokenRefresher.getToken();
600
- console.log('[Bridge] Token refreshed successfully');
601
- return token;
602
563
  }
603
564
  const store = new SessionStore(dataDir);
604
565
  // Migrate pre-existing session.json/terminal.log/inbox.jsonl off 0644.
@@ -613,7 +574,14 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
613
574
  updateAdmission.freeze();
614
575
  const updateEffects = new UpdateEffects(updateAdmission);
615
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
+ } : {};
616
583
  const centrifugo = new CentrifugoClient(centrifugoUrl, auth.token, getToken, {
584
+ ...ledgerOptions,
617
585
  disabled: opts.local,
618
586
  // On a transport reconnect, re-publish the session snapshot. The UI does
619
587
  // not re-request its list on reconnect, so without this its session list
@@ -637,9 +605,9 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
637
605
  },
638
606
  });
639
607
  centrifugo.setSecurity(security);
640
- centrifugo.onSecurityNudge(scope => { if (scope === 'account')
641
- security.requestAccountRefresh(); });
642
- 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();
643
611
  centrifugo.onLocalPublication((channel, data) => localApiServer.recordPublication(channel, data));
644
612
  // Direct transport (WebRTC DataChannel) data plane. Peers attach per session;
645
613
  // terminal output/screen fan out to them directly, and to Centrifugo only when
@@ -753,10 +721,11 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
753
721
  console.log(`[Bridge] Loopback WS rung ready at ws://127.0.0.1:${hookPort}/ws`);
754
722
  }
755
723
  localApiServer.setDependencies(store, runner, centrifugo, userId, terminalManager);
756
- 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({
757
726
  apiUrl: opts.apiUrl,
758
727
  bridgeId,
759
- getToken: () => currentBridgeToken,
728
+ getToken: () => ledgerRelay.tokens.getToken(),
760
729
  getSession: (sessionId) => store.loadSession(sessionId),
761
730
  });
762
731
  const attentionStore = new AttentionStore((sessionId) => store.sessionDir(sessionId), 500, pushNotifier ? (sessionId, notice) => pushNotifier.notify(sessionId, notice) : undefined, {
@@ -878,8 +847,8 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
878
847
  discard: discardPreparedRelease,
879
848
  reserve: operationId => reserveUpdate(updateHome, operationId),
880
849
  prepareHandoff, readReceipt: () => readUpdateReceipt(stagingRoot),
881
- writeReceipt: receipt => writeUpdateReceipt(stagingRoot, receipt),
882
- shutdown: () => shutdown(), admission: updateAdmission,
850
+ writeReceipt: receipt => { updatePhase = receipt.operation.phase; return writeUpdateReceipt(stagingRoot, receipt); },
851
+ shutdown: () => shutdownAndExit(), admission: updateAdmission,
883
852
  });
884
853
  // Pointer lives under the instance home (see configure* above): default →
885
854
  // $HOME/.ftown (harness CLIs read it there), custom --data-dir → that dir.
@@ -1012,8 +981,13 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
1012
981
  if (!updateRestart) {
1013
982
  ready = true;
1014
983
  console.log('[Bridge] Ready and listening for commands');
1015
- if (bridgePointerPublished)
1016
- 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
+ }
1017
991
  }
1018
992
  try {
1019
993
  await resurrection.resurrectSessions();
@@ -1037,45 +1011,68 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
1037
1011
  }
1038
1012
  })();
1039
1013
  }, 2000);
1040
- let shuttingDown = false;
1041
- const shutdown = async () => {
1042
- if (shuttingDown)
1043
- return;
1044
- shuttingDown = true;
1045
- console.log('\n[Bridge] Shutting down...');
1046
- await pullRequestEvents.stop();
1047
- attentionStore.stopNotifications();
1048
- stopPushSessionObserver();
1049
- pushNotifier?.stop();
1050
- // Solo L2 ordering: stop accepting first, then children (panel before
1051
- // hub), then the rest of the bridge teardown.
1052
- if (solo) {
1053
- void solo.front.close().catch(() => { });
1054
- solo.panel.stop();
1055
- solo.hub.stop();
1056
- }
1057
- localApiServer.stop();
1058
- runner.stopAll();
1059
- loopbackServer.closeAll();
1060
- directPeers.closeAll();
1061
- watchRegistry.dispose();
1062
- // node-datachannel module-level cleanup — avoids the native crash-on-exit race.
1063
- try {
1064
- ndcCleanup();
1065
- }
1066
- catch { /* native lib already torn down */ }
1067
- centrifugo.disconnect();
1014
+ // Leaves tmux sessions running and the process alive; a revoked restart re-executes this same PID
1015
+ // afterwards, so the local port, pointer, relay connection and timers must all be released here.
1016
+ let shutdownRun;
1017
+ function shutdown() {
1018
+ shutdownRun ??= (async () => {
1019
+ console.log('\n[Bridge] Shutting down...');
1020
+ try {
1021
+ await pullRequestEvents.stop();
1022
+ attentionStore.stopNotifications();
1023
+ stopPushSessionObserver();
1024
+ pushNotifier?.stop();
1025
+ ledgerRelay?.stop();
1026
+ accountRegistrar?.stop();
1027
+ // Solo L2 ordering: stop accepting first, then children (panel before
1028
+ // hub), then the rest of the bridge teardown.
1029
+ if (solo) {
1030
+ void solo.front.close().catch(() => { });
1031
+ solo.panel.stop();
1032
+ solo.hub.stop();
1033
+ }
1034
+ }
1035
+ finally {
1036
+ // The port and the pointer would outlive a same-PID re-exec, so no earlier failure may skip them.
1037
+ try {
1038
+ localApiServer.stop();
1039
+ }
1040
+ finally {
1041
+ cleanupPointer();
1042
+ }
1043
+ }
1044
+ runner.stopAll();
1045
+ loopbackServer.closeAll();
1046
+ directPeers.closeAll();
1047
+ watchRegistry.dispose();
1048
+ // node-datachannel module-level cleanup — avoids the native crash-on-exit race.
1049
+ try {
1050
+ ndcCleanup();
1051
+ }
1052
+ catch { /* native lib already torn down */ }
1053
+ centrifugo.disconnect();
1054
+ try {
1055
+ await security.close();
1056
+ }
1057
+ catch (err) {
1058
+ console.error('[Bridge] Failed to persist security shutdown state.');
1059
+ throw err;
1060
+ }
1061
+ })();
1062
+ return shutdownRun;
1063
+ }
1064
+ const shutdownAndExit = async () => {
1068
1065
  try {
1069
- await security.close();
1070
- process.exit(0);
1066
+ await shutdown();
1071
1067
  }
1072
1068
  catch {
1073
- console.error('[Bridge] Failed to persist security shutdown state.');
1074
1069
  process.exit(1);
1075
1070
  }
1071
+ process.exit(0);
1076
1072
  };
1077
- process.on('SIGINT', shutdown);
1078
- process.on('SIGTERM', shutdown);
1073
+ releaseForRestart = shutdown;
1074
+ process.on('SIGINT', () => { void shutdownAndExit(); });
1075
+ process.on('SIGTERM', () => { void shutdownAndExit(); });
1079
1076
  });
1080
1077
  registerLocalBrowserCommands(program);
1081
1078
  try {