ftown 0.20.9 → 0.24.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 (154) hide show
  1. package/dist/attention-events.d.ts +1 -1
  2. package/dist/attention-store.d.ts +3 -0
  3. package/dist/attention-store.js +25 -1
  4. package/dist/attention-store.js.map +1 -1
  5. package/dist/centrifugo-client.d.ts +28 -11
  6. package/dist/centrifugo-client.js +84 -70
  7. package/dist/centrifugo-client.js.map +1 -1
  8. package/dist/cli-client.d.ts +11 -1
  9. package/dist/cli-client.js +23 -3
  10. package/dist/cli-client.js.map +1 -1
  11. package/dist/command-rpc.d.ts +2 -0
  12. package/dist/command-rpc.js +59 -17
  13. package/dist/command-rpc.js.map +1 -1
  14. package/dist/identity-reset.d.ts +4 -1
  15. package/dist/identity-reset.js +54 -6
  16. package/dist/identity-reset.js.map +1 -1
  17. package/dist/index.js +194 -288
  18. package/dist/index.js.map +1 -1
  19. package/dist/ledger-pairing-client.d.ts +28 -0
  20. package/dist/ledger-pairing-client.js +154 -0
  21. package/dist/ledger-pairing-client.js.map +1 -0
  22. package/dist/ledger-relay.d.ts +159 -0
  23. package/dist/ledger-relay.js +595 -0
  24. package/dist/ledger-relay.js.map +1 -0
  25. package/dist/ledger-reoffer.d.ts +56 -0
  26. package/dist/ledger-reoffer.js +210 -0
  27. package/dist/ledger-reoffer.js.map +1 -0
  28. package/dist/ledger-startup.d.ts +176 -0
  29. package/dist/ledger-startup.js +233 -0
  30. package/dist/ledger-startup.js.map +1 -0
  31. package/dist/local-api-server.d.ts +3 -0
  32. package/dist/local-api-server.js +68 -6
  33. package/dist/local-api-server.js.map +1 -1
  34. package/dist/mail-delivery.d.ts +26 -3
  35. package/dist/mail-delivery.js +65 -7
  36. package/dist/mail-delivery.js.map +1 -1
  37. package/dist/preview-protocol.d.ts +19 -3
  38. package/dist/preview-protocol.js +14 -0
  39. package/dist/preview-protocol.js.map +1 -1
  40. package/dist/relay-token.d.ts +45 -0
  41. package/dist/relay-token.js +138 -0
  42. package/dist/relay-token.js.map +1 -0
  43. package/dist/revocation-gating.d.ts +0 -13
  44. package/dist/revocation-gating.js +0 -16
  45. package/dist/revocation-gating.js.map +1 -1
  46. package/dist/security/bridge/identity.d.ts +2 -47
  47. package/dist/security/bridge/identity.js +3 -3
  48. package/dist/security/bridge/identity.js.map +1 -1
  49. package/dist/security/bridge/ledger-identity.d.ts +48 -0
  50. package/dist/security/bridge/ledger-identity.js +342 -0
  51. package/dist/security/bridge/ledger-identity.js.map +1 -0
  52. package/dist/security/bridge/ledger-rpc.d.ts +34 -0
  53. package/dist/security/bridge/ledger-rpc.js +58 -0
  54. package/dist/security/bridge/ledger-rpc.js.map +1 -0
  55. package/dist/security/bridge/ledger-store.d.ts +153 -0
  56. package/dist/security/bridge/ledger-store.js +825 -0
  57. package/dist/security/bridge/ledger-store.js.map +1 -0
  58. package/dist/security/bridge/metadata.d.ts +2 -2
  59. package/dist/security/bridge/permissions.js +4 -3
  60. package/dist/security/bridge/permissions.js.map +1 -1
  61. package/dist/security/bridge/policy.d.ts +43 -23
  62. package/dist/security/bridge/policy.js +214 -146
  63. package/dist/security/bridge/policy.js.map +1 -1
  64. package/dist/security/bridge/security.d.ts +212 -33
  65. package/dist/security/bridge/security.js +437 -165
  66. package/dist/security/bridge/security.js.map +1 -1
  67. package/dist/security/crypto.js +1 -1
  68. package/dist/security/crypto.js.map +1 -1
  69. package/dist/security/ledger/announce.d.ts +32 -0
  70. package/dist/security/ledger/announce.js +101 -0
  71. package/dist/security/ledger/announce.js.map +1 -0
  72. package/dist/security/ledger/channels.d.ts +5 -0
  73. package/dist/security/ledger/channels.js +21 -0
  74. package/dist/security/ledger/channels.js.map +1 -0
  75. package/dist/security/ledger/ledger.d.ts +154 -0
  76. package/dist/security/ledger/ledger.js +878 -0
  77. package/dist/security/ledger/ledger.js.map +1 -0
  78. package/dist/security/ledger/pairing.d.ts +81 -0
  79. package/dist/security/ledger/pairing.js +414 -0
  80. package/dist/security/ledger/pairing.js.map +1 -0
  81. package/dist/security/ledger/proof.d.ts +36 -0
  82. package/dist/security/ledger/proof.js +193 -0
  83. package/dist/security/ledger/proof.js.map +1 -0
  84. package/dist/security/ledger/protocol.d.ts +113 -0
  85. package/dist/security/ledger/protocol.js +16 -0
  86. package/dist/security/ledger/protocol.js.map +1 -0
  87. package/dist/security/ledger/testing.d.ts +27 -0
  88. package/dist/security/ledger/testing.js +70 -0
  89. package/dist/security/ledger/testing.js.map +1 -0
  90. package/dist/security/ledger/webauthn.d.ts +12 -0
  91. package/dist/security/ledger/webauthn.js +147 -0
  92. package/dist/security/ledger/webauthn.js.map +1 -0
  93. package/dist/security/protocol.d.ts +5 -10
  94. package/dist/security-cli.d.ts +5 -0
  95. package/dist/security-cli.js +28 -16
  96. package/dist/security-cli.js.map +1 -1
  97. package/dist/session-controller.d.ts +9 -3
  98. package/dist/session-controller.js +120 -20
  99. package/dist/session-controller.js.map +1 -1
  100. package/dist/session-inbox-protocol.d.ts +2 -0
  101. package/dist/session-preview.d.ts +20 -5
  102. package/dist/session-preview.js +185 -37
  103. package/dist/session-preview.js.map +1 -1
  104. package/dist/session-store.js +19 -1
  105. package/dist/session-store.js.map +1 -1
  106. package/dist/sessions-commands.js +41 -7
  107. package/dist/sessions-commands.js.map +1 -1
  108. package/dist/token-provider.d.ts +39 -0
  109. package/dist/token-provider.js +101 -0
  110. package/dist/token-provider.js.map +1 -0
  111. package/dist/types.d.ts +1 -1
  112. package/opencode-plugin/ftown.js +15 -2
  113. package/package.json +2 -2
  114. package/skills/ftown/SKILL.md +18 -7
  115. package/skills/ftown/references/sessions.md +38 -12
  116. package/dist/bridge-auth.d.ts +0 -31
  117. package/dist/bridge-auth.js +0 -70
  118. package/dist/bridge-auth.js.map +0 -1
  119. package/dist/pairing-client.d.ts +0 -62
  120. package/dist/pairing-client.js +0 -469
  121. package/dist/pairing-client.js.map +0 -1
  122. package/dist/rotating-token-refresher.d.ts +0 -42
  123. package/dist/rotating-token-refresher.js +0 -91
  124. package/dist/rotating-token-refresher.js.map +0 -1
  125. package/dist/security/account-crypto.d.ts +0 -33
  126. package/dist/security/account-crypto.js +0 -649
  127. package/dist/security/account-crypto.js.map +0 -1
  128. package/dist/security/account-protocol.d.ts +0 -228
  129. package/dist/security/account-protocol.js +0 -2
  130. package/dist/security/account-protocol.js.map +0 -1
  131. package/dist/security/bridge/account-client.d.ts +0 -7
  132. package/dist/security/bridge/account-client.js +0 -101
  133. package/dist/security/bridge/account-client.js.map +0 -1
  134. package/dist/security/bridge/account-state.d.ts +0 -105
  135. package/dist/security/bridge/account-state.js +0 -79
  136. package/dist/security/bridge/account-state.js.map +0 -1
  137. package/dist/security/bridge/pending-approval.d.ts +0 -21
  138. package/dist/security/bridge/pending-approval.js +0 -80
  139. package/dist/security/bridge/pending-approval.js.map +0 -1
  140. package/dist/security/bridge/pending-enrollment.d.ts +0 -33
  141. package/dist/security/bridge/pending-enrollment.js +0 -177
  142. package/dist/security/bridge/pending-enrollment.js.map +0 -1
  143. package/dist/security/bridge/revocation.d.ts +0 -14
  144. package/dist/security/bridge/revocation.js +0 -40
  145. package/dist/security/bridge/revocation.js.map +0 -1
  146. package/dist/security/pending-approval-crypto.d.ts +0 -29
  147. package/dist/security/pending-approval-crypto.js +0 -716
  148. package/dist/security/pending-approval-crypto.js.map +0 -1
  149. package/dist/security/pending-approval-protocol.d.ts +0 -329
  150. package/dist/security/pending-approval-protocol.js +0 -2
  151. package/dist/security/pending-approval-protocol.js.map +0 -1
  152. package/dist/security/security-nudge.d.ts +0 -31
  153. package/dist/security/security-nudge.js +0 -98
  154. package/dist/security/security-nudge.js.map +0 -1
