ftown-bridge 0.20.7 → 0.20.9

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 (37) hide show
  1. package/dist/bridge-auth.d.ts +6 -0
  2. package/dist/bridge-auth.js +15 -1
  3. package/dist/bridge-auth.js.map +1 -1
  4. package/dist/identity-reset.d.ts +10 -0
  5. package/dist/identity-reset.js +166 -0
  6. package/dist/identity-reset.js.map +1 -0
  7. package/dist/index.js +147 -43
  8. package/dist/index.js.map +1 -1
  9. package/dist/pairing-client.d.ts +2 -0
  10. package/dist/pairing-client.js +9 -0
  11. package/dist/pairing-client.js.map +1 -1
  12. package/dist/revocation-gating.d.ts +28 -0
  13. package/dist/revocation-gating.js +32 -0
  14. package/dist/revocation-gating.js.map +1 -0
  15. package/dist/revoked-restart.d.ts +21 -0
  16. package/dist/revoked-restart.js +55 -0
  17. package/dist/revoked-restart.js.map +1 -0
  18. package/dist/rotating-token-refresher.d.ts +17 -0
  19. package/dist/rotating-token-refresher.js +39 -0
  20. package/dist/rotating-token-refresher.js.map +1 -1
  21. package/dist/security/account-crypto.d.ts +1 -1
  22. package/dist/security/account-crypto.js +1 -1
  23. package/dist/security/account-crypto.js.map +1 -1
  24. package/dist/security/account-protocol.d.ts +1 -1
  25. package/dist/security/bridge/account-client.js +14 -0
  26. package/dist/security/bridge/account-client.js.map +1 -1
  27. package/dist/security/bridge/policy.d.ts +3 -1
  28. package/dist/security/bridge/policy.js +14 -0
  29. package/dist/security/bridge/policy.js.map +1 -1
  30. package/dist/security/bridge/revocation.d.ts +14 -0
  31. package/dist/security/bridge/revocation.js +40 -0
  32. package/dist/security/bridge/revocation.js.map +1 -0
  33. package/dist/security/bridge/security.d.ts +20 -3
  34. package/dist/security/bridge/security.js +109 -13
  35. package/dist/security/bridge/security.js.map +1 -1
  36. package/package.json +2 -2
  37. package/skills/ftown/references/sessions.md +3 -0
@@ -1,3 +1,4 @@
1
+ import { UnauthorizedError } from 'centrifuge';
1
2
  import type { AccountEnrollmentBundle, AccountHistory } from './security/account-protocol.js';
2
3
  /** Loopback advert embedded in the Centrifugo connection JWT `info` claim (L2). */
