ftown 0.20.8 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/dist/centrifugo-client.d.ts +15 -11
  2. package/dist/centrifugo-client.js +63 -70
  3. package/dist/centrifugo-client.js.map +1 -1
  4. package/dist/cli-client.d.ts +11 -1
  5. package/dist/cli-client.js +23 -3
  6. package/dist/cli-client.js.map +1 -1
  7. package/dist/command-rpc.js +4 -8
  8. package/dist/command-rpc.js.map +1 -1
  9. package/dist/identity-reset.d.ts +11 -0
  10. package/dist/identity-reset.js +192 -0
  11. package/dist/identity-reset.js.map +1 -0
  12. package/dist/index.js +276 -279
  13. package/dist/index.js.map +1 -1
  14. package/dist/ledger-pairing-client.d.ts +28 -0
  15. package/dist/ledger-pairing-client.js +152 -0
  16. package/dist/ledger-pairing-client.js.map +1 -0
  17. package/dist/ledger-relay.d.ts +124 -0
  18. package/dist/ledger-relay.js +453 -0
  19. package/dist/ledger-relay.js.map +1 -0
  20. package/dist/ledger-startup.d.ts +151 -0
  21. package/dist/ledger-startup.js +203 -0
  22. package/dist/ledger-startup.js.map +1 -0
  23. package/dist/relay-token.d.ts +39 -0
  24. package/dist/relay-token.js +130 -0
  25. package/dist/relay-token.js.map +1 -0
  26. package/dist/revocation-gating.d.ts +15 -0
  27. package/dist/revocation-gating.js +16 -0
  28. package/dist/revocation-gating.js.map +1 -0
  29. package/dist/revoked-restart.d.ts +21 -0
  30. package/dist/revoked-restart.js +55 -0
  31. package/dist/revoked-restart.js.map +1 -0
  32. package/dist/security/bridge/identity.d.ts +2 -47
  33. package/dist/security/bridge/identity.js +3 -3
  34. package/dist/security/bridge/identity.js.map +1 -1
  35. package/dist/security/bridge/ledger-identity.d.ts +48 -0
  36. package/dist/security/bridge/ledger-identity.js +342 -0
  37. package/dist/security/bridge/ledger-identity.js.map +1 -0
  38. package/dist/security/bridge/ledger-store.d.ts +84 -0
  39. package/dist/security/bridge/ledger-store.js +485 -0
  40. package/dist/security/bridge/ledger-store.js.map +1 -0
  41. package/dist/security/bridge/metadata.d.ts +2 -2
  42. package/dist/security/bridge/permissions.js +2 -2
  43. package/dist/security/bridge/permissions.js.map +1 -1
  44. package/dist/security/bridge/policy.d.ts +42 -21
  45. package/dist/security/bridge/policy.js +217 -135
  46. package/dist/security/bridge/policy.js.map +1 -1
  47. package/dist/security/bridge/security.d.ts +91 -28
  48. package/dist/security/bridge/security.js +274 -117
  49. package/dist/security/bridge/security.js.map +1 -1
  50. package/dist/security/crypto.js +1 -1
  51. package/dist/security/crypto.js.map +1 -1
  52. package/dist/security/ledger/channels.d.ts +5 -0
  53. package/dist/security/ledger/channels.js +21 -0
  54. package/dist/security/ledger/channels.js.map +1 -0
  55. package/dist/security/ledger/ledger.d.ts +61 -0
  56. package/dist/security/ledger/ledger.js +597 -0
  57. package/dist/security/ledger/ledger.js.map +1 -0
  58. package/dist/security/ledger/pairing.d.ts +81 -0
  59. package/dist/security/ledger/pairing.js +414 -0
  60. package/dist/security/ledger/pairing.js.map +1 -0
  61. package/dist/security/ledger/proof.d.ts +24 -0
  62. package/dist/security/ledger/proof.js +161 -0
  63. package/dist/security/ledger/proof.js.map +1 -0
  64. package/dist/security/ledger/protocol.d.ts +100 -0
  65. package/dist/security/ledger/protocol.js +16 -0
  66. package/dist/security/ledger/protocol.js.map +1 -0
  67. package/dist/security/ledger/testing.d.ts +27 -0
  68. package/dist/security/ledger/testing.js +70 -0
  69. package/dist/security/ledger/testing.js.map +1 -0
  70. package/dist/security/ledger/webauthn.d.ts +12 -0
  71. package/dist/security/ledger/webauthn.js +147 -0
  72. package/dist/security/ledger/webauthn.js.map +1 -0
  73. package/dist/security/protocol.d.ts +5 -10
  74. package/dist/security-cli.d.ts +5 -0
  75. package/dist/security-cli.js +28 -16
  76. package/dist/security-cli.js.map +1 -1
  77. package/dist/token-provider.d.ts +39 -0
  78. package/dist/token-provider.js +101 -0
  79. package/dist/token-provider.js.map +1 -0
  80. package/dist/types.d.ts +1 -1
  81. package/package.json +2 -2
  82. package/skills/ftown/references/sessions.md +4 -0
  83. package/dist/bridge-auth.d.ts +0 -25
  84. package/dist/bridge-auth.js +0 -56
  85. package/dist/bridge-auth.js.map +0 -1
  86. package/dist/pairing-client.d.ts +0 -60
  87. package/dist/pairing-client.js +0 -460
  88. package/dist/pairing-client.js.map +0 -1
  89. package/dist/rotating-token-refresher.d.ts +0 -42
  90. package/dist/rotating-token-refresher.js +0 -91
  91. package/dist/rotating-token-refresher.js.map +0 -1
  92. package/dist/security/account-crypto.d.ts +0 -33
  93. package/dist/security/account-crypto.js +0 -649
  94. package/dist/security/account-crypto.js.map +0 -1
  95. package/dist/security/account-protocol.d.ts +0 -228
  96. package/dist/security/account-protocol.js +0 -2
  97. package/dist/security/account-protocol.js.map +0 -1
  98. package/dist/security/bridge/account-client.d.ts +0 -7
  99. package/dist/security/bridge/account-client.js +0 -87
  100. package/dist/security/bridge/account-client.js.map +0 -1
  101. package/dist/security/bridge/account-state.d.ts +0 -105
  102. package/dist/security/bridge/account-state.js +0 -79
  103. package/dist/security/bridge/account-state.js.map +0 -1
  104. package/dist/security/bridge/pending-approval.d.ts +0 -21
  105. package/dist/security/bridge/pending-approval.js +0 -80
  106. package/dist/security/bridge/pending-approval.js.map +0 -1
  107. package/dist/security/bridge/pending-enrollment.d.ts +0 -33
  108. package/dist/security/bridge/pending-enrollment.js +0 -177
  109. package/dist/security/bridge/pending-enrollment.js.map +0 -1
  110. package/dist/security/pending-approval-crypto.d.ts +0 -29
  111. package/dist/security/pending-approval-crypto.js +0 -716
  112. package/dist/security/pending-approval-crypto.js.map +0 -1
  113. package/dist/security/pending-approval-protocol.d.ts +0 -329
  114. package/dist/security/pending-approval-protocol.js +0 -2
  115. package/dist/security/pending-approval-protocol.js.map +0 -1
  116. package/dist/security/security-nudge.d.ts +0 -31
  117. package/dist/security/security-nudge.js +0 -98
  118. package/dist/security/security-nudge.js.map +0 -1