@@ -0,0 +1,878 @@
1
+ import { AUTHORITY_CONSENT_DOMAIN, LEDGER_DOMAIN, LEDGER_LIMITS, SIGNIN_DOMAIN } from './protocol.js';
2
+ import { verifyPasskeyAssertion } from './webauthn.js';
3
+ const messages = {
4
+ invalid_ledger: 'Account ledger could not be verified.',
5
+ ledger_other_account: 'Account ledger entry belongs to a different account.',
6
+ ledger_revoked: 'Account ledger entry refers to a revoked key.',
7
+ ledger_limit: 'Account ledger reached its supported capacity.',
8
+ resolution_incomplete: 'Account ledger resolution must first carry the other history\'s removals and passkeys.',
9
+ };
10
+ /** No untrusted text, key material or underlying crypto error enters this error. */
11
+ export class LedgerError extends Error {
12
+ code;
13
+ constructor(code) {
14
+ super(messages[code]);
15
+ this.code = code;
16
+ this.name = 'LedgerError';
17
+ }
18
+ }
19
+ const encoder = new TextEncoder();
20
+ const MAX_TIME = 253402300799999;
21
+ const MAX_ORIGINS = 16;
22
+ const MAX_ORIGIN_LENGTH = 512;
23
+ const MAX_CREDENTIAL_ID_BYTES = 1023;
24
+ const MAX_AUTHENTICATOR_DATA_BYTES = 4096;
25
+ const MAX_CLIENT_DATA_BYTES = 4096;
26
+ const MAX_DER_SIGNATURE_BYTES = 72;
27
+ const MAX_STICKY_IDS = 8 * LEDGER_LIMITS.maxTransitions;
28
+ function fail(code = 'invalid_ledger') {
29
+ throw new LedgerError(code);
30
+ }
31
+ function isPlain(value) {
32
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
33
+ return false;
34
+ const prototype = Object.getPrototypeOf(value);
35
+ // Any realm's Object.prototype or null; class instances and exotic prototypes are refused.
36
+ return prototype === null || Object.getPrototypeOf(prototype) === null;
37
+ }
38
+ function object(value) {
39
+ return isPlain(value) ? value : fail();
40
+ }
41
+ function exact(value, keys) {
42
+ const record = object(value);
43
+ const own = Object.keys(record);
44
+ if (own.length !== keys.length || own.some(key => keys.indexOf(key) === -1))
45
+ return fail();
46
+ return record;
47
+ }
48
+ function each(value, max, parse, code = 'invalid_ledger') {
49
+ if (!Array.isArray(value))
50
+ return fail();
51
+ const items = value;
52
+ if (items.length > max)
53
+ return fail(code);
54
+ const result = [];
55
+ for (let index = 0; index < items.length; index += 1)
56
+ result.push(parse(items[index]));
57
+ return result;
58
+ }
59
+ function string(value, max) {
60
+ return typeof value === 'string' && value.length <= max ? value : fail();
61
+ }
62
+ function b64url(data) {
63
+ let binary = '';
64
+ for (let index = 0; index < data.length; index += 1)
65
+ binary += String.fromCharCode(data[index]);
66
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
67
+ }
68
+ /** Canonical unpadded base64url only; the length bound is checked before decoding. */
69
+ function decode(value, min, max) {
70
+ const text = string(value, Math.ceil(max * 4 / 3));
71
+ if (!/^[A-Za-z0-9_-]+$/.test(text))
72
+ return fail();
73
+ let binary;
74
+ try {
75
+ binary = atob(text.replace(/-/g, '+').replace(/_/g, '/'));
76
+ }
77
+ catch {
78
+ return fail();
79
+ }
80
+ if (binary.length < min || binary.length > max)
81
+ return fail();
82
+ const result = new Uint8Array(binary.length);
83
+ for (let index = 0; index < binary.length; index += 1)
84
+ result[index] = binary.charCodeAt(index);
85
+ if (b64url(result) !== text)
86
+ return fail();
87
+ return result;
88
+ }
89
+ const fingerprint = (value) => b64url(decode(value, 32, 32));
90
+ const credential = (value) => b64url(decode(value, 1, MAX_CREDENTIAL_ID_BYTES));
91
+ function point(value) {
92
+ const raw = decode(value, 65, 65);
93
+ return raw[0] === 4 ? b64url(raw) : fail();
94
+ }
95
+ function token(value) {
96
+ const text = string(value, 200);
97
+ return /^[A-Za-z0-9_-]{1,200}$/.test(text) ? text : fail();
98
+ }
99
+ function label(value) {
100
+ const text = string(value, LEDGER_LIMITS.maxLabelLength);
101
+ // C0/C1 controls, zero-width and bidi controls, BOM, unpaired surrogates: refused, never silently rewritten in a signed label.
102
+ return /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff\ud800-\udfff]/u.test(text) ? fail() : text;
103
+ }
104
+ function time(value) {
105
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= MAX_TIME ? value : fail();
106
+ }
107
+ function count(value, min) {
108
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= min && value <= LEDGER_LIMITS.maxTransitions ? value : fail();
109
+ }
110
+ function relyingParty(value) {
111
+ const text = string(value, 253);
112
+ return /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/.test(text) ? text : fail();
113
+ }
114
+ /** A serialized origin exactly as URL.origin prints it: https, or http only for localhost. */
115
+ function origin(value) {
116
+ const text = string(value, MAX_ORIGIN_LENGTH);
117
+ let url;
118
+ try {
119
+ url = new URL(text);
120
+ }
121
+ catch {
122
+ return fail();
123
+ }
124
+ if (url.origin !== text)
125
+ return fail();
126
+ return url.protocol === 'https:' || (url.protocol === 'http:' && url.hostname === 'localhost') ? text : fail();
127
+ }
128
+ function parseSignature(value) {
129
+ const record = exact(value, ['authenticatorData', 'clientDataJSON', 'signature']);
130
+ return {
131
+ authenticatorData: b64url(decode(record.authenticatorData, 37, MAX_AUTHENTICATOR_DATA_BYTES)),
132
+ clientDataJSON: b64url(decode(record.clientDataJSON, 1, MAX_CLIENT_DATA_BYTES)),
133
+ signature: b64url(decode(record.signature, 8, MAX_DER_SIGNATURE_BYTES)),
134
+ };
135
+ }
136
+ function parseEndpoint(value, kind) {
137
+ const record = exact(value, ['version', 'id', 'ownerId', 'kind', 'publicKey']);
138
+ if (record.version !== 1 || record.kind !== kind)
139
+ return fail();
140
+ return { version: 1, id: fingerprint(record.id), ownerId: fingerprint(record.ownerId), kind, publicKey: point(record.publicKey) };
141
+ }
142
+ function parseAuthority(value) {
143
+ const record = exact(value, ['credentialId', 'publicKey', 'label', 'addedAt']);
144
+ return { credentialId: credential(record.credentialId), publicKey: point(record.publicKey), label: label(record.label), addedAt: time(record.addedAt) };
145
+ }
146
+ function parseDevice(value) {
147
+ const record = exact(value, ['endpoint', 'authPublicKey', 'label', 'approvedAt']);
148
+ const parsed = { endpoint: parseEndpoint(record.endpoint, 'browser'), authPublicKey: point(record.authPublicKey), label: label(record.label), approvedAt: time(record.approvedAt) };
149
+ return parsed.authPublicKey === parsed.endpoint.publicKey ? fail() : parsed;
150
+ }
151
+ function parseBridge(value) {
152
+ const record = exact(value, ['bridgeId', 'endpoint', 'authPublicKey', 'label', 'approvedAt']);
153
+ const parsed = {
154
+ bridgeId: token(record.bridgeId), endpoint: parseEndpoint(record.endpoint, 'bridge'),
155
+ authPublicKey: point(record.authPublicKey), label: label(record.label), approvedAt: time(record.approvedAt),
156
+ };
157
+ return parsed.authPublicKey === parsed.endpoint.publicKey ? fail() : parsed;
158
+ }
159
+ function parseOperation(value) {
160
+ const record = object(value);
161
+ switch (record.type) {
162
+ case 'add_device': return { type: 'add_device', device: parseDevice(exact(record, ['type', 'device']).device) };
163
+ case 'revoke_device': return { type: 'revoke_device', endpointId: fingerprint(exact(record, ['type', 'endpointId']).endpointId) };
164
+ case 'add_bridge': return { type: 'add_bridge', bridge: parseBridge(exact(record, ['type', 'bridge']).bridge) };
165
+ case 'revoke_bridge': return { type: 'revoke_bridge', bridgeId: token(exact(record, ['type', 'bridgeId']).bridgeId) };
166
+ case 'add_authority': {
167
+ const fields = exact(record, ['type', 'authority', 'consent']);
168
+ return { type: 'add_authority', authority: parseAuthority(fields.authority), consent: parseSignature(fields.consent) };
169
+ }
170
+ case 'resolve_fork': {
171
+ const fields = exact(record, ['type', 'forkHead', 'discardedFirst', 'discardedHead', 'discardedRevision']);
172
+ return {
173
+ type: 'resolve_fork', forkHead: fingerprint(fields.forkHead), discardedFirst: fingerprint(fields.discardedFirst),
174
+ discardedHead: fingerprint(fields.discardedHead), discardedRevision: count(fields.discardedRevision, 1),
175
+ };
176
+ }
177
+ default: return fail();
178
+ }
179
+ }
180
+ function parseGenesis(value) {
181
+ const record = exact(value, ['type', 'version', 'rpId', 'origins', 'handle', 'createdAt', 'authority']);
182
+ if (record.type !== 'ftown_account_genesis' || record.version !== 2)
183
+ return fail();
184
+ const origins = each(record.origins, MAX_ORIGINS, origin);
185
+ if (!origins.length || origins.some((item, index) => origins.indexOf(item) !== index))
186
+ return fail();
187
+ return {
188
+ type: 'ftown_account_genesis', version: 2, rpId: relyingParty(record.rpId), origins,
189
+ handle: fingerprint(record.handle), createdAt: time(record.createdAt), authority: parseAuthority(record.authority),
190
+ };
191
+ }
192
+ function parseTransition(value) {
193
+ const record = exact(value, ['type', 'version', 'accountId', 'revision', 'previousHead', 'issuedAt', 'credentialId', 'operation']);
194
+ if (record.type !== 'ftown_account_transition' || record.version !== 2)
195
+ return fail();
196
+ return {
197
+ type: 'ftown_account_transition', version: 2, accountId: fingerprint(record.accountId), revision: count(record.revision, 1),
198
+ previousHead: fingerprint(record.previousHead), issuedAt: time(record.issuedAt), credentialId: credential(record.credentialId),
199
+ operation: parseOperation(record.operation),
200
+ };
201
+ }
202
+ function parseSigned(value, parse) {
203
+ const record = exact(value, ['payload', 'signature']);
204
+ return { payload: parse(record.payload), signature: parseSignature(record.signature) };
205
+ }
206
+ function parseCheckpoint(value) {
207
+ const record = exact(value, ['version', 'accountId', 'revision', 'head', 'authorities', 'devices', 'bridges', 'revokedDeviceIds', 'revokedBridgeIds']);
208
+ if (record.version !== 2)
209
+ return fail();
210
+ const authorities = each(record.authorities, LEDGER_LIMITS.maxAuthorities, parseAuthority, 'ledger_limit');
211
+ if (!authorities.length)
212
+ return fail();
213
+ return {
214
+ version: 2, accountId: fingerprint(record.accountId), revision: count(record.revision, 0), head: fingerprint(record.head), authorities,
215
+ devices: each(record.devices, LEDGER_LIMITS.maxDevices, parseDevice, 'ledger_limit'),
216
+ bridges: each(record.bridges, LEDGER_LIMITS.maxBridges, parseBridge, 'ledger_limit'),
217
+ revokedDeviceIds: each(record.revokedDeviceIds, LEDGER_LIMITS.maxTransitions, fingerprint, 'ledger_limit'),
218
+ revokedBridgeIds: each(record.revokedBridgeIds, LEDGER_LIMITS.maxTransitions, token, 'ledger_limit'),
219
+ };
220
+ }
221
+ function canonical(value) {
222
+ if (value === null || typeof value === 'boolean' || typeof value === 'string')
223
+ return JSON.stringify(value);
224
+ if (typeof value === 'number')
225
+ return Number.isFinite(value) ? JSON.stringify(value) : fail();
226
+ if (Array.isArray(value)) {
227
+ const items = value;
228
+ return `[${items.map(canonical).join(',')}]`;
229
+ }
230
+ const record = object(value);
231
+ return `{${Object.keys(record).sort().map(key => `${JSON.stringify(key)}:${canonical(record[key])}`).join(',')}}`;
232
+ }
233
+ async function sha256(data) {
234
+ return new Uint8Array(await crypto.subtle.digest('SHA-256', data));
235
+ }
236
+ async function importable(raw, name) {
237
+ try {
238
+ await crypto.subtle.importKey('raw', decode(raw, 65, 65), { name, namedCurve: 'P-256' }, false, name === 'ECDSA' ? ['verify'] : []);
239
+ }
240
+ catch {
241
+ fail();
242
+ }
243
+ }
244
+ /** The asynchronous checks of new key material: endpoint id = SHA-256(point), every point on P-256. */
245
+ async function keyMaterial(operation) {
246
+ if (operation.type === 'add_authority')
247
+ return importable(operation.authority.publicKey, 'ECDSA');
248
+ if (operation.type !== 'add_device' && operation.type !== 'add_bridge')
249
+ return;
250
+ const member = operation.type === 'add_device' ? operation.device : operation.bridge;
251
+ if (b64url(await sha256(decode(member.endpoint.publicKey, 65, 65))) !== member.endpoint.id)
252
+ fail();
253
+ await importable(member.endpoint.publicKey, 'ECDH');
254
+ await importable(member.authPublicKey, 'ECDSA');
255
+ }
256
+ function signerOf(state, credentialId) {
257
+ for (const item of state.authorities)
258
+ if (item.credentialId === credentialId)
259
+ return item;
260
+ return fail();
261
+ }
262
+ /** Shared rules of add_device and add_bridge; keys are never shared between roster members or between a member's two keys. */
263
+ function admit(state, endpoint, authPublicKey, approvedAt, issuedAt) {
264
+ if (endpoint.ownerId !== state.accountId)
265
+ fail('ledger_other_account');
266
+ if (approvedAt > issuedAt)
267
+ fail();
268
+ if (state.revokedDeviceIds.indexOf(endpoint.id) !== -1)
269
+ fail('ledger_revoked');
270
+ const members = [...state.devices, ...state.bridges];
271
+ const keys = [endpoint.publicKey, authPublicKey];
272
+ if (members.some(item => item.endpoint.id === endpoint.id
273
+ || keys.indexOf(item.endpoint.publicKey) !== -1 || keys.indexOf(item.authPublicKey) !== -1))
274
+ fail();
275
+ }
276
+ function apply(current, operation, issuedAt) {
277
+ const next = {
278
+ ...current, authorities: current.authorities.slice(), devices: current.devices.slice(), bridges: current.bridges.slice(),
279
+ revokedDeviceIds: current.revokedDeviceIds.slice(), revokedBridgeIds: current.revokedBridgeIds.slice(),
280
+ };
281
+ switch (operation.type) {
282
+ case 'add_device': {
283
+ const added = operation.device;
284
+ admit(next, added.endpoint, added.authPublicKey, added.approvedAt, issuedAt);
285
+ if (next.devices.length >= LEDGER_LIMITS.maxDevices)
286
+ fail('ledger_limit');
287
+ next.devices.push(added);
288
+ break;
289
+ }
290
+ case 'add_bridge': {
291
+ const added = operation.bridge;
292
+ admit(next, added.endpoint, added.authPublicKey, added.approvedAt, issuedAt);
293
+ if (next.revokedBridgeIds.indexOf(added.bridgeId) !== -1)
294
+ fail('ledger_revoked');
295
+ if (next.bridges.some(item => item.bridgeId === added.bridgeId))
296
+ fail();
297
+ if (next.bridges.length >= LEDGER_LIMITS.maxBridges)
298
+ fail('ledger_limit');
299
+ next.bridges.push(added);
300
+ break;
301
+ }
302
+ // A revoked id is a permanent tombstone. Revoking an id that is not a member only tombstones it, so it can never be added later: that is
303
+ // how a resolution carries a removal made on the other history. A device revocation never names a bridge member's endpoint.
304
+ case 'revoke_device': {
305
+ if (next.revokedDeviceIds.indexOf(operation.endpointId) !== -1)
306
+ fail('ledger_revoked');
307
+ if (next.bridges.some(item => item.endpoint.id === operation.endpointId))
308
+ fail();
309
+ const index = next.devices.findIndex(item => item.endpoint.id === operation.endpointId);
310
+ if (index !== -1)
311
+ next.devices.splice(index, 1);
312
+ next.revokedDeviceIds.push(operation.endpointId);
313
+ break;
314
+ }
315
+ case 'revoke_bridge': {
316
+ if (next.revokedBridgeIds.indexOf(operation.bridgeId) !== -1)
317
+ fail('ledger_revoked');
318
+ const index = next.bridges.findIndex(item => item.bridgeId === operation.bridgeId);
319
+ if (index !== -1)
320
+ next.bridges.splice(index, 1);
321
+ next.revokedBridgeIds.push(operation.bridgeId);
322
+ break;
323
+ }
324
+ case 'add_authority': {
325
+ const added = operation.authority;
326
+ if (added.addedAt > issuedAt)
327
+ fail();
328
+ if (next.authorities.some(item => item.credentialId === added.credentialId || item.publicKey === added.publicKey))
329
+ fail();
330
+ if (next.authorities.length >= LEDGER_LIMITS.maxAuthorities)
331
+ fail('ledger_limit');
332
+ next.authorities.push(added);
333
+ break;
334
+ }
335
+ case 'resolve_fork': {
336
+ // Membership is unchanged. Only the rules that need no history are here; settle() checks the heads against the chain itself.
337
+ const own = [operation.forkHead, next.head, next.accountId];
338
+ if (own.indexOf(operation.discardedFirst) !== -1 || own.indexOf(operation.discardedHead) !== -1)
339
+ fail();
340
+ break;
341
+ }
342
+ default: return fail();
343
+ }
344
+ return next;
345
+ }
346
+ function joinedAt(trail, credentialId) {
347
+ for (const item of trail.joined)
348
+ if (item.credentialId === credentialId)
349
+ return item.revision;
350
+ return -1;
351
+ }
352
+ /** History rules of resolve_fork, against the chain before the record: forkHead is one of its heads and the signer was an authority at it;
353
+ * the discarded branch (discardedFirst ... discardedHead at discardedRevision) starts after it and is no part of this chain; its first record
354
+ * is its tip exactly when it is one record long. */
355
+ function settle(trail, operation, credentialId) {
356
+ if (operation.type !== 'resolve_fork')
357
+ return;
358
+ const fork = trail.heads.indexOf(operation.forkHead);
359
+ if (fork === -1 || operation.discardedRevision <= fork)
360
+ fail();
361
+ if (trail.heads.indexOf(operation.discardedFirst) !== -1 || trail.heads.indexOf(operation.discardedHead) !== -1)
362
+ fail();
363
+ if ((operation.discardedRevision === fork + 1) !== (operation.discardedHead === operation.discardedFirst))
364
+ fail();
365
+ const joined = joinedAt(trail, credentialId);
366
+ if (joined === -1 || joined > fork)
367
+ fail();
368
+ }
369
+ /** Revoked ids and authorities of a chain from its payloads alone; equals its checkpoint's for a chain already verified or vouched for. */
370
+ function holdings(ledger) {
371
+ const held = { revokedDeviceIds: [], revokedBridgeIds: [], authorities: [ledger.genesis.payload.authority] };
372
+ for (const record of ledger.transitions) {
373
+ const operation = record.payload.operation;
374
+ if (operation.type === 'revoke_device')
375
+ held.revokedDeviceIds.push(operation.endpointId);
376
+ else if (operation.type === 'revoke_bridge')
377
+ held.revokedBridgeIds.push(operation.bridgeId);
378
+ else if (operation.type === 'add_authority')
379
+ held.authorities.push(operation.authority);
380
+ }
381
+ return held;
382
+ }
383
+ /** The security-relevant operations of `branch` that `held` lacks, in branch order: one revocation per id `held` has not revoked, and one
384
+ * add_authority (the original authority and consent) per authority `held` does not hold with the same credentialId and key. */
385
+ function missing(branch, held) {
386
+ const devices = held.revokedDeviceIds.slice();
387
+ const bridges = held.revokedBridgeIds.slice();
388
+ const authorities = held.authorities.slice();
389
+ const carry = [];
390
+ for (const record of branch) {
391
+ const operation = record.payload.operation;
392
+ if (operation.type === 'revoke_device' && devices.indexOf(operation.endpointId) === -1) {
393
+ devices.push(operation.endpointId);
394
+ carry.push({ type: 'revoke_device', endpointId: operation.endpointId });
395
+ }
396
+ else if (operation.type === 'revoke_bridge' && bridges.indexOf(operation.bridgeId) === -1) {
397
+ bridges.push(operation.bridgeId);
398
+ carry.push({ type: 'revoke_bridge', bridgeId: operation.bridgeId });
399
+ }
400
+ else if (operation.type === 'add_authority') {
401
+ const added = operation.authority;
402
+ if (authorities.some(item => item.credentialId === added.credentialId && item.publicKey === added.publicKey))
403
+ continue;
404
+ authorities.push(added);
405
+ carry.push({ type: 'add_authority', authority: { ...added }, consent: { ...operation.consent } });
406
+ }
407
+ }
408
+ return carry;
409
+ }
410
+ function note(trail, payload, head) {
411
+ trail.heads.push(head);
412
+ if (payload.operation.type === 'add_authority')
413
+ trail.joined.push({ credentialId: payload.operation.authority.credentialId, revision: payload.revision });
414
+ }
415
+ function initialState(root, accountId) {
416
+ return { version: 2, accountId, revision: 0, head: accountId, authorities: [root.authority], devices: [], bridges: [], revokedDeviceIds: [], revokedBridgeIds: [] };
417
+ }
418
+ function initialTrail(root, accountId) {
419
+ return { heads: [accountId], joined: [{ credentialId: root.authority.credentialId, revision: 0 }] };
420
+ }
421
+ /** The trail of the first `length` records of a chain already verified or vouched for; no signature work. */
422
+ function trailOf(chain, length) {
423
+ const trail = initialTrail(chain.ledger.genesis.payload, chain.accountId);
424
+ for (let index = 0; index < length; index += 1)
425
+ note(trail, chain.ledger.transitions[index].payload, chain.heads[index + 1]);
426
+ return trail;
427
+ }
428
+ /** The checkpoint after the first `length` records of a chain already verified or vouched for: the verifier's state rules, no signature work. */
429
+ function replay(chain, length) {
430
+ let state = initialState(chain.ledger.genesis.payload, chain.accountId);
431
+ for (let index = 0; index < length; index += 1) {
432
+ const payload = chain.ledger.transitions[index].payload;
433
+ state = { ...apply(state, payload.operation, payload.issuedAt), revision: payload.revision, head: chain.heads[index + 1] };
434
+ }
435
+ return state;
436
+ }
437
+ function copyOf(state) {
438
+ return {
439
+ version: 2, accountId: state.accountId, revision: state.revision, head: state.head,
440
+ authorities: state.authorities.slice(), devices: state.devices.slice(), bridges: state.bridges.slice(),
441
+ revokedDeviceIds: state.revokedDeviceIds.slice(), revokedBridgeIds: state.revokedBridgeIds.slice(),
442
+ };
443
+ }
444
+ /** The added passkey's own assertion that it joins THIS account; every path that applies add_authority goes through extend and here. */
445
+ async function consented(operation, accountId, root) {
446
+ const challenge = await authorityConsentChallenge(accountId, operation.authority);
447
+ if (!await verifyPasskeyAssertion({ rpId: root.rpId, origins: root.origins, publicKey: operation.authority.publicKey, challenge, signature: operation.consent }))
448
+ fail();
449
+ }
450
+ /** Verifies `records` in order on top of a verified `state` whose last record was issued at `previousTime`; `trail` describes the chain up
451
+ * to `state` and is extended in place. */
452
+ async function extend(state, root, previousTime, records, at, trail) {
453
+ let current = state;
454
+ let lastTime = previousTime;
455
+ for (const record of records) {
456
+ const payload = record.payload;
457
+ if (payload.accountId !== current.accountId)
458
+ fail('ledger_other_account');
459
+ if (payload.revision !== current.revision + 1)
460
+ fail();
461
+ if (payload.previousHead !== current.head)
462
+ fail();
463
+ if (payload.issuedAt < lastTime || payload.issuedAt > at + LEDGER_LIMITS.maxFutureSkewMs)
464
+ fail();
465
+ const signer = signerOf(current, payload.credentialId);
466
+ settle(trail, payload.operation, signer.credentialId);
467
+ const challenge = await ledgerChallenge(payload);
468
+ if (!await verifyPasskeyAssertion({ rpId: root.rpId, origins: root.origins, publicKey: signer.publicKey, challenge, signature: record.signature }))
469
+ fail();
470
+ await keyMaterial(payload.operation);
471
+ if (payload.operation.type === 'add_authority')
472
+ await consented(payload.operation, current.accountId, root);
473
+ const head = b64url(challenge);
474
+ current = { ...apply(current, payload.operation, payload.issuedAt), revision: payload.revision, head };
475
+ note(trail, payload, head);
476
+ lastTime = payload.issuedAt;
477
+ }
478
+ return current;
479
+ }
480
+ async function verifyAll(ledger, at) {
481
+ const root = ledger.genesis.payload;
482
+ if (root.createdAt > at + LEDGER_LIMITS.maxFutureSkewMs || root.authority.addedAt > root.createdAt)
483
+ fail();
484
+ await importable(root.authority.publicKey, 'ECDSA');
485
+ const challenge = await ledgerChallenge(root);
486
+ if (!await verifyPasskeyAssertion({ rpId: root.rpId, origins: root.origins, publicKey: root.authority.publicKey, challenge, signature: ledger.genesis.signature }))
487
+ fail();
488
+ const accountId = b64url(challenge);
489
+ return extend(initialState(root, accountId), root, root.createdAt, ledger.transitions, at, initialTrail(root, accountId));
490
+ }
491
+ /** Unexpected (non-ledger) failures become invalid_ledger. */
492
+ async function guarded(run) {
493
+ try {
494
+ return await run();
495
+ }
496
+ catch (error) {
497
+ if (error instanceof LedgerError)
498
+ throw error;
499
+ return fail();
500
+ }
501
+ }
502
+ async function indexed(value) {
503
+ const ledger = parseLedger(value);
504
+ const heads = [await ledgerHead(ledger.genesis.payload)];
505
+ for (const record of ledger.transitions)
506
+ heads.push(await ledgerHead(record.payload));
507
+ return { ledger, accountId: heads[0], heads };
508
+ }
509
+ /** The cache vouches for `chain` only if it names the chain's last head and the chain is hash-linked up to it. */
510
+ function vouched(chain, cache) {
511
+ if (cache === undefined)
512
+ return null;
513
+ try {
514
+ const record = object(cache);
515
+ const checkpoint = parseCheckpoint(record.checkpoint);
516
+ const head = chain.heads[chain.heads.length - 1];
517
+ if (record.head !== head || checkpoint.head !== head || checkpoint.accountId !== chain.accountId
518
+ || checkpoint.revision !== chain.heads.length - 1)
519
+ return null;
520
+ for (let index = 0; index < chain.ledger.transitions.length; index += 1) {
521
+ const payload = chain.ledger.transitions[index].payload;
522
+ if (payload.revision !== index + 1 || payload.previousHead !== chain.heads[index])
523
+ return null;
524
+ }
525
+ return checkpoint;
526
+ }
527
+ catch {
528
+ return null;
529
+ }
530
+ }
531
+ /** Index of the first revision whose heads differ, or the shorter length when one list is a prefix of the other. String compares only. */
532
+ function firstDifference(local, candidate) {
533
+ const length = Math.min(local.length, candidate.length);
534
+ let index = 0;
535
+ while (index < length && local[index] === candidate[index])
536
+ index += 1;
537
+ return index;
538
+ }
539
+ /** null when even the genesis differs (another account). */
540
+ function classify(local, candidate) {
541
+ const index = firstDifference(local, candidate);
542
+ if (index === 0)
543
+ return null;
544
+ if (index < local.length && index < candidate.length)
545
+ return 'diverged';
546
+ if (local.length === candidate.length)
547
+ return 'same';
548
+ return candidate.length > local.length ? 'ahead' : 'behind';
549
+ }
550
+ function headList(value) {
551
+ if (!Array.isArray(value))
552
+ return fail();
553
+ const heads = value;
554
+ if (heads.length < 1 || heads.length > LEDGER_LIMITS.maxTransitions + 1 || heads.some(item => typeof item !== 'string'))
555
+ return fail();
556
+ return value;
557
+ }
558
+ /** The fork revision of two diverged chains; ledger_other_account for another account, invalid_ledger for any other relation. */
559
+ function divergence(mine, theirs) {
560
+ const relation = classify(mine.heads, theirs.heads);
561
+ if (relation === null)
562
+ fail('ledger_other_account');
563
+ if (relation !== 'diverged')
564
+ fail();
565
+ return firstDifference(mine.heads, theirs.heads) - 1;
566
+ }
567
+ /**
568
+ * True iff the verified diverged candidate (checkpoint `adopted`) may replace local's branch, i.e. local's records after `fork`:
569
+ * - the candidate already holds every revocation and every authority that local's branch makes (nothing security-relevant is discarded), and
570
+ * - it carries, after the fork point, a resolve_fork R with
571
+ * 1. R.forkHead = the fork point and R.discardedFirst = local's first record after it (so a lagging or sibling replica of that branch matches);
572
+ * 2. if local's branch holds any resolve_fork (whatever it targets), R.discardedHead = local's OWN record at R.discardedRevision, at or after
573
+ * the latest one: R's payload hashes over it, so its signer saw that choice. A revision number alone is not proof (a signer who saw a
574
+ * sibling branch at a higher revision never saw it). Each adoption thus moves to a resolution that hashes over the one held before, and
575
+ * as hashes cannot form a cycle, no replica ever adopts its way back to a chain it held;
576
+ * 3. no record of local's branch is newer than R: issuedAt <= R.issuedAt + maxFutureSkewMs (the module's clock allowance).
577
+ */
578
+ function resolves(mine, theirs, fork, adopted) {
579
+ const branch = mine.ledger.transitions.slice(fork);
580
+ if (missing(branch, adopted).length)
581
+ return false;
582
+ let seen = 0;
583
+ let newest = 0;
584
+ for (const record of branch) {
585
+ if (record.payload.operation.type === 'resolve_fork')
586
+ seen = record.payload.revision;
587
+ newest = Math.max(newest, record.payload.issuedAt);
588
+ }
589
+ for (const record of theirs.ledger.transitions.slice(fork)) {
590
+ const operation = record.payload.operation;
591
+ if (operation.type !== 'resolve_fork' || operation.forkHead !== mine.heads[fork] || operation.discardedFirst !== mine.heads[fork + 1])
592
+ continue;
593
+ if (seen > 0 && (operation.discardedRevision < seen || mine.heads[operation.discardedRevision] !== operation.discardedHead))
594
+ continue;
595
+ if (newest > record.payload.issuedAt + LEDGER_LIMITS.maxFutureSkewMs)
596
+ continue;
597
+ return true;
598
+ }
599
+ return false;
600
+ }
601
+ /** Local's records after the fork, and the revocations among them that the adopted chain lacks. */
602
+ function discardedBy(mine, fork, adopted) {
603
+ const records = mine.ledger.transitions.slice(fork);
604
+ const deviceIds = [];
605
+ const bridgeIds = [];
606
+ for (const record of records) {
607
+ const operation = record.payload.operation;
608
+ if (operation.type === 'revoke_device' && adopted.revokedDeviceIds.indexOf(operation.endpointId) === -1)
609
+ deviceIds.push(operation.endpointId);
610
+ if (operation.type === 'revoke_bridge' && adopted.revokedBridgeIds.indexOf(operation.bridgeId) === -1)
611
+ bridgeIds.push(operation.bridgeId);
612
+ }
613
+ return { records, deviceIds, bridgeIds };
614
+ }
615
+ /** LedgerSeen of a VERIFIED diverged candidate: revocations after `fork` signed by an authority of the shared prefix (joined at or before `fork`). */
616
+ function seenIn(mine, theirs, fork) {
617
+ const authorities = trailOf(mine, fork).joined.map(item => item.credentialId);
618
+ const seen = { deviceIds: [], bridgeIds: [] };
619
+ for (const record of theirs.ledger.transitions.slice(fork)) {
620
+ const { operation, credentialId } = record.payload;
621
+ if (authorities.indexOf(credentialId) === -1)
622
+ continue;
623
+ if (operation.type === 'revoke_device' && seen.deviceIds.indexOf(operation.endpointId) === -1)
624
+ seen.deviceIds.push(operation.endpointId);
625
+ if (operation.type === 'revoke_bridge' && seen.bridgeIds.indexOf(operation.bridgeId) === -1)
626
+ seen.bridgeIds.push(operation.bridgeId);
627
+ }
628
+ return seen;
629
+ }
630
+ function choice(decision, relation, checkpoint, ledger, discarded, seen) {
631
+ return {
632
+ decision, relation, checkpoint: copyOf(checkpoint), discarded: discarded ?? { records: [], deviceIds: [], bridgeIds: [] }, ledger,
633
+ seen: seen ?? { deviceIds: [], bridgeIds: [] },
634
+ };
635
+ }
636
+ /** SHA-256(UTF-8(LEDGER_DOMAIN + canonical(payload))): the WebAuthn challenge, and the record's head. */
637
+ export async function ledgerChallenge(payload) {
638
+ return sha256(encoder.encode(LEDGER_DOMAIN + canonical(payload)));
639
+ }
640
+ export async function ledgerHead(payload) {
641
+ return b64url(await ledgerChallenge(payload));
642
+ }
643
+ export function accountIdOf(genesis) {
644
+ return ledgerHead(genesis);
645
+ }
646
+ /** SHA-256(UTF-8(SIGNIN_DOMAIN + nonce)): the only sign-in challenge. The domain differs from LEDGER_DOMAIN, so a sign-in assertion
647
+ * can never double as a ledger record signature. The nonce is a base64url token (22-128 chars); anything else throws invalid_ledger. */
648
+ export async function signInChallenge(nonce) {
649
+ if (typeof nonce !== 'string' || !/^[A-Za-z0-9_-]{22,128}$/.test(nonce))
650
+ fail();
651
+ return sha256(encoder.encode(SIGNIN_DOMAIN + nonce));
652
+ }
653
+ /** SHA-256(UTF-8(AUTHORITY_CONSENT_DOMAIN + canonical({accountId, credentialId, publicKey}))): what a passkey signs to consent to
654
+ * becoming an authority of exactly that account. Throws invalid_ledger on a malformed accountId, credentialId or key. */
655
+ export async function authorityConsentChallenge(accountId, authority) {
656
+ const bound = { accountId: fingerprint(accountId), credentialId: credential(authority.credentialId), publicKey: point(authority.publicKey) };
657
+ return sha256(encoder.encode(AUTHORITY_CONSENT_DOMAIN + canonical(bound)));
658
+ }
659
+ /** The issuedAt of the last transition, or the genesis createdAt: the `lastIssuedAt` argument of nextTransition. */
660
+ export function ledgerLastIssuedAt(ledger) {
661
+ const last = ledger.transitions[ledger.transitions.length - 1];
662
+ return time(last === undefined ? ledger.genesis.payload.createdAt : last.payload.issuedAt);
663
+ }
664
+ /** Every device endpoint id and bridgeId revoked anywhere in the chain, in chain order. Only for chains that verified: replicas keep the
665
+ * union over the chains they held (plus chooseLedger's `discarded` ids) and never trust those ids again. Throws on a malformed shape. */
666
+ export function ledgerRevocations(ledger) {
667
+ const deviceIds = [];
668
+ const bridgeIds = [];
669
+ for (const record of parseLedger(ledger).transitions) {
670
+ const operation = record.payload.operation;
671
+ if (operation.type === 'revoke_device' && deviceIds.indexOf(operation.endpointId) === -1)
672
+ deviceIds.push(operation.endpointId);
673
+ if (operation.type === 'revoke_bridge' && bridgeIds.indexOf(operation.bridgeId) === -1)
674
+ bridgeIds.push(operation.bridgeId);
675
+ }
676
+ return { deviceIds, bridgeIds };
677
+ }
678
+ /** Pure: `checkpoint` with every sticky id removed from devices/bridges and added, de-duplicated, to the revoked lists. Inputs are never
679
+ * mutated. Every trust decision uses this output. NEVER pass it as the chooseLedger cache: it no longer describes the chain itself. */
680
+ export function applyRevocations(checkpoint, sticky) {
681
+ const current = parseCheckpoint(checkpoint);
682
+ const record = exact(sticky, ['deviceIds', 'bridgeIds']);
683
+ const deviceIds = new Set(each(record.deviceIds, MAX_STICKY_IDS, fingerprint, 'ledger_limit'));
684
+ const bridgeIds = new Set(each(record.bridgeIds, MAX_STICKY_IDS, token, 'ledger_limit'));
685
+ const revokedDeviceIds = current.revokedDeviceIds.slice();
686
+ deviceIds.forEach(id => { if (revokedDeviceIds.indexOf(id) === -1)
687
+ revokedDeviceIds.push(id); });
688
+ const revokedBridgeIds = current.revokedBridgeIds.slice();
689
+ bridgeIds.forEach(id => { if (revokedBridgeIds.indexOf(id) === -1)
690
+ revokedBridgeIds.push(id); });
691
+ return {
692
+ ...current, devices: current.devices.filter(item => !deviceIds.has(item.endpoint.id)), bridges: current.bridges.filter(item => !bridgeIds.has(item.bridgeId)),
693
+ revokedDeviceIds, revokedBridgeIds,
694
+ };
695
+ }
696
+ /** Strict shape: exact keys, canonical base64url, key lengths, canonical origins, labels and limits.
697
+ * Returns a rebuilt copy that shares nothing with the input. Throws invalid_ledger, or ledger_limit for size and count. */
698
+ export function parseLedger(value) {
699
+ const record = exact(value, ['genesis', 'transitions']);
700
+ const ledger = {
701
+ genesis: parseSigned(record.genesis, parseGenesis),
702
+ transitions: each(record.transitions, LEDGER_LIMITS.maxTransitions, item => parseSigned(item, parseTransition), 'ledger_limit'),
703
+ };
704
+ if (encoder.encode(JSON.stringify(ledger)).byteLength > LEDGER_LIMITS.maxBytes)
705
+ return fail('ledger_limit');
706
+ return ledger;
707
+ }
708
+ /** Verifies ONE chain from genesis and returns its last checkpoint. Every record's passkey signature is checked against an authority of the
709
+ * PREVIOUS state (genesis: its own authority) with genesis.rpId/origins. Throws LedgerError: invalid_ledger, or a more specific code. */
710
+ export async function verifyLedger(ledger, now = Date.now()) {
711
+ const at = time(now);
712
+ return copyOf(await guarded(() => verifyAll(parseLedger(ledger), at)));
713
+ }
714
+ /** The head of every record, index = revision: [accountId, head of revision 1, ..., head of the tip]. O(n) SHA-256 over the parsed chain and no
715
+ * signature work; compute it once per chain and keep it for relate/forkPoint/relateHead. Throws like parseLedger. */
716
+ export async function ledgerHeads(ledger) {
717
+ return (await guarded(() => indexed(ledger))).heads;
718
+ }
719
+ /** How `candidate` relates to `local` (both ledgerHeads lists of chains of ONE account): 'same' (identical heads), 'ahead' (candidate strictly
720
+ * extends local), 'behind' (local strictly extends candidate), 'diverged' (they differ at some revision both have). Pure, O(min(n, m)) string
721
+ * compares, no hashing. Another account is not a relation: throws ledger_other_account (genesis heads differ); a malformed list throws
722
+ * invalid_ledger. Every head of the shorter list is compared, so a list whose tip matches but whose middle differs is 'diverged'. */
723
+ export function relate(local, candidate) {
724
+ const relation = classify(headList(local), headList(candidate));
725
+ return relation === null ? fail('ledger_other_account') : relation;
726
+ }
727
+ /** The last record `local` and `candidate` (ledgerHeads lists) share, or null when even the genesis differs. Same cost as relate. */
728
+ export function forkPoint(local, candidate) {
729
+ const mine = headList(local);
730
+ const index = firstDifference(mine, headList(candidate));
731
+ return index === 0 ? null : { revision: index - 1, head: mine[index - 1] };
732
+ }
733
+ /** What another replica's bare `{ revision, head }` (an RPC answer or head announcement; other fields ignored) says relative to `local`
734
+ * (a ledgerHeads list): 'same', 'behind' (local holds that head at that revision and more), or 'unknown' (ahead, diverged or another
735
+ * account: fetch its chain and run chooseLedger). O(1). Throws invalid_ledger on a malformed head or revision. */
736
+ export function relateHead(local, other) {
737
+ const mine = headList(local);
738
+ const record = object(other);
739
+ const revision = count(record.revision, 0);
740
+ const head = fingerprint(record.head);
741
+ if (revision >= mine.length || mine[revision] !== head)
742
+ return 'unknown';
743
+ return revision === mine.length - 1 ? 'same' : 'behind';
744
+ }
745
+ /**
746
+ * The append-only acceptance rule, the same on every replica (server, browser, bridge). The candidate's relation decides:
747
+ * - no local chain (first bind): verified from genesis and adopted, relation null; an invalid candidate throws.
748
+ * - malformed or another account: 'reject', relation null.
749
+ * - 'same' / 'behind': 'keep', decided from record heads alone (no candidate signature is checked).
750
+ * - 'ahead': only the records after local's head are verified, on top of local's state -> 'adopt', or 'reject' if any is invalid.
751
+ * - 'diverged': the candidate's records after the fork point are verified on top of the shared prefix -> 'reject' if invalid. It is
752
+ * 'adopt'ed only under the owner-signed resolve_fork rule of resolves(); `discarded.records` then lists local's records after the fork.
753
+ * Otherwise 'keep': a valid different history is never adopted, whatever its length or content; `seen` lists its removals to enforce locally.
754
+ * The candidate's copy of the shared prefix is never verified and never stored: its heads equal local's, so its payloads and state do, and
755
+ * only the candidate's records after the fork point (after local's tip when 'ahead') are checked. Store `choice.ledger`, which keeps local's
756
+ * prefix records byte-for-byte; re-encoded prefix signatures therefore cost nothing and are dropped. `cache` is the checkpoint of the
757
+ * already-verified local chain: when it vouches for `local` (same last head, hash-linked) local is not re-verified, else local is verified
758
+ * from genesis. Throws only when `local` is invalid (and not vouched for), or when `local` is null and the candidate is invalid.
759
+ */
760
+ export async function chooseLedger(local, candidate, now = Date.now(), cache) {
761
+ const at = time(now);
762
+ if (local === null) {
763
+ const first = await guarded(async () => {
764
+ const ledger = parseLedger(candidate);
765
+ return { ledger, checkpoint: await verifyAll(ledger, at) };
766
+ });
767
+ return choice('adopt', null, first.checkpoint, first.ledger);
768
+ }
769
+ const mine = await guarded(() => indexed(local));
770
+ const base = vouched(mine, cache) ?? await guarded(() => verifyAll(mine.ledger, at));
771
+ let theirs;
772
+ try {
773
+ theirs = await indexed(candidate);
774
+ }
775
+ catch {
776
+ return choice('reject', null, base, mine.ledger);
777
+ }
778
+ const relation = classify(mine.heads, theirs.heads);
779
+ if (relation === null)
780
+ return choice('reject', null, base, mine.ledger);
781
+ if (relation === 'same' || relation === 'behind')
782
+ return choice('keep', relation, base, mine.ledger);
783
+ // Revision of the last shared record: local's tip when 'ahead'.
784
+ const fork = firstDifference(mine.heads, theirs.heads) - 1;
785
+ // Local is verified or vouched for, so replaying its prefix can only fail on corrupt local state, which throws like an invalid local.
786
+ const start = relation === 'ahead' ? base : await guarded(async () => replay(mine, fork));
787
+ const suffix = theirs.ledger.transitions.slice(fork);
788
+ let verified;
789
+ try {
790
+ const root = mine.ledger.genesis.payload;
791
+ const previousTime = fork === 0 ? root.createdAt : mine.ledger.transitions[fork - 1].payload.issuedAt;
792
+ verified = await extend(start, root, previousTime, suffix, at, trailOf(mine, fork));
793
+ }
794
+ catch {
795
+ return choice('reject', relation, base, mine.ledger);
796
+ }
797
+ const adopted = { genesis: mine.ledger.genesis, transitions: mine.ledger.transitions.slice(0, fork).concat(suffix) };
798
+ if (relation === 'ahead')
799
+ return choice('adopt', relation, verified, adopted);
800
+ return resolves(mine, theirs, fork, verified)
801
+ ? choice('adopt', relation, verified, adopted, discardedBy(mine, fork, verified))
802
+ : choice('keep', relation, base, mine.ledger, undefined, seenIn(mine, theirs, fork));
803
+ }
804
+ /** describeFork(local, other): where the two chains part and each side's payloads after that point, for showing two histories side by side.
805
+ * Works for any relation of one account (for 'same' both lists are empty). Throws ledger_other_account for another account, else like parseLedger.
806
+ * Neither chain is verified: pass only a local chain and one that chooseLedger classified as 'keep' + 'diverged'. */
807
+ export async function describeFork(local, other) {
808
+ const mine = await guarded(() => indexed(local));
809
+ const theirs = await guarded(() => indexed(other));
810
+ const relation = classify(mine.heads, theirs.heads);
811
+ if (relation === null)
812
+ return fail('ledger_other_account');
813
+ const revision = firstDifference(mine.heads, theirs.heads) - 1;
814
+ return {
815
+ relation, fork: { revision, head: mine.heads[revision] },
816
+ mine: mine.ledger.transitions.slice(revision).map(record => record.payload),
817
+ theirs: theirs.ledger.transitions.slice(revision).map(record => record.payload),
818
+ };
819
+ }
820
+ /** What keeping `chosen` over `other` requires first: their fork point, and `carry`, the operations to append to `chosen` (each signed as an
821
+ * ordinary record, in this order) before resolveForkTransition. In `other`'s chain order: one revoke_device / revoke_bridge per id revoked on
822
+ * other's branch after the fork and not revoked in `chosen` (an id that is not a member of `chosen` is tombstoned, so it can never be added
823
+ * there), and one add_authority, with the ORIGINAL authority and consent (a consent binds only accountId, credentialId and key), per authority
824
+ * added on other's branch and absent from `chosen` with the same credentialId and key. Building a carried record can still throw (for example
825
+ * ledger_limit, or invalid_ledger when `chosen` holds that credentialId or key under another authority): then `chosen` cannot be kept.
826
+ * Throws ledger_other_account for another account and invalid_ledger unless the chains diverge. Neither chain is verified: pass only a
827
+ * verified local chain and one that chooseLedger classified as 'keep' + 'diverged'. */
828
+ export async function resolutionPlan(chosen, other) {
829
+ const mine = await guarded(() => indexed(chosen));
830
+ const theirs = await guarded(() => indexed(other));
831
+ const fork = divergence(mine, theirs);
832
+ return { fork: { revision: fork, head: mine.heads[fork] }, carry: missing(theirs.ledger.transitions.slice(fork), holdings(mine.ledger)) };
833
+ }
834
+ /** The unsigned resolve_fork that keeps `chosen` and discards `other` from their fork point: appended to `chosen`, forkHead = the last shared
835
+ * record, discardedFirst = other's first record after it, discardedHead/discardedRevision = other's tip. Verifies `chosen` (skipped when
836
+ * `cache` vouches for it, as in chooseLedger) and throws invalid_ledger when the chains do not diverge or `credentialId` was not an authority
837
+ * at the fork point (one added on chosen's own branch cannot discard the other); resolution_incomplete while resolutionPlan(chosen, other)
838
+ * still has operations to carry; ledger_other_account for another account; else nextTransition's errors. `other` is not verified: pass only
839
+ * a chain that chooseLedger classified as 'keep' + 'diverged'. */
840
+ export async function resolveForkTransition(chosen, other, credentialId, now = Date.now(), cache) {
841
+ const at = time(now);
842
+ const mine = await guarded(() => indexed(chosen));
843
+ const theirs = await guarded(() => indexed(other));
844
+ const fork = divergence(mine, theirs);
845
+ const checkpoint = vouched(mine, cache) ?? await guarded(() => verifyAll(mine.ledger, at));
846
+ const signer = credential(credentialId);
847
+ if (joinedAt(trailOf(mine, fork), signer) === -1)
848
+ fail();
849
+ if (missing(theirs.ledger.transitions.slice(fork), checkpoint).length)
850
+ fail('resolution_incomplete');
851
+ const operation = {
852
+ type: 'resolve_fork', forkHead: mine.heads[fork], discardedFirst: theirs.heads[fork + 1],
853
+ discardedHead: theirs.heads[theirs.heads.length - 1], discardedRevision: theirs.heads.length - 1,
854
+ };
855
+ return nextTransition(checkpoint, signer, operation, at, ledgerLastIssuedAt(mine.ledger));
856
+ }
857
+ /** Pure next-payload builder for the signer. Applies the operation to a copy of the checkpoint with the verifier's rules and throws the
858
+ * same errors. Not checked here (they need async crypto or the chain): endpoint id = SHA-256(point), points on the curve, the add_authority
859
+ * consent signature (only its shape is checked) and resolve_fork's forkHead and signer rules (build it with resolveForkTransition);
860
+ * verifyLedger enforces all of them.
861
+ * `lastIssuedAt` is the issuedAt of the ledger's last record (see ledgerLastIssuedAt); the result's issuedAt is max(now, lastIssuedAt). */
862
+ export function nextTransition(checkpoint, credentialId, operation, now = Date.now(), lastIssuedAt) {
863
+ const current = parseCheckpoint(checkpoint);
864
+ const signer = signerOf(current, credential(credentialId));
865
+ if (current.revision >= LEDGER_LIMITS.maxTransitions)
866
+ fail('ledger_limit');
867
+ const at = time(now);
868
+ const issuedAt = lastIssuedAt === undefined ? at : Math.max(at, time(lastIssuedAt));
869
+ if (issuedAt > at + LEDGER_LIMITS.maxFutureSkewMs)
870
+ fail();
871
+ const parsed = parseOperation(operation);
872
+ apply(current, parsed, issuedAt);
873
+ return {
874
+ type: 'ftown_account_transition', version: 2, accountId: current.accountId, revision: current.revision + 1,
875
+ previousHead: current.head, issuedAt, credentialId: signer.credentialId, operation: parsed,
876
+ };
877
+ }
878
+ //# sourceMappingURL=ledger.js.map