3
4
  export interface BridgeLocalAdvert {
@@ -13,6 +14,11 @@ export interface BridgeAuthResponse {
13
14
  accountApproval?: AccountEnrollmentBundle;
14
15
  accountHistory?: AccountHistory;
15
16
  }
17
+ /** A 401 refresh that carries signed revocation evidence; still terminal for centrifuge-js. */
18
+ export declare class BridgeRevokedError extends UnauthorizedError {
19
+ readonly evidence: AccountHistory;
20
+ constructor(evidence: AccountHistory);
21
+ }
16
22
  export declare function fetchBridgeToken(apiUrl: string, authToken: string, bridgeId: string, local: BridgeLocalAdvert): Promise<BridgeAuthResponse>;
17
23
  /**
18
24
  * Exchange a (rotating) refresh token for a fresh Centrifugo connect token.
@@ -1,5 +1,15 @@
1
1
  import { hostname as osHostname } from 'node:os';
2
2
  import { UnauthorizedError } from 'centrifuge';
3
+ import { parseBridgeRevokedBody } from './security/bridge/revocation.js';
4
+ /** A 401 refresh that carries signed revocation evidence; still terminal for centrifuge-js. */
5
+ export class BridgeRevokedError extends UnauthorizedError {
6
+ evidence;
7
+ constructor(evidence) {
8
+ super('Bridge revoked');
9
+ this.name = 'BridgeRevokedError';
10
+ this.evidence = evidence;
11
+ }
12
+ }
3
13
  export async function fetchBridgeToken(apiUrl, authToken, bridgeId, local) {
4
14
  const res = await fetch(`${apiUrl}/api/auth/bridge`, {
5
15
  method: 'POST',
@@ -42,11 +52,15 @@ export async function refreshBridgeToken(apiUrl, refreshToken, bridgeId, local)
42
52
  });
43
53
  if (!res.ok) {
44
54
  const body = await res.text();
45
- const message = `Token refresh failed (${res.status}): ${body}`;
55
+ // Capped: a malformed or oversized 401 body must not flood the logged error message.
56
+ const message = `Token refresh failed (${res.status}): ${body.slice(0, 200)}`;
46
57
  // A rejected bridge credential cannot become valid through retrying.
47
58
  // centrifuge-js only stops its token retry loop for UnauthorizedError;
48
59
  // ordinary errors are treated as transient and retried indefinitely.
49
60
  if (res.status === 401) {
61
+ const evidence = parseBridgeRevokedBody(body);
62
+ if (evidence !== null)
63
+ throw new BridgeRevokedError(evidence);
50
64
  throw new UnauthorizedError(message);
51
65
  }
52
66
  throw new Error(message);
@@ -1 +1 @@
1
- {"version":3,"file":"bridge-auth.js","sourceRoot":"","sources":["../src/bridge-auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,IAAI,UAAU,EAAE,MAAM,SAAS,CAAC;AACjD,OAAO,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAmB/C,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACpC,MAAc,EACd,SAAiB,EACjB,QAAgB,EAChB,KAAwB;IAExB,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,MAAM,kBAAkB,EAAE;QACnD,MAAM,EAAE,MAAM;QACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;QAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;YACnB,KAAK,EAAE,SAAS;YAChB,QAAQ;YACR,QAAQ,EAAE,UAAU,EAAE;YACtB,uEAAuE;YACvE,kEAAkE;YAClE,SAAS,EAAE,KAAK,CAAC,SAAS;YAC1B,6EAA6E;SAC9E,CAAC;KACH,CAAC,CAAC;IAEH,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;QACZ,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;QAC9B,MAAM,IAAI,KAAK,CAAC,uBAAuB,GAAG,CAAC,MAAM,MAAM,IAAI,EAAE,CAAC,CAAC;IACjE,CAAC;IAED,OAAO,GAAG,CAAC,IAAI,EAAiC,CAAC;AACnD,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACtC,MAAc,EACd,YAAoB,EACpB,QAAgB,EAChB,KAAwB;IAExB,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,MAAM,0BAA0B,EAAE;QAC3D,MAAM,EAAE,MAAM;QACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;QAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;YACnB,YAAY;YACZ,QAAQ;YACR,QAAQ,EAAE,UAAU,EAAE;YACtB,SAAS,EAAE,KAAK,CAAC,SAAS;YAC1B,6EAA6E;SAC9E,CAAC;KACH,CAAC,CAAC;IAEH,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;QACZ,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;QAC9B,MAAM,OAAO,GAAG,yBAAyB,GAAG,CAAC,MAAM,MAAM,IAAI,EAAE,CAAC;QAChE,qEAAqE;QACrE,uEAAuE;QACvE,qEAAqE;QACrE,IAAI,GAAG,CAAC,MAAM,KAAK,GAAG,EAAE,CAAC;YACvB,MAAM,IAAI,iBAAiB,CAAC,OAAO,CAAC,CAAC;QACvC,CAAC;QACD,MAAM,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC;IAC3B,CAAC;IAED,OAAO,GAAG,CAAC,IAAI,EAAiC,CAAC;AACnD,CAAC"}
1
+ {"version":3,"file":"bridge-auth.js","sourceRoot":"","sources":["../src/bridge-auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,IAAI,UAAU,EAAE,MAAM,SAAS,CAAC;AACjD,OAAO,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAE/C,OAAO,EAAE,sBAAsB,EAAE,MAAM,iCAAiC,CAAC;AAkBzE,+FAA+F;AAC/F,MAAM,OAAO,kBAAmB,SAAQ,iBAAiB;IAC9C,QAAQ,CAAiB;IAElC,YAAY,QAAwB;QAClC,KAAK,CAAC,gBAAgB,CAAC,CAAC;QACxB,IAAI,CAAC,IAAI,GAAG,oBAAoB,CAAC;QACjC,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;IAC3B,CAAC;CACF;AAED,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACpC,MAAc,EACd,SAAiB,EACjB,QAAgB,EAChB,KAAwB;IAExB,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,MAAM,kBAAkB,EAAE;QACnD,MAAM,EAAE,MAAM;QACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;QAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;YACnB,KAAK,EAAE,SAAS;YAChB,QAAQ;YACR,QAAQ,EAAE,UAAU,EAAE;YACtB,uEAAuE;YACvE,kEAAkE;YAClE,SAAS,EAAE,KAAK,CAAC,SAAS;YAC1B,6EAA6E;SAC9E,CAAC;KACH,CAAC,CAAC;IAEH,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;QACZ,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;QAC9B,MAAM,IAAI,KAAK,CAAC,uBAAuB,GAAG,CAAC,MAAM,MAAM,IAAI,EAAE,CAAC,CAAC;IACjE,CAAC;IAED,OAAO,GAAG,CAAC,IAAI,EAAiC,CAAC;AACnD,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACtC,MAAc,EACd,YAAoB,EACpB,QAAgB,EAChB,KAAwB;IAExB,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,MAAM,0BAA0B,EAAE;QAC3D,MAAM,EAAE,MAAM;QACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;QAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;YACnB,YAAY;YACZ,QAAQ;YACR,QAAQ,EAAE,UAAU,EAAE;YACtB,SAAS,EAAE,KAAK,CAAC,SAAS;YAC1B,6EAA6E;SAC9E,CAAC;KACH,CAAC,CAAC;IAEH,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;QACZ,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;QAC9B,qFAAqF;QACrF,MAAM,OAAO,GAAG,yBAAyB,GAAG,CAAC,MAAM,MAAM,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC;QAC9E,qEAAqE;QACrE,uEAAuE;QACvE,qEAAqE;QACrE,IAAI,GAAG,CAAC,MAAM,KAAK,GAAG,EAAE,CAAC;YACvB,MAAM,QAAQ,GAAG,sBAAsB,CAAC,IAAI,CAAC,CAAC;YAC9C,IAAI,QAAQ,KAAK,IAAI;gBAAE,MAAM,IAAI,kBAAkB,CAAC,QAAQ,CAAC,CAAC;YAC9D,MAAM,IAAI,iBAAiB,CAAC,OAAO,CAAC,CAAC;QACvC,CAAC;QACD,MAAM,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC;IAC3B,CAAC;IAED,OAAO,GAAG,CAAC,IAAI,EAAiC,CAAC;AACnD,CAAC"}
@@ -0,0 +1,10 @@
1
+ export type IdentityResetReason = 'revoked' | 'manual';
2
+ export interface IdentityResetResult {
3
+ oldBridgeId: string | null;
4
+ newBridgeId: string;
5
+ reason: IdentityResetReason;
6
+ }
7
+ export declare function beginIdentityReset(dataDir: string, reason: IdentityResetReason): void;
8
+ export declare function completePendingIdentityReset(dataDir: string): IdentityResetResult | null;
9
+ export declare function argvForPairingRestart(argv: readonly string[]): string[];
10
+ export declare function reexec(scriptPath: string, argv: string[]): boolean;
@@ -0,0 +1,166 @@
1
+ import { accessSync, closeSync, constants, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, unlinkSync, writeSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { v4 as uuidv4 } from 'uuid';
4
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5
+ const markerPath = (dataDir) => join(dataDir, 'security', 'identity-reset.json');
6
+ function errCode(err) {
7
+ return err?.code;
8
+ }
9
+ /** Missing file counts as done; any other fs failure must surface. */
10
+ function removeIfPresent(path) {
11
+ try {
12
+ unlinkSync(path);
13
+ }
14
+ catch (err) {
15
+ if (errCode(err) !== 'ENOENT')
16
+ throw err;
17
+ }
18
+ }
19
+ function syncDir(dir) {
20
+ let fd;
21
+ try {
22
+ fd = openSync(dir, 'r');
23
+ }
24
+ catch {
25
+ return;
26
+ }
27
+ try {
28
+ fsyncSync(fd);
29
+ }
30
+ catch { /* directory fsync is unsupported on some platforms */ }
31
+ finally {
32
+ closeSync(fd);
33
+ }
34
+ }
35
+ function writeAtomic(path, dir, content) {
36
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
37
+ const tmp = `${path}.${uuidv4()}.tmp`;
38
+ const fd = openSync(tmp, 'wx', 0o600);
39
+ try {
40
+ try {
41
+ writeSync(fd, content);
42
+ fsyncSync(fd);
43
+ }
44
+ finally {
45
+ closeSync(fd);
46
+ }
47
+ renameSync(tmp, path);
48
+ }
49
+ catch (err) {
50
+ rmSync(tmp, { force: true });
51
+ throw err;
52
+ }
53
+ syncDir(dir);
54
+ }
55
+ /** null = no marker file; undefined = marker present but invalid. */
56
+ function readMarker(dataDir) {
57
+ let raw;
58
+ try {
59
+ raw = readFileSync(markerPath(dataDir), 'utf8');
60
+ }
61
+ catch (err) {
62
+ if (errCode(err) === 'ENOENT')
63
+ return null;
64
+ throw err;
65
+ }
66
+ try {
67
+ const v = JSON.parse(raw);
68
+ if (typeof v !== 'object' || v === null)
69
+ return undefined;
70
+ const m = v;
71
+ if (m.version !== 1 || (m.reason !== 'revoked' && m.reason !== 'manual'))
72
+ return undefined;
73
+ if (typeof m.requestedAt !== 'string' || typeof m.newBridgeId !== 'string' || !UUID_RE.test(m.newBridgeId))
74
+ return undefined;
75
+ return { version: 1, reason: m.reason, requestedAt: m.requestedAt, newBridgeId: m.newBridgeId };
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
80
+ }
81
+ export function beginIdentityReset(dataDir, reason) {
82
+ if (readMarker(dataDir))
83
+ return;
84
+ const marker = { version: 1, reason, requestedAt: new Date().toISOString(), newBridgeId: uuidv4() };
85
+ writeAtomic(markerPath(dataDir), join(dataDir, 'security'), JSON.stringify(marker));
86
+ }
87
+ export function completePendingIdentityReset(dataDir) {
88
+ const marker = readMarker(dataDir);
89
+ if (marker === null)
90
+ return null;
91
+ // A corrupt marker cannot name the new identity; leave it for beginIdentityReset to replace.
92
+ if (marker === undefined)
93
+ return null;
94
+ const securityDir = join(dataDir, 'security');
95
+ const bridgeIdPath = join(dataDir, 'bridge-id');
96
+ let oldBridgeId = null;
97
+ try {
98
+ oldBridgeId = readFileSync(bridgeIdPath, 'utf8').trim() || null;
99
+ }
100
+ catch (err) {
101
+ if (errCode(err) !== 'ENOENT')
102
+ throw err;
103
+ }
104
+ // A crash after step 5 already holds the new id; the previous one is unknowable then.
105
+ if (oldBridgeId === marker.newBridgeId)
106
+ oldBridgeId = null;
107
+ removeIfPresent(join(dataDir, 'refresh-token'));
108
+ for (const name of ['account-enrollment.json', 'pending-approval-status.json', 'pending-approval-confirmation.json', 'local-cli-identity.json']) {
109
+ removeIfPresent(join(securityDir, name));
110
+ }
111
+ removeIfPresent(join(dataDir, 'browser-devices.json'));
112
+ removeIfPresent(join(securityDir, 'policy.json'));
113
+ removeIfPresent(join(securityDir, 'bridge-identity.json'));
114
+ // Deletions must be durable before the new id lands.
115
+ syncDir(securityDir);
116
+ syncDir(dataDir);
117
+ writeAtomic(bridgeIdPath, dataDir, `${marker.newBridgeId}\n`);
118
+ removeIfPresent(markerPath(dataDir));
119
+ syncDir(securityDir);
120
+ return { oldBridgeId, newBridgeId: marker.newBridgeId, reason: marker.reason };
121
+ }
122
+ // Every value-taking `ftown start` option (index.ts); each consumes its next token verbatim.
123
+ const VALUE_OPTIONS = ['--port', '--token', '--account', '--api-url', '--data-dir', '--bridge-id'];
124
+ const DROPPED_OPTIONS = ['--bridge-id', '--account', '--token'];
125
+ export function argvForPairingRestart(argv) {
126
+ const out = [];
127
+ for (let i = 0; i < argv.length; i++) {
128
+ const a = argv[i];
129
+ if (a === '--new-identity' || a.startsWith('--new-identity='))
130
+ continue;
131
+ if (VALUE_OPTIONS.some((f) => a.startsWith(`${f}=`))) {
132
+ if (!DROPPED_OPTIONS.some((f) => a.startsWith(`${f}=`)))
133
+ out.push(a);
134
+ continue;
135
+ }
136
+ if (VALUE_OPTIONS.includes(a)) {
137
+ const hasValue = i + 1 < argv.length;
138
+ if (!DROPPED_OPTIONS.includes(a)) {
139
+ out.push(a);
140
+ if (hasValue)
141
+ out.push(argv[i + 1]);
142
+ }
143
+ i++;
144
+ continue;
145
+ }
146
+ out.push(a);
147
+ }
148
+ if (!out.includes('--bootstrap'))
149
+ out.push('--bootstrap');
150
+ return out;
151
+ }
152
+ export function reexec(scriptPath, argv) {
153
+ if (typeof process.execve !== 'function')
154
+ return false;
155
+ // A failing execve aborts the process (SIGABRT) instead of throwing, e.g. when the node binary was replaced under us.
156
+ try {
157
+ accessSync(process.execPath, constants.X_OK);
158
+ accessSync(scriptPath, constants.R_OK);
159
+ }
160
+ catch {
161
+ return false;
162
+ }
163
+ process.execve(process.execPath, [process.execPath, ...process.execArgv, scriptPath, ...argv], process.env);
164
+ return true;
165
+ }
166
+ //# sourceMappingURL=identity-reset.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"identity-reset.js","sourceRoot":"","sources":["../src/identity-reset.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -32,6 +32,7 @@ import { selectBridgePairingMode, waitForPendingBridgeDecision, writePendingBrid
32
32
  import { readAccountHistory } from './security/bridge/account-client.js';
33
33
  import { openAccountApproval } from './security/account-crypto.js';
34
34
  import { BridgeSecurityService, applicationJson } from './security/bridge/security.js';
35
+ import { readPinnedAccountCheckpoint } from './security/bridge/policy.js';
35
36
  import { createCommandHandler } from './command-rpc.js';
36
37
  import { PullRequestStatusService } from './pull-request-status.js';
37
38
  import { createPrReviewService } from './pull-request-review.js';
@@ -42,7 +43,10 @@ import { TerminalPump } from './terminal-pump.js';
42
43
  import { collectSessionUsage } from './usage-collector.js';
43
44
  import { AgentSessionIdPersister } from './session-ids.js';
44
45
  import { HookUsagePersister } from './hook-usage.js';
45
- import { fetchBridgeToken, refreshBridgeToken } from './bridge-auth.js';
46
+ import { BridgeRevokedError, fetchBridgeToken, refreshBridgeToken } from './bridge-auth.js';
47
+ import { beginIdentityReset, completePendingIdentityReset } from './identity-reset.js';
48
+ import { createRevokedRestart } from './revoked-restart.js';
49
+ import { bridgeMode, canReexecAfterRevocation, handlesRevocation, restartForPinnedRevocation, restartForRefreshError } from './revocation-gating.js';
46
50
  import { RotatingTokenRefresher } from './rotating-token-refresher.js';
47
51
  import { resolveDefaultDataDir, resolveFtownHome } from './ftown-home.js';
48
52
  import { SessionController } from './session-controller.js';
@@ -104,6 +108,7 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
104
108
  .option('--api-url <url>', 'ftown UI API URL', DEFAULT_API_URL)
105
109
  .option('--data-dir <path>', 'Directory for session data (default: ~/.ftown/data)')
106
110
  .option('--bridge-id <id>', 'Bridge instance ID (default: persisted per data dir)')
111
+ .option('--new-identity', 'Forget this computer’s account identity and credentials, then pair it again as a new computer with a private link (sessions are kept)')
107
112
  .action(async (opts) => {
108
113
  if (opts.local && opts.solo)
109
114
  program.error('--local and --solo cannot be combined');
@@ -111,6 +116,8 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
111
116
  program.error('--account and --bootstrap cannot be combined');
112
117
  if ((opts.local || opts.solo) && (opts.account !== undefined || opts.bootstrap))
113
118
  program.error('--account and --bootstrap require cloud mode');
119
+ if ((opts.local || opts.solo) && opts.newIdentity)
120
+ program.error('--new-identity requires cloud mode');
114
121
  const apiUrl = new URL(opts.apiUrl);
115
122
  const isLocalHost = apiUrl.hostname === 'localhost' ||
116
123
  apiUrl.hostname === '127.0.0.1' ||
@@ -121,16 +128,46 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
121
128
  'Bridge tokens would otherwise cross the network unencrypted. ' +
122
129
  'Set FTOWN_ALLOW_INSECURE_API=1 to override for trusted LAN setups.');
123
130
  }
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
131
  // Explicit setup must finish before any migration, identity, network or auth side effect.
130
132
  assertSetupReady(BRIDGE_VERSION);
131
133
  // Read-only default resolution; explicit setup owns legacy data migration.
132
134
  const defaultDataDir = resolveDefaultDataDir();
133
135
  const dataDir = opts.dataDir ? resolve(opts.dataDir) : defaultDataDir;
136
+ // No bridge starts, and no identity is reset, while an update holds the reservation.
137
+ assertUpdateReservationAllowed(homedir());
138
+ if (opts.newIdentity)
139
+ beginIdentityReset(dataDir, 'manual');
140
+ // Also finishes a reset that a revoked restart or a crash left pending.
141
+ const identityReset = completePendingIdentityReset(dataDir);
142
+ let updateRestart = null;
143
+ if (identityReset) {
144
+ console.log(`[Bridge] New identity for this computer: ${identityReset.oldBridgeId ?? 'unknown'} -> ${identityReset.newBridgeId}`);
145
+ if (opts.bridgeId !== undefined) {
146
+ console.warn('[Bridge] Ignoring --bridge-id: this computer has a new identity.');
147
+ opts.bridgeId = undefined;
148
+ }
149
+ // A reset bridge is a fresh install; an update context names the identity it just discarded.
150
+ try {
151
+ if (readUpdateRestartContext())
152
+ console.warn('[Bridge] Ignoring the update restart context: this computer has a new identity.');
153
+ }
154
+ catch (err) {
155
+ console.warn('[Bridge] Ignoring an invalid update restart context after the identity reset:', err instanceof Error ? err.message : String(err));
156
+ }
157
+ if (!opts.local && !opts.solo) {
158
+ if (opts.token !== undefined || opts.account !== undefined)
159
+ console.warn('[Bridge] Ignoring --token and --account: a new identity pairs with a private link.');
160
+ opts.token = undefined;
161
+ opts.account = undefined;
162
+ opts.bootstrap = true;
163
+ }
164
+ }
165
+ else {
166
+ // Restart context is authenticated by the private helper channel before any mutation.
167
+ updateRestart = readUpdateRestartContext();
168
+ if (updateRestart && (updateRestart.targetVersion !== BRIDGE_VERSION || opts.token || opts.account || opts.bootstrap || opts.solo))
169
+ throw new Error('Invalid update restart context');
170
+ }
134
171
  // Every child/local CLI must resolve this exact instance, including custom --data-dir bridges.
135
172
  process.env.FTOWN_DATA_DIR = dataDir;
136
173
  // Instance-scoped ".ftown home" for pointer and registry files. The DEFAULT
@@ -166,6 +203,22 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
166
203
  catch (err) {
167
204
  console.error('[Bridge] Failed to persist bridge id:', err instanceof Error ? err.message : String(err));
168
205
  }
206
+ // Signed revocation of this bridgeId restarts the process as a fresh install, at most once.
207
+ // The re-exec keeps this PID, so whatever startup has acquired so far is released first.
208
+ let releaseForRestart = async () => { };
209
+ // Mirrors the update coordinator's receipts; an active operation holds the reservation under this PID.
210
+ let updatePhase = null;
211
+ const revokedRestart = createRevokedRestart({
212
+ dataDir, scriptPath: process.argv[1], argv: process.argv.slice(2),
213
+ shutdown: () => releaseForRestart(), log: message => console.log(message), exit: code => process.exit(code),
214
+ canReexec: () => canReexecAfterRevocation({ updateRestart: updateRestart !== null, updatePhase }),
215
+ });
216
+ const securityDir = join(dataDir, 'security');
217
+ const runMode = bridgeMode(opts);
218
+ if (restartForPinnedRevocation(runMode, readPinnedAccountCheckpoint(securityDir), bridgeId)) {
219
+ await revokedRestart();
220
+ return;
221
+ }
169
222
  // L4: --rotate-key short-circuits BEFORE any listener binds (exit 0,
170
223
  // nothing started). Without --solo it is an argument error.
171
224
  if (opts.rotateKey && !opts.solo) {
@@ -208,6 +261,7 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
208
261
  localApiServer.stop();
209
262
  throw new Error('Update restart endpoint is unavailable');
210
263
  }
264
+ releaseForRestart = async () => { localApiServer.stop(); };
211
265
  writeFileSync(localPortPath, `${hookPort}\n`, { mode: 0o600 });
212
266
  console.log(`[Bridge] Local API server started on port ${hookPort}`);
213
267
  const localNonce = randomBytes(16).toString('hex');
@@ -492,6 +546,11 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
492
546
  auth = await refreshBridgeToken(opts.apiUrl, persistedRefreshToken, bridgeId, local);
493
547
  }
494
548
  catch (err) {
549
+ // Only a refusal whose signed history verifies against our own pin resets; anything else keeps today's path.
550
+ if (await restartForRefreshError({ mode: runMode, error: err, pinned: readPinnedAccountCheckpoint(securityDir), bridgeId })) {
551
+ await revokedRestart();
552
+ return;
553
+ }
495
554
  if (updateRestart)
496
555
  throw new Error('Update restart authentication failed');
497
556
  console.warn('[Bridge] Stored refresh token rejected, falling back to bootstrap token / pairing:', err instanceof Error ? err.message : String(err));
@@ -564,8 +623,17 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
564
623
  localOrigin: `http://127.0.0.1:${hookPort}`,
565
624
  readAccountHistory: !solo && !opts.local ? signal => readAccountHistory(opts.apiUrl, currentBridgeToken, signal) : undefined,
566
625
  });
626
+ releaseForRestart = async () => { localApiServer.stop(); await security.close(); };
627
+ let bridgeRevoked = false;
628
+ if (handlesRevocation(runMode))
629
+ security.onBridgeRevoked(() => { bridgeRevoked = true; void revokedRestart(); });
567
630
  await security.policy.provisionLocalEndpoint(localIdentity.endpoint);
568
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
+ }
569
637
  if (!solo && !opts.local) {
570
638
  const registration = await fetch(`${opts.apiUrl.replace(/\/$/, '')}/api/security/endpoints`, { method: 'PUT',
571
639
  headers: { Authorization: `Bearer ${currentBridgeToken}`, 'Content-Type': 'application/json' },
@@ -592,8 +660,23 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
592
660
  info: { bridgeId, hostname: osHostname() },
593
661
  });
594
662
  }
595
- console.log('[Bridge] Refreshing Centrifugo token...');
596
- const token = await tokenRefresher.getToken();
663
+ // centrifuge-js polls every 5-10 s during a cooldown; stay quiet.
664
+ if (tokenRefresher.cooldownRemainingMs() <= 0) {
665
+ console.log('[Bridge] Refreshing Centrifugo token...');
666
+ }
667
+ let token;
668
+ try {
669
+ token = await tokenRefresher.getToken();
670
+ }
671
+ catch (err) {
672
+ // The refusal is unverified; the security service checks it against the pinned account state.
673
+ if (err instanceof BridgeRevokedError) {
674
+ security.confirmRevocationEvidence(err.evidence).catch((confirmErr) => {
675
+ console.error('[Bridge] Failed to verify revocation evidence:', confirmErr instanceof Error ? confirmErr.message : String(confirmErr));
676
+ });
677
+ }
678
+ throw err;
679
+ }
597
680
  console.log('[Bridge] Token refreshed successfully');
598
681
  return token;
599
682
  }
@@ -875,8 +958,8 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
875
958
  discard: discardPreparedRelease,
876
959
  reserve: operationId => reserveUpdate(updateHome, operationId),
877
960
  prepareHandoff, readReceipt: () => readUpdateReceipt(stagingRoot),
878
- writeReceipt: receipt => writeUpdateReceipt(stagingRoot, receipt),
879
- shutdown: () => shutdown(), admission: updateAdmission,
961
+ writeReceipt: receipt => { updatePhase = receipt.operation.phase; return writeUpdateReceipt(stagingRoot, receipt); },
962
+ shutdown: () => shutdownAndExit(), admission: updateAdmission,
880
963
  });
881
964
  // Pointer lives under the instance home (see configure* above): default →
882
965
  // $HOME/.ftown (harness CLIs read it there), custom --data-dir → that dir.
@@ -1034,45 +1117,66 @@ export async function runBridge(argv, commandName = 'ftown-bridge') {
1034
1117
  }
1035
1118
  })();
