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.
- package/dist/centrifugo-client.d.ts +15 -11
- package/dist/centrifugo-client.js +63 -70
- package/dist/centrifugo-client.js.map +1 -1
- package/dist/cli-client.d.ts +11 -1
- package/dist/cli-client.js +23 -3
- package/dist/cli-client.js.map +1 -1
- package/dist/command-rpc.js +4 -8
- package/dist/command-rpc.js.map +1 -1
- package/dist/identity-reset.d.ts +11 -0
- package/dist/identity-reset.js +192 -0
- package/dist/identity-reset.js.map +1 -0
- package/dist/index.js +276 -279
- package/dist/index.js.map +1 -1
- package/dist/ledger-pairing-client.d.ts +28 -0
- package/dist/ledger-pairing-client.js +152 -0
- package/dist/ledger-pairing-client.js.map +1 -0
- package/dist/ledger-relay.d.ts +124 -0
- package/dist/ledger-relay.js +453 -0
- package/dist/ledger-relay.js.map +1 -0
- package/dist/ledger-startup.d.ts +151 -0
- package/dist/ledger-startup.js +203 -0
- package/dist/ledger-startup.js.map +1 -0
- package/dist/relay-token.d.ts +39 -0
- package/dist/relay-token.js +130 -0
- package/dist/relay-token.js.map +1 -0
- package/dist/revocation-gating.d.ts +15 -0
- package/dist/revocation-gating.js +16 -0
- package/dist/revocation-gating.js.map +1 -0
- package/dist/revoked-restart.d.ts +21 -0
- package/dist/revoked-restart.js +55 -0
- package/dist/revoked-restart.js.map +1 -0
- package/dist/security/bridge/identity.d.ts +2 -47
- package/dist/security/bridge/identity.js +3 -3
- package/dist/security/bridge/identity.js.map +1 -1
- package/dist/security/bridge/ledger-identity.d.ts +48 -0
- package/dist/security/bridge/ledger-identity.js +342 -0
- package/dist/security/bridge/ledger-identity.js.map +1 -0
- package/dist/security/bridge/ledger-store.d.ts +84 -0
- package/dist/security/bridge/ledger-store.js +485 -0
- package/dist/security/bridge/ledger-store.js.map +1 -0
- package/dist/security/bridge/metadata.d.ts +2 -2
- package/dist/security/bridge/permissions.js +2 -2
- package/dist/security/bridge/permissions.js.map +1 -1
- package/dist/security/bridge/policy.d.ts +42 -21
- package/dist/security/bridge/policy.js +217 -135
- package/dist/security/bridge/policy.js.map +1 -1
- package/dist/security/bridge/security.d.ts +91 -28
- package/dist/security/bridge/security.js +274 -117
- package/dist/security/bridge/security.js.map +1 -1
- package/dist/security/crypto.js +1 -1
- package/dist/security/crypto.js.map +1 -1
- package/dist/security/ledger/channels.d.ts +5 -0
- package/dist/security/ledger/channels.js +21 -0
- package/dist/security/ledger/channels.js.map +1 -0
- package/dist/security/ledger/ledger.d.ts +61 -0
- package/dist/security/ledger/ledger.js +597 -0
- package/dist/security/ledger/ledger.js.map +1 -0
- package/dist/security/ledger/pairing.d.ts +81 -0
- package/dist/security/ledger/pairing.js +414 -0
- package/dist/security/ledger/pairing.js.map +1 -0
- package/dist/security/ledger/proof.d.ts +24 -0
- package/dist/security/ledger/proof.js +161 -0
- package/dist/security/ledger/proof.js.map +1 -0
- package/dist/security/ledger/protocol.d.ts +100 -0
- package/dist/security/ledger/protocol.js +16 -0
- package/dist/security/ledger/protocol.js.map +1 -0
- package/dist/security/ledger/testing.d.ts +27 -0
- package/dist/security/ledger/testing.js +70 -0
- package/dist/security/ledger/testing.js.map +1 -0
- package/dist/security/ledger/webauthn.d.ts +12 -0
- package/dist/security/ledger/webauthn.js +147 -0
- package/dist/security/ledger/webauthn.js.map +1 -0
- package/dist/security/protocol.d.ts +5 -10
- package/dist/security-cli.d.ts +5 -0
- package/dist/security-cli.js +28 -16
- package/dist/security-cli.js.map +1 -1
- package/dist/token-provider.d.ts +39 -0
- package/dist/token-provider.js +101 -0
- package/dist/token-provider.js.map +1 -0
- package/dist/types.d.ts +1 -1
- package/package.json +2 -2
- package/skills/ftown/references/sessions.md +4 -0
- package/dist/bridge-auth.d.ts +0 -25
- package/dist/bridge-auth.js +0 -56
- package/dist/bridge-auth.js.map +0 -1
- package/dist/pairing-client.d.ts +0 -60
- package/dist/pairing-client.js +0 -460
- package/dist/pairing-client.js.map +0 -1
- package/dist/rotating-token-refresher.d.ts +0 -42
- package/dist/rotating-token-refresher.js +0 -91
- package/dist/rotating-token-refresher.js.map +0 -1
- package/dist/security/account-crypto.d.ts +0 -33
- package/dist/security/account-crypto.js +0 -649
- package/dist/security/account-crypto.js.map +0 -1
- package/dist/security/account-protocol.d.ts +0 -228
- package/dist/security/account-protocol.js +0 -2
- package/dist/security/account-protocol.js.map +0 -1
- package/dist/security/bridge/account-client.d.ts +0 -7
- package/dist/security/bridge/account-client.js +0 -87
- package/dist/security/bridge/account-client.js.map +0 -1
- package/dist/security/bridge/account-state.d.ts +0 -105
- package/dist/security/bridge/account-state.js +0 -79
- package/dist/security/bridge/account-state.js.map +0 -1
- package/dist/security/bridge/pending-approval.d.ts +0 -21
- package/dist/security/bridge/pending-approval.js +0 -80
- package/dist/security/bridge/pending-approval.js.map +0 -1
- package/dist/security/bridge/pending-enrollment.d.ts +0 -33
- package/dist/security/bridge/pending-enrollment.js +0 -177
- package/dist/security/bridge/pending-enrollment.js.map +0 -1
- package/dist/security/pending-approval-crypto.d.ts +0 -29
- package/dist/security/pending-approval-crypto.js +0 -716
- package/dist/security/pending-approval-crypto.js.map +0 -1
- package/dist/security/pending-approval-protocol.d.ts +0 -329
- package/dist/security/pending-approval-protocol.js +0 -2
- package/dist/security/pending-approval-protocol.js.map +0 -1
- package/dist/security/security-nudge.d.ts +0 -31
- package/dist/security/security-nudge.js +0 -98
- 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 {
|
|
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 {
|
|
46
|
-
import {
|
|
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.
|
|
111
|
-
program.error('--
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
//
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
//
|
|
596
|
-
if (
|
|
597
|
-
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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: () =>
|
|
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
|
-
|
|
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
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
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
|
|
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
|
-
|
|
1078
|
-
process.on('
|
|
1073
|
+
releaseForRestart = shutdown;
|
|
1074
|
+
process.on('SIGINT', () => { void shutdownAndExit(); });
|
|
1075
|
+
process.on('SIGTERM', () => { void shutdownAndExit(); });
|
|
1079
1076
|
});
|
|
1080
1077
|
registerLocalBrowserCommands(program);
|
|
1081
1078
|
try {
|