@@ -1,16 +1,27 @@
1
1
  import type { ApprovedEndpoint, BridgeInvitation, EndpointPermission, EndpointPublic, EnrollmentMessage, InvitationOptions, LocalRecoveryAuthority, VerifiedPrincipal } from '../protocol.js';
2
- import type { AccountHead, AccountHistory, StoredAccountTrust, VerifiedAccountTrust } from '../account-protocol.js';
3
- import type { SecurityErrorCode } from '../protocol.js';
4
- /** Pre-removal policy files carry `mcp: []`, mcp devices and mcp provenance; the strict schema would reject them and brick the bridge. */
2
+ import { type LedgerCheckpoint } from '../ledger/protocol.js';
3
+ /** Pre-removal policy files carry `mcp: []`, mcp devices and mcp provenance; the strict schema would reject them and brick the bridge.
4
+ * Files written before the ledger carry `account: null` (Solo/local always did): dropped. A non-null v1 account section is kept, so
5
+ * the strict schema refuses it: v1 account state is never reinterpreted (startup resets such a bridge's identity instead). */
5
6
  export declare function migrateLegacyPolicy(raw: unknown): unknown;
6
7
  /** Identity comparison matters: request JSON cannot manufacture this recovery capability. */
7
8
  export declare const LOCAL_RECOVERY_AUTHORITY: LocalRecoveryAuthority;
8
9
  export interface PolicyOptions {
9
10
  now?: () => number;
10
- monotonicNow?: () => number;
11
11
  relayUrl?: string;
12
12
  localOrigin?: string;
13
13
  }
14
+ /** This bridge in its trusted ledger: 'unpaired' = no ledger stored, 'absent' = not listed with exactly its keys (or another
15
+ * account's ledger), 'revoked' = its bridgeId is revoked (signed, or sticky from any chain the replica kept or adopted). */
16
+ export type LedgerOwnStatus = 'active' | 'revoked' | 'absent' | 'unpaired';
17
+ /** The account-mode authority, held in memory and replaced only by BridgeSecurityService. */
18
+ export interface LedgerView {
19
+ /** applyRevocations(checkpoint, sticky) of the stored chain; null while no ledger is stored. Never the raw checkpoint. */
20
+ trusted: LedgerCheckpoint | null;
21
+ own: LedgerOwnStatus;
22
+ /** The ledger store's lastServerReadAt (null = never). Read once, on the first projection; afterwards contacts set it. */
23
+ lastServerReadAt: number | null;
24
+ }
14
25
  /** Bridge-owned authorization. Directory entries and caller-claimed permissions never enter this store. */