1036
1119
  }, 2000);
1037
- let shuttingDown = false;
1038
- const shutdown = async () => {
1039
- if (shuttingDown)
1040
- return;
1041
- shuttingDown = true;
1042
- console.log('\n[Bridge] Shutting down...');
1043
- await pullRequestEvents.stop();
1044
- attentionStore.stopNotifications();
1045
- stopPushSessionObserver();
1046
- pushNotifier?.stop();
1047
- // Solo L2 ordering: stop accepting first, then children (panel before
1048
- // hub), then the rest of the bridge teardown.
1049
- if (solo) {
1050
- void solo.front.close().catch(() => { });
1051
- solo.panel.stop();
1052
- solo.hub.stop();
1053
- }
1054
- localApiServer.stop();
1055
- runner.stopAll();
1056
- loopbackServer.closeAll();
1057
- directPeers.closeAll();
1058
- watchRegistry.dispose();
1059
- // node-datachannel module-level cleanup — avoids the native crash-on-exit race.
1060
- try {
1061
- ndcCleanup();
1062
- }
1063
- catch { /* native lib already torn down */ }
1064
- centrifugo.disconnect();
1120
+ // Leaves tmux sessions running and the process alive; a revoked restart re-executes this same PID
1121
+ // afterwards, so the local port, pointer, relay connection and timers must all be released here.
1122
+ let shutdownRun;
1123
+ function shutdown() {
1124
+ shutdownRun ??= (async () => {
1125
+ console.log('\n[Bridge] Shutting down...');
1126
+ try {
1127
+ await pullRequestEvents.stop();
1128
+ attentionStore.stopNotifications();
1129
+ stopPushSessionObserver();
1130
+ pushNotifier?.stop();
1131
+ // Solo L2 ordering: stop accepting first, then children (panel before
1132
+ // hub), then the rest of the bridge teardown.
1133
+ if (solo) {
1134
+ void solo.front.close().catch(() => { });
1135
+ solo.panel.stop();
1136
+ solo.hub.stop();
1137
+ }
1138
+ }
1139
+ finally {
1140
+ // The port and the pointer would outlive a same-PID re-exec, so no earlier failure may skip them.
1141
+ try {
1142
+ localApiServer.stop();
1143
+ }
1144
+ finally {
1145
+ cleanupPointer();
1146
+ }
1147
+ }
1148
+ runner.stopAll();
1149
+ loopbackServer.closeAll();
1150
+ directPeers.closeAll();
1151
+ watchRegistry.dispose();
1152
+ // node-datachannel module-level cleanup — avoids the native crash-on-exit race.
1153
+ try {
1154
+ ndcCleanup();
1155
+ }
1156
+ catch { /* native lib already torn down */ }
1157
+ centrifugo.disconnect();
1158
+ try {
1159
+ await security.close();
1160
+ }
1161
+ catch (err) {
1162
+ console.error('[Bridge] Failed to persist security shutdown state.');
1163
+ throw err;
1164
+ }
1165
+ })();
1166
+ return shutdownRun;
1167
+ }
1168
+ const shutdownAndExit = async () => {
1065
1169
  try {
1066
- await security.close();
1067
- process.exit(0);
1170
+ await shutdown();
1068
1171
  }
1069
1172
  catch {
1070
- console.error('[Bridge] Failed to persist security shutdown state.');
1071
1173
  process.exit(1);
1072
1174
  }
1175
+ process.exit(0);
1073
1176
  };
1074
- process.on('SIGINT', shutdown);
1075
- process.on('SIGTERM', shutdown);
1177
+ releaseForRestart = shutdown;
1178
+ process.on('SIGINT', () => { void shutdownAndExit(); });
1179
+ process.on('SIGTERM', () => { void shutdownAndExit(); });
1076
1180
  });
1077
1181
  registerLocalBrowserCommands(program);
1078
1182
  try {