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.
- 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 +1 -0
- package/dist/identity-reset.js +31 -5
- package/dist/identity-reset.js.map +1 -1
- package/dist/index.js +184 -288
- 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 +0 -13
- package/dist/revocation-gating.js +0 -16
- package/dist/revocation-gating.js.map +1 -1
- 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 -23
- package/dist/security/bridge/policy.js +214 -146
- package/dist/security/bridge/policy.js.map +1 -1
- package/dist/security/bridge/security.d.ts +84 -33
- package/dist/security/bridge/security.js +240 -166
- 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 +3 -2
- package/dist/bridge-auth.d.ts +0 -31
- package/dist/bridge-auth.js +0 -70
- package/dist/bridge-auth.js.map +0 -1
- package/dist/pairing-client.d.ts +0 -62
- package/dist/pairing-client.js +0 -469
- 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 -101
- 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/bridge/revocation.d.ts +0 -14
- package/dist/security/bridge/revocation.js +0 -40
- package/dist/security/bridge/revocation.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,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 {
|
|
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
|
|
50
|
-
import {
|
|
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
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
//
|
|
141
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
427
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
510
|
-
apiUrl: opts.apiUrl,
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
//
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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 (
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
518
|
+
if (registration.kind === 'exit')
|
|
519
|
+
return fail(registration.message);
|
|
520
|
+
if (registration.kind === 'retry')
|
|
521
|
+
registrar.retryInBackground();
|
|
558
522
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
//
|
|
664
|
-
if (
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
|
|
556
|
+
return await accountRelay.connectionToken();
|
|
670
557
|
}
|
|
671
558
|
catch (err) {
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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
|
-
|
|
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) {
|