15
26
  export declare class BridgePolicyStore {
16
27
  readonly bridgeId: string;
@@ -18,10 +29,22 @@ export declare class BridgePolicyStore {
18
29
  private readonly options;
19
30
  private readonly snapshot;
20
31
  private readonly now;
21
- private readonly connectivity;
32
+ private readonly clockFile;
33
+ /** Account mode (enableLedger): null for Solo and --local, whose devices come only from invitations and the local CLI. */
34
+ private ledger;
22
35
  constructor(dataDir: string, bridgeId: string, bridge: EndpointPublic, options?: PolicyOptions);
23
36
  private check;
24
37
  private active;
38
+ /** Account mode. A revoked id is never trusted again. The OS-local CLI stays outside the ledger; every other endpoint must be a
39
+ * projected ledger device that the CURRENT trusted view lists with the same key, while this bridge is active and in offline grace. */
40
+ private ledgerAccess;
41
+ /** Written so that NaN, an unknown contact or an unread clock file fails closed. A passing check raises the high-water. */
42
+ private withinGrace;
43
+ /** Loads the persisted high-water once (absent: none; unreadable: a contact in this process is then required). */
44
+ private loadClock;
45
+ /** Persists the observed high-water (after a contact, and on close). An unreadable file is only replaced after a fresh contact. */
46
+ persistLedgerClock(): Promise<void>;
47
+ private refuseInAccountMode;
25
48
  private principalFrom;
26
49
  private authorize;
27
50
  principal(endpointId: string): Promise<VerifiedPrincipal>;
@@ -32,21 +55,19 @@ export declare class BridgePolicyStore {
32
55
  recipients(): Promise<ApprovedEndpoint[]>;
33
56
  revoke(actor: VerifiedPrincipal, endpointId: string): Promise<void>;
34
57
  provisionLocalEndpoint(endpoint: EndpointPublic): Promise<void>;
35
- accountTrust(): Promise<StoredAccountTrust | null>;
36
- accountEndpointIds(): Promise<string[]>;
37
- blockAccount(code: SecurityErrorCode): void;
38
- accountAvailable(): Promise<boolean>;
39
- /** Trusted startup bootstrap only; no remote endpoint can call this entry point. */
40
- pinBootstrap(trust: VerifiedAccountTrust): Promise<void>;
41
- private requireOwnBridge;
42
- private projectAccount;
43
- /** Only an authenticated API read reaches here; browser-supplied timestamps never do. */
44
- applyAccountRead(history: AccountHistory, adoption?: {
45
- actor: VerifiedPrincipal;
46
- head: AccountHead;
47
- }): Promise<{
48
- head: AccountHead;
49
- revoked: string[];
50
- }>;
58
+ /** Enters account mode for this instance (irreversible): until projectLedger supplies a view, every non-local endpoint fails closed,
59
+ * and invitations are refused. Only BridgeSecurityService calls this, with a ledger configured. */
60
+ enableLedger(now: () => number, monotonic: () => number): void;
61
+ /**
62
+ * Account mode only. Installs `view` synchronously (a revocation never waits for the write below), then makes the provenance-'account'
63
+ * devices exactly `view.trusted.devices` (none when trusted is null or another account's). Devices of any other provenance are never
64
+ * touched, and a ledger device whose id is already held by one is skipped. Returns the endpoint ids that lost account membership.
65
+ * `view.lastServerReadAt` is taken only on the first projection; later contacts come from noteServerRead.
66
+ */
67
+ projectLedger(view: LedgerView): Promise<string[]>;
68
+ /** Account mode: a successful server contact at wall time `at` re-anchors offline grace (lastServerReadAt = at, even if earlier: a
69
+ * contact stamped during a forward clock jump never outlives its correction), restarts the monotonic bound and raises the high-water.
70
+ * Returns `at`, or null (nothing changed) outside account mode or for an invalid time. */
71
+ noteServerRead(at: number): number | null;
51
72
  close(): Promise<void>;
52
73
  }
@@ -1,23 +1,27 @@
1
1
  import { legacyDateTimeSchema } from '../../zod-compat.js';
2
2
  import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
3
- import { join } from 'node:path';
3
+ import { mkdir, open, rename, rm } from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
4
5
  import { z } from 'zod';
5
6
  import { BridgeSecurityError } from './errors.js';
6
7
  import { approvedSchema, displayLabel, endpointSchema, permissionSchema } from './metadata.js';
7
8
  import { AtomicSnapshot } from './snapshot.js';
8
- import { verifyAccountHistory } from '../account-crypto.js';
9
- import { AccountConnectivity, storedAccountSchema } from './account-state.js';
9
+ import { LEDGER_LIMITS } from '../ledger/protocol.js';
10
10
  const FIVE_MINUTES = 300_000;
11
11
  const MAX_ENDPOINTS = 1000;
12
12
  const digest = (secret) => createHash('sha256').update(secret).digest('hex');
13
13
  const invitationSchema = z.object({ id: z.string().guid(), secretHash: z.string().regex(/^[a-f0-9]{64}$/),
14
14
  label: z.string().max(100), permissions: z.array(permissionSchema).max(3), expiresAt: legacyDateTimeSchema,
15
- consumedBy: z.string().nullable(), provenance: z.enum(['legacy', 'local-recovery']).default('legacy') }).strict();
16
- /** Pre-removal policy files carry `mcp: []`, mcp devices and mcp provenance; the strict schema would reject them and brick the bridge. */
15
+ consumedBy: z.string().nullable(), provenance: z.literal('legacy').default('legacy') }).strict();
16
+ /** Pre-removal policy files carry `mcp: []`, mcp devices and mcp provenance; the strict schema would reject them and brick the bridge.
17
+ * Files written before the ledger carry `account: null` (Solo/local always did): dropped. A non-null v1 account section is kept, so
18
+ * the strict schema refuses it: v1 account state is never reinterpreted (startup resets such a bridge's identity instead). */
17
19
  export function migrateLegacyPolicy(raw) {
18
20
  if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
19
21
  return raw;
20
22
  const { mcp: _mcp, ...rest } = raw;
23
+ if (rest.account === null)
24
+ delete rest.account;
21
25
  const isMcpDevice = (device) => typeof device === 'object' && device !== null
22
26
  && device.endpoint?.kind === 'mcp';
23
27
  const dropped = new Set();
@@ -40,8 +44,8 @@ const policySchema = z.preprocess(migrateLegacyPolicy, z.object({ version: z.lit
40
44
  invitations: z.array(invitationSchema).max(256),
41
45
  // Frozen: earlier releases require this key, so a downgrade must still parse what we write.
42
46
  mcp: z.array(z.never()).max(0).default([]),
43
- account: storedAccountSchema.nullable().default(null),
44
- provenance: z.record(z.string(), z.enum(['legacy', 'local-recovery', 'account', 'local-cli'])).default({}),
47
+ // 'account': projected from the ledger replica (account mode only). 'legacy': enrolled by invitation. 'local-cli': OS-local CLI.
48
+ provenance: z.record(z.string(), z.enum(['legacy', 'account', 'local-cli'])).default({}),
45
49
  }).strict().superRefine((state, context) => {
46
50
  if (new Set(state.devices.map(device => device.endpoint.id)).size !== state.devices.length
47
51
  || state.devices.some(device => device.endpoint.ownerId !== state.ownerId
@@ -52,6 +56,59 @@ const policySchema = z.preprocess(migrateLegacyPolicy, z.object({ version: z.lit
52
56
  }));
53
57
  /** Identity comparison matters: request JSON cannot manufacture this recovery capability. */
54
58
  export const LOCAL_RECOVERY_AUTHORITY = Object.freeze({ source: 'os-local-recovery' });
59
+ const isTime = (value) => value !== null && Number.isSafeInteger(value) && value >= 0;
60
+ const CLOCK_KEYS = ['version', 'observedAt'];
61
+ async function readClock(file) {
62
+ let text;
63
+ try {
64
+ const handle = await open(file, 'r');
65
+ try {
66
+ if ((await handle.stat()).size > 4096)
67
+ return 'corrupt';
68
+ text = await handle.readFile('utf8');
69
+ }
70
+ finally {
71
+ await handle.close();
72
+ }
73
+ }
74
+ catch (error) {
75
+ return error.code === 'ENOENT' ? 'absent' : 'corrupt';
76
+ }
77
+ try {
78
+ const value = JSON.parse(text);
79
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
80
+ return 'corrupt';
81
+ const record = value;
82
+ const keys = Object.keys(record);
83
+ const observedAt = record.observedAt;
84
+ if (keys.length !== CLOCK_KEYS.length || keys.some(key => !CLOCK_KEYS.includes(key)) || record.version !== 1
85
+ || typeof observedAt !== 'number' || !isTime(observedAt))
86
+ return 'corrupt';
87
+ return observedAt;
88
+ }
89
+ catch {
90
+ return 'corrupt';
91
+ }
92
+ }
93
+ async function writeClock(file, observedAt) {
94
+ const temporary = `${file}.${randomUUID()}.tmp`;
95
+ try {
96
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 });
97
+ const handle = await open(temporary, 'wx', 0o600);
98
+ try {
99
+ await handle.writeFile(JSON.stringify({ version: 1, observedAt }));
100
+ await handle.sync();
101
+ }
102
+ finally {
103
+ await handle.close();
104
+ }
105
+ await rename(temporary, file);
106
+ }
107
+ catch {
108
+ await rm(temporary, { force: true }).catch(() => undefined);
109
+ throw new BridgeSecurityError('storage_unavailable');
110
+ }
111
+ }
55
112
  /** Bridge-owned authorization. Directory entries and caller-claimed permissions never enter this store. */
56
113
  export class BridgePolicyStore {
57
114
  bridgeId;
@@ -59,7 +116,9 @@ export class BridgePolicyStore {
59
116
  options;
60
117
  snapshot;
61
118
  now;
62
- connectivity;
119
+ clockFile;
120
+ /** Account mode (enableLedger): null for Solo and --local, whose devices come only from invitations and the local CLI. */
121
+ ledger = null;
63
122
  constructor(dataDir, bridgeId, bridge, options = {}) {
64
123
  this.bridgeId = bridgeId;
65
124
  this.bridge = bridge;
@@ -68,10 +127,10 @@ export class BridgePolicyStore {
68
127
  if (bridge.kind !== 'bridge')
69
128
  throw new BridgeSecurityError('wrong_context');
70
129
  this.now = options.now ?? Date.now;
71
- this.connectivity = new AccountConnectivity(this.now, options.monotonicNow ?? (() => performance.now()));
130
+ this.clockFile = join(dataDir, 'security', 'ledger-clock.json');
72
131
  this.snapshot = new AtomicSnapshot(join(dataDir, 'security', 'policy.json'), policySchema, () => ({
73
132
  version: 1, ownerId: bridge.ownerId, bridgeId, bridgeEndpointId: bridge.id, generation: 0,
74
- devices: [], invitations: [], mcp: [], account: null, provenance: {},
133
+ devices: [], invitations: [], mcp: [], provenance: {},
75
134
  }));
76
135
  }
77
136
  check(state) {
@@ -81,23 +140,89 @@ export class BridgePolicyStore {
81
140
  }
82
141
  active(state, endpointId) {
83
142
  this.check(state);
84
- const account = state.account;
85
- const member = account?.checkpoint.devices.find(item => item.endpoint.id === endpointId);
86
- if (member) {
87
- this.connectivity.check(account);
88
- const own = account.checkpoint.bridges.find(item => item.bridgeId === this.bridgeId);
89
- if (!own || own.endpoint.id !== this.bridge.id || own.endpoint.publicKey !== this.bridge.publicKey)
90
- throw new BridgeSecurityError('account_bridge_revoked');
91
- }
92
- else if (account?.checkpoint.revokedDeviceIds.includes(endpointId))
93
- throw new BridgeSecurityError('account_device_revoked');
94
143
  const record = state.devices.find(device => device.endpoint.id === endpointId);
144
+ if (this.ledger)
145
+ this.ledgerAccess(state, endpointId, record);
146
+ // Without a ledger nothing vouches for a projected account device: it never authorizes.
147
+ else if (state.provenance[endpointId] === 'account')
148
+ throw new BridgeSecurityError('approval_required');
95
149
  if (!record)
96
150
  throw new BridgeSecurityError('approval_required');
97
151
  if (record.expiresAt && Date.parse(record.expiresAt) <= this.now())
98
152
  throw new BridgeSecurityError('expired');
99
153
  return record;
100
154
  }
155
+ /** Account mode. A revoked id is never trusted again. The OS-local CLI stays outside the ledger; every other endpoint must be a
156
+ * projected ledger device that the CURRENT trusted view lists with the same key, while this bridge is active and in offline grace. */
157
+ ledgerAccess(state, endpointId, record) {
158
+ const view = this.ledger.view;
159
+ if (view.trusted?.revokedDeviceIds.includes(endpointId))
160
+ throw new BridgeSecurityError('account_device_revoked');
161
+ if (!record || record.endpoint.kind === 'local-cli')
162
+ return;
163
+ if (state.provenance[endpointId] !== 'account')
164
+ throw new BridgeSecurityError('approval_required');
165
+ if (view.trusted === null)
166
+ throw new BridgeSecurityError('account_offline_expired');
167
+ if (view.own !== 'active')
168
+ throw new BridgeSecurityError('account_bridge_revoked');
169
+ if (!this.withinGrace())
170
+ throw new BridgeSecurityError('account_offline_expired');
171
+ const member = view.trusted.devices.find(item => item.endpoint.id === endpointId);
172
+ if (!member || member.endpoint.publicKey !== record.endpoint.publicKey)
173
+ throw new BridgeSecurityError('account_device_revoked');
174
+ }
175
+ /** Written so that NaN, an unknown contact or an unread clock file fails closed. A passing check raises the high-water. */
176
+ withinGrace() {
177
+ const gate = this.ledger;
178
+ const at = gate.now();
179
+ const monotonic = gate.monotonic();
180
+ const last = gate.lastServerReadAt;
181
+ const { offlineGraceMs: grace, maxFutureSkewMs: skew } = LEDGER_LIMITS;
182
+ const within = gate.clock !== 'loading' && (gate.clock === 'trusted' || gate.contactAt !== null)
183
+ && isTime(last) && Number.isSafeInteger(at)
184
+ // The wall clock went back before the last contact, or before a time this bridge already observed (also across restarts).
185
+ && at >= last - skew && at >= gate.highWater - skew
186
+ && at - last <= grace
187
+ && (gate.contactAt === null || (Number.isFinite(monotonic) && monotonic >= gate.contactAt && monotonic - gate.contactAt <= grace));
188
+ if (within && at > gate.highWater)
189
+ gate.highWater = at;
190
+ return within;
191
+ }
192
+ /** Loads the persisted high-water once (absent: none; unreadable: a contact in this process is then required). */
193
+ loadClock() {
194
+ const gate = this.ledger;
195
+ gate.clockLoad ??= readClock(this.clockFile).then(stored => {
196
+ if (typeof stored === 'number') {
197
+ gate.highWater = Math.max(gate.highWater, stored);
198
+ gate.persisted = stored;
199
+ }
200
+ gate.clock = stored === 'corrupt' ? 'untrusted' : 'trusted';
201
+ });
202
+ return gate.clockLoad;
203
+ }
204
+ /** Persists the observed high-water (after a contact, and on close). An unreadable file is only replaced after a fresh contact. */
205
+ async persistLedgerClock() {
206
+ const gate = this.ledger;
207
+ if (!gate)
208
+ return;
209
+ await this.loadClock();
210
+ if (gate.clock === 'untrusted' && gate.contactAt === null)
211
+ return;
212
+ const write = gate.clockWrite.then(async () => {
213
+ const observedAt = gate.highWater;
214
+ if (observedAt <= gate.persisted)
215
+ return;
216
+ await writeClock(this.clockFile, observedAt);
217
+ gate.persisted = Math.max(gate.persisted, observedAt);
218
+ });
219
+ gate.clockWrite = write.catch(() => undefined);
220
+ await write;
221
+ }
222
+ refuseInAccountMode() {
223
+ if (this.ledger)
224
+ throw new BridgeSecurityError('account_managed');
225
+ }
101
226
  principalFrom(state, record) {
102
227
  return { endpoint: record.endpoint, bridgeId: this.bridgeId, permissions: record.permissions,
103
228
  policyGeneration: state.generation, grantId: record.grantId };
@@ -135,16 +260,15 @@ export class BridgePolicyStore {
135
260
  const expiresAt = new Date(this.now() + FIVE_MINUTES).toISOString();
136
261
  await this.snapshot.transact(state => {
137
262
  this.check(state);
138
- if (actor !== LOCAL_RECOVERY_AUTHORITY) {
263
+ // Account mode: browsers join only through the ledger, whoever asks.
264
+ this.refuseInAccountMode();
265
+ if (actor !== LOCAL_RECOVERY_AUTHORITY)
139
266
  this.authorize(state, actor, 'admin');
140
- if (state.account && actor.endpoint.kind !== 'local-cli')
141
- throw new BridgeSecurityError('account_approval_required');
142
- }
143
267
  state.invitations = state.invitations.filter(invitation => Date.parse(invitation.expiresAt) > this.now());
144
268
  if (state.invitations.length >= 256)
145
269
  throw new BridgeSecurityError('capacity_exceeded');
146
270
  state.invitations.push({ id: invitationId, secretHash: digest(secret), label: displayLabel(options.label),
147
- permissions: ['read', 'control', 'admin'], expiresAt, consumedBy: null, provenance: state.account ? 'local-recovery' : 'legacy' });
271
+ permissions: ['read', 'control', 'admin'], expiresAt, consumedBy: null, provenance: 'legacy' });
148
272
  });
149
273
  return { version: 1, invitationId, ownerId: this.bridge.ownerId, bridgeId: this.bridgeId, bridge: this.bridge,
150
274
  secret, expiresAt, relayUrl: this.options.relayUrl ?? null, localOrigin: this.options.localOrigin ?? null };
@@ -162,18 +286,13 @@ export class BridgePolicyStore {
162
286
  throw new BridgeSecurityError('invitation_invalid');
163
287
  return this.snapshot.transact(state => {
164
288
  this.check(state);
289
+ this.refuseInAccountMode();
165
290
  const invitation = state.invitations.find(item => item.id === message.invitationId);
166
291
  if (!invitation || !timingSafeEqual(Buffer.from(invitation.secretHash), Buffer.from(digest(message.invitationSecret)))) {
167
292
  throw new BridgeSecurityError('invitation_invalid');
168
293
  }
169
294
  if (Date.parse(invitation.expiresAt) <= this.now())
170
295
  throw new BridgeSecurityError('invitation_expired');
171
- if (state.account && invitation.provenance !== 'local-recovery')
172
- throw new BridgeSecurityError('invitation_invalid');
173
- // Independent recovery must never shadow an account membership or revive its tombstone.
174
- if (state.account && (state.account.checkpoint.devices.some(item => item.endpoint.id === candidate.id)
175
- || state.account.checkpoint.revokedDeviceIds.includes(candidate.id)))
176
- throw new BridgeSecurityError('account_approval_required');
177
296
  if (invitation.consumedBy) {
178
297
  if (invitation.consumedBy !== candidate.id)
179
298
  throw new BridgeSecurityError('invitation_consumed');
@@ -200,7 +319,7 @@ export class BridgePolicyStore {
200
319
  const provenance = state.provenance[device.endpoint.id];
201
320
  if (provenance === 'account')
202
321
  return { ...device, source: 'account' };
203
- if (provenance === 'local-cli' || provenance === 'local-recovery')
322
+ if (provenance === 'local-cli')
204
323
  return { ...device, source: 'local' };
205
324
  return { ...device };
206
325
  });
@@ -222,8 +341,10 @@ export class BridgePolicyStore {
222
341
  async revoke(actor, endpointId) {
223
342
  await this.snapshot.transact(state => {
224
343
  this.authorize(state, actor, 'admin');
225
- if (state.account?.checkpoint.devices.some(item => item.endpoint.id === endpointId))
226
- throw new BridgeSecurityError('account_approval_required');
344
+ // A ledger device leaves only by a passkey-signed revoke_device; the bridge never drops it on request.
345
+ if (this.ledger && (state.provenance[endpointId] === 'account'
346
+ || this.ledger.view.trusted?.devices.some(item => item.endpoint.id === endpointId)))
347
+ throw new BridgeSecurityError('account_managed');
227
348
  state.devices = state.devices.filter(device => device.endpoint.id !== endpointId);
228
349
  delete state.provenance[endpointId];
229
350
  state.generation++;
@@ -245,116 +366,77 @@ export class BridgePolicyStore {
245
366
  state.provenance[candidate.id] = 'local-cli';
246
367
  });
247
368
  }
248
- async accountTrust() {
249
- return this.snapshot.read(state => { this.check(state); return state.account; });
250
- }
251
- async accountEndpointIds() {
252
- return this.snapshot.read(state => { this.check(state); return state.account?.checkpoint.devices.map(item => item.endpoint.id) ?? []; });
369
+ /** Enters account mode for this instance (irreversible): until projectLedger supplies a view, every non-local endpoint fails closed,
370
+ * and invitations are refused. Only BridgeSecurityService calls this, with a ledger configured. */
371
+ enableLedger(now, monotonic) {
372
+ this.ledger ??= { view: { trusted: null, own: 'unpaired' }, now, monotonic, loaded: false, lastServerReadAt: null, contactAt: null,
373
+ highWater: 0, persisted: 0, clock: 'loading', clockLoad: null, clockWrite: Promise.resolve() };
253
374
  }
254
- blockAccount(code) { this.connectivity.block(code); }
255
- async accountAvailable() {
256
- return this.snapshot.read(state => {
375
+ /**
376
+ * Account mode only. Installs `view` synchronously (a revocation never waits for the write below), then makes the provenance-'account'
377
+ * devices exactly `view.trusted.devices` (none when trusted is null or another account's). Devices of any other provenance are never
378
+ * touched, and a ledger device whose id is already held by one is skipped. Returns the endpoint ids that lost account membership.
379
+ * `view.lastServerReadAt` is taken only on the first projection; later contacts come from noteServerRead.
380
+ */
381
+ projectLedger(view) {
382
+ const ledger = this.ledger;
383
+ if (!ledger)
384
+ return Promise.reject(new BridgeSecurityError('wrong_context'));
385
+ const trusted = view.trusted !== null && view.trusted.accountId === this.bridge.ownerId ? view.trusted : null;
386
+ ledger.view = { trusted, own: trusted === null && view.own === 'active' ? 'absent' : view.own };
387
+ if (!ledger.loaded) {
388
+ ledger.loaded = true;
389
+ ledger.lastServerReadAt = isTime(view.lastServerReadAt) ? view.lastServerReadAt : null;
390
+ }
391
+ const clock = this.loadClock();
392
+ const members = trusted === null ? [] : trusted.devices;
393
+ return clock.then(() => this.snapshot.transact(state => {
257
394
  this.check(state);
258
- if (!state.account)
259
- return true;
260
- try {
261
- this.connectivity.check(state.account);
262
- return state.account.checkpoint.bridges.some(item => item.bridgeId === this.bridgeId && item.endpoint.id === this.bridge.id);
263
- }
264
- catch {
395
+ const removed = [];
396
+ state.devices = state.devices.filter(device => {
397
+ const id = device.endpoint.id;
398
+ if (state.provenance[id] !== 'account')
399
+ return true;
400
+ const member = members.find(item => item.endpoint.id === id);
401
+ if (member && member.endpoint.publicKey === device.endpoint.publicKey)
402
+ return true;
403
+ removed.push(id);
404
+ delete state.provenance[id];
265
405
  return false;
266
- }
267
- });
268
- }
269
- /** Trusted startup bootstrap only; no remote endpoint can call this entry point. */
270
- async pinBootstrap(trust) {
271
- await this.snapshot.transact(async (state) => {
272
- this.check(state);
273
- if (state.account) {
274
- if (state.account.checkpoint.rootId !== trust.checkpoint.rootId)
275
- throw new BridgeSecurityError('account_trust_rollback');
276
- return;
277
- }
278
- const checkpoint = await verifyAccountHistory(trust.history, trust.checkpoint, this.now());
279
- this.requireOwnBridge(checkpoint);
280
- this.projectAccount(state, { ...trust, checkpoint, lastServerReadAt: 0, lastObservedAt: 0 });
281
- });
282
- }
283
- requireOwnBridge(checkpoint) {
284
- const own = checkpoint.bridges.find(item => item.bridgeId === this.bridgeId);
285
- if (checkpoint.ownerId !== this.bridge.ownerId || !own || own.endpoint.id !== this.bridge.id
286
- || own.endpoint.publicKey !== this.bridge.publicKey || own.endpoint.ownerId !== this.bridge.ownerId)
287
- throw new BridgeSecurityError('wrong_context');
288
- }
289
- projectAccount(state, trust) {
290
- const previous = state.devices.filter(item => item.endpoint.kind === 'browser');
291
- const first = state.account === null;
292
- const unchanged = state.account?.checkpoint.head === trust.checkpoint.head;
293
- state.devices = state.devices.filter(item => item.endpoint.kind !== 'browser'
294
- || (!first && state.provenance[item.endpoint.id] === 'local-recovery'
295
- && !trust.checkpoint.devices.some(member => member.endpoint.id === item.endpoint.id)
296
- && !trust.checkpoint.revokedDeviceIds.includes(item.endpoint.id)));
297
- if (first)
298
- state.invitations = [];
299
- for (const entry of previous)
300
- if (!state.devices.some(item => item.endpoint.id === entry.endpoint.id))
301
- delete state.provenance[entry.endpoint.id];
302
- const own = trust.checkpoint.bridges.find(item => item.bridgeId === this.bridgeId);
303
- if (own?.endpoint.id === this.bridge.id && own.endpoint.publicKey === this.bridge.publicKey) {
304
- for (const member of trust.checkpoint.devices) {
305
- if (state.devices.some(item => item.endpoint.id === member.endpoint.id))
306
- throw new BridgeSecurityError('wrong_context');
406
+ });
407
+ for (const member of members) {
408
+ if (state.devices.some(device => device.endpoint.id === member.endpoint.id))
409
+ continue;
410
+ if (state.devices.length >= MAX_ENDPOINTS)
411
+ throw new BridgeSecurityError('capacity_exceeded');
307
412
  state.devices.push({ endpoint: member.endpoint, label: displayLabel(member.label), permissions: ['read', 'control', 'admin'],
308
- approvedAt: member.approvedAt, grantId: null, expiresAt: null });
413
+ approvedAt: new Date(member.approvedAt).toISOString(), grantId: null, expiresAt: null });
309
414
  state.provenance[member.endpoint.id] = 'account';
310
415
  }
311
- }
312
- state.account = trust;
313
- if (!unchanged)
314
- state.generation++;
315
- return previous.filter(item => !state.devices.some(next => next.endpoint.id === item.endpoint.id)).map(item => item.endpoint.id);
416
+ // Never the global generation: a ledger change must not disturb the principals it leaves in place. A removed device is
417
+ // invalidated on its own: its record is gone (and the view refuses it), so its next principal/revalidate fails, and the
418
+ // caller emits onRevoked for it.
419
+ return removed;
420
+ }));
316
421
  }
317
- /** Only an authenticated API read reaches here; browser-supplied timestamps never do. */
318
- async applyAccountRead(history, adoption) {
319
- const result = await this.snapshot.transact(async (state) => {
320
- this.check(state);
321
- if (!state.account && !adoption)
322
- throw new BridgeSecurityError('account_approval_required');
323
- if (adoption) {
324
- if (adoption.actor.endpoint.kind !== 'browser')
325
- throw new BridgeSecurityError('permission_denied');
326
- this.authorize(state, adoption.actor, 'admin');
327
- }
328
- const checkpoint = await verifyAccountHistory(history, state.account?.checkpoint ?? null, this.now());
329
- if (checkpoint.ownerId !== this.bridge.ownerId)
330
- throw new BridgeSecurityError('wrong_context');
331
- if (adoption) {
332
- const head = adoption.head;
333
- if (head.rootId !== checkpoint.rootId || head.revision !== checkpoint.revision || head.head !== checkpoint.head)
334
- throw new BridgeSecurityError('account_trust_conflict');
335
- this.requireOwnBridge(checkpoint);
336
- const member = checkpoint.devices.find(item => item.endpoint.id === adoption.actor.endpoint.id);
337
- if (!member || member.endpoint.publicKey !== adoption.actor.endpoint.publicKey || member.endpoint.ownerId !== adoption.actor.endpoint.ownerId) {
338
- throw new BridgeSecurityError('account_device_revoked');
339
- }
340
- this.authorize(state, adoption.actor, 'admin');
341
- }
342
- const initial = state.account ?? { checkpoint, history, lastServerReadAt: 0, lastObservedAt: 0 };
343
- const now = this.connectivity.observed(initial);
344
- const trust = { checkpoint, history, lastServerReadAt: Math.max(initial.lastServerReadAt, now),
345
- lastObservedAt: Math.max(initial.lastObservedAt, now) };
346
- const revoked = this.projectAccount(state, trust);
347
- return { head: { rootId: checkpoint.rootId, revision: checkpoint.revision, head: checkpoint.head }, revoked, trust };
348
- });
349
- this.connectivity.renew(result.trust);
350
- return { head: result.head, revoked: result.revoked };
422
+ /** Account mode: a successful server contact at wall time `at` re-anchors offline grace (lastServerReadAt = at, even if earlier: a
423
+ * contact stamped during a forward clock jump never outlives its correction), restarts the monotonic bound and raises the high-water.
424
+ * Returns `at`, or null (nothing changed) outside account mode or for an invalid time. */
425
+ noteServerRead(at) {
426
+ const ledger = this.ledger;
427
+ if (!ledger || !isTime(at))
428
+ return null;
429
+ ledger.loaded = true;
430
+ ledger.lastServerReadAt = at;
431
+ ledger.contactAt = ledger.monotonic();
432
+ if (at > ledger.highWater)
433
+ ledger.highWater = at;
434
+ return at;
351
435
  }
352
436
  async close() {
353
- await this.snapshot.transact(state => {
354
- this.check(state);
355
- if (state.account)
356
- state.account.lastObservedAt = Math.max(state.account.lastObservedAt, this.connectivity.highwater());
357
- });
437
+ if (this.ledger) {
438
+ await this.persistLedgerClock().catch((error) => console.error('[Security] Saving the ledger clock failed:', error instanceof Error ? error.message : String(error)));
439
+ }
358
440
  await this.snapshot.close();
359
441
  }
360
442
  }