ftown 0.21.0 → 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.
- package/dist/attention-events.d.ts +1 -1
- package/dist/attention-store.d.ts +3 -0
- package/dist/attention-store.js +25 -1
- package/dist/attention-store.js.map +1 -1
- package/dist/centrifugo-client.d.ts +14 -1
- package/dist/centrifugo-client.js +23 -2
- package/dist/centrifugo-client.js.map +1 -1
- package/dist/command-rpc.d.ts +2 -0
- package/dist/command-rpc.js +55 -9
- package/dist/command-rpc.js.map +1 -1
- package/dist/identity-reset.d.ts +3 -1
- package/dist/identity-reset.js +27 -5
- package/dist/identity-reset.js.map +1 -1
- package/dist/index.js +15 -5
- package/dist/index.js.map +1 -1
- package/dist/ledger-pairing-client.d.ts +1 -1
- package/dist/ledger-pairing-client.js +5 -3
- package/dist/ledger-pairing-client.js.map +1 -1
- package/dist/ledger-relay.d.ts +42 -7
- package/dist/ledger-relay.js +175 -33
- package/dist/ledger-relay.js.map +1 -1
- package/dist/ledger-reoffer.d.ts +56 -0
- package/dist/ledger-reoffer.js +210 -0
- package/dist/ledger-reoffer.js.map +1 -0
- package/dist/ledger-startup.d.ts +30 -5
- package/dist/ledger-startup.js +40 -10
- package/dist/ledger-startup.js.map +1 -1
- package/dist/local-api-server.d.ts +3 -0
- package/dist/local-api-server.js +68 -6
- package/dist/local-api-server.js.map +1 -1
- package/dist/mail-delivery.d.ts +26 -3
- package/dist/mail-delivery.js +65 -7
- package/dist/mail-delivery.js.map +1 -1
- package/dist/preview-protocol.d.ts +19 -3
- package/dist/preview-protocol.js +14 -0
- package/dist/preview-protocol.js.map +1 -1
- package/dist/relay-token.d.ts +6 -0
- package/dist/relay-token.js +9 -1
- package/dist/relay-token.js.map +1 -1
- package/dist/security/bridge/ledger-rpc.d.ts +34 -0
- package/dist/security/bridge/ledger-rpc.js +58 -0
- package/dist/security/bridge/ledger-rpc.js.map +1 -0
- package/dist/security/bridge/ledger-store.d.ts +82 -13
- package/dist/security/bridge/ledger-store.js +394 -54
- package/dist/security/bridge/ledger-store.js.map +1 -1
- package/dist/security/bridge/permissions.js +2 -1
- package/dist/security/bridge/permissions.js.map +1 -1
- package/dist/security/bridge/policy.d.ts +2 -1
- package/dist/security/bridge/policy.js.map +1 -1
- package/dist/security/bridge/security.d.ts +138 -10
- package/dist/security/bridge/security.js +252 -54
- package/dist/security/bridge/security.js.map +1 -1
- package/dist/security/ledger/announce.d.ts +32 -0
- package/dist/security/ledger/announce.js +101 -0
- package/dist/security/ledger/announce.js.map +1 -0
- package/dist/security/ledger/ledger.d.ts +107 -14
- package/dist/security/ledger/ledger.js +364 -83
- package/dist/security/ledger/ledger.js.map +1 -1
- package/dist/security/ledger/proof.d.ts +12 -0
- package/dist/security/ledger/proof.js +40 -8
- package/dist/security/ledger/proof.js.map +1 -1
- package/dist/security/ledger/protocol.d.ts +14 -1
- package/dist/security/ledger/protocol.js.map +1 -1
- package/dist/session-controller.d.ts +9 -3
- package/dist/session-controller.js +120 -20
- package/dist/session-controller.js.map +1 -1
- package/dist/session-inbox-protocol.d.ts +2 -0
- package/dist/session-preview.d.ts +20 -5
- package/dist/session-preview.js +185 -37
- package/dist/session-preview.js.map +1 -1
- package/dist/session-store.js +19 -1
- package/dist/session-store.js.map +1 -1
- package/dist/sessions-commands.js +41 -7
- package/dist/sessions-commands.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/opencode-plugin/ftown.js +15 -2
- package/package.json +2 -2
- package/skills/ftown/SKILL.md +18 -7
- package/skills/ftown/references/sessions.md +35 -10
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* The bridge's local replica of its account ledger: `<dataDir>/security/ledger.json`,
|
|
3
|
-
* {version:
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* {version:3, ledger, checkpoint, sticky, lastServerReadAt, divergences, refused}, file mode 0600, replaced atomically. Version 1 (0.21:
|
|
4
|
+
* without divergences/refused) and version 2 (an earlier 0.22 build) files are still read, and rewritten as version 3 on the next change.
|
|
5
|
+
* - Append-only rule (CONTRACT §2, §5, §12): chooseLedger decides, always with the RAW checkpoint of the stored chain as its cache. What is
|
|
6
|
+
* written is never rewritten. A candidate is adopted only when it strictly extends the stored chain ('ahead'), or when it diverges and
|
|
7
|
+
* carries an owner-signed resolve_fork that applies to the stored chain (the single owner-authorized exception). Same and behind change
|
|
8
|
+
* nothing; behind is reported to the caller, which may re-offer the stored chain to the server.
|
|
9
|
+
* - A VALID diverged chain without an applicable resolve_fork is refused: the stored chain is kept, its RAW checkpoint is unchanged, and the
|
|
10
|
+
* only thing taken from the refused chain is the list of removals chooseLedger saw on it (C1c `seen`), added to the sticky set so a
|
|
11
|
+
* browser removed on the other history is refused here at once; this bridge's own id is never added (status only:
|
|
12
|
+
* removedOnOtherHistory). The divergence is recorded per source (one entry each for server and relay, persisted here); a REFUSED line is
|
|
13
|
+
* logged once per refused head (the last 16 per source are persisted) and at most once a minute per source. The entry is cleared when a
|
|
14
|
+
* later chain from THAT source agrees with ours again (same, ahead or behind), and every entry by an owner-chosen adoption. A refused
|
|
15
|
+
* chain seen again (same source, our head, its tip) is not verified again.
|
|
16
|
+
* - An invalid candidate (malformed, another account, a bad record) is ignored, logged at most once per 60 s per source.
|
|
17
|
+
* - Sticky set: the revocations of every chain this replica adopted (its own chain included), plus, on an owner-chosen adoption, the
|
|
18
|
+
* revocations among the records it discarded, plus the `seen` removals of refused histories, so a removal is never silently undone.
|
|
19
|
+
* trusted() = applyRevocations(checkpoint, sticky) is the only checkpoint for trust decisions. This bridge's OWN removal is decided from
|
|
20
|
+
* the raw chain alone (ledgerRemovesBridge), never from the sticky set.
|
|
7
21
|
* - An empty store is bound to an account ONLY by adoptPaired (link secret proof + confirmPairedLedger) or adoptBound (the identity is
|
|
8
22
|
* already bound to that account and the chain lists this bridge's keys). offer() never binds an empty store.
|
|
9
23
|
* - Once bound (a stored ledger, or the `accountId` option) every other account's ledger is rejected.
|
|
@@ -14,14 +28,27 @@
|
|
|
14
28
|
import { createHash, randomUUID } from 'node:crypto';
|
|
15
29
|
import { mkdir, open, readdir, rename, rm, unlink } from 'node:fs/promises';
|
|
16
30
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
17
|
-
import { accountIdOf, applyRevocations, chooseLedger, ledgerLastIssuedAt, ledgerRevocations, parseLedger, verifyLedger, } from '../ledger/ledger.js';
|
|
31
|
+
import { accountIdOf, applyRevocations, chooseLedger, forkPoint, ledgerHead, ledgerHeads, ledgerLastIssuedAt, ledgerRevocations, parseLedger, verifyLedger, } from '../ledger/ledger.js';
|
|
18
32
|
import { confirmPairedLedger } from '../ledger/pairing.js';
|
|
33
|
+
import { LEDGER_LIMITS } from '../ledger/protocol.js';
|
|
19
34
|
import { BridgeSecurityError } from './errors.js';
|
|
20
35
|
import { bindLedgerIdentity, readLedgerIdentity } from './ledger-identity.js';
|
|
36
|
+
/** Divergence lastSeen is rewritten at most this often while the same other head keeps arriving. */
|
|
37
|
+
export const DIVERGENCE_LAST_SEEN_RESOLUTION_MS = 10 * 60_000;
|
|
38
|
+
/** "ignored an invalid chain" is logged at most once per this interval per source. */
|
|
39
|
+
export const INVALID_CHAIN_LOG_SPACING_MS = 60_000;
|
|
40
|
+
/** A REFUSED line for a head not refused before is logged at most once per this interval per source. */
|
|
41
|
+
export const REFUSED_LOG_SPACING_MS = 60_000;
|
|
42
|
+
/** Refused heads remembered per source (persisted), and verdicts on refused chains cached per process. */
|
|
43
|
+
export const REFUSED_HEADS_PER_SOURCE = 16;
|
|
44
|
+
export const REFUSED_VERDICT_CACHE_SIZE = 16;
|
|
45
|
+
/** The first 8 characters of a head, as the log lines and the status show it. */
|
|
46
|
+
export const shortHead = (head) => head.slice(0, 8);
|
|
21
47
|
const messages = {
|
|
22
48
|
ledger_store_corrupt: 'Local account ledger is corrupted.',
|
|
23
49
|
ledger_store_wrong_account: 'Local account ledger belongs to a different account.',
|
|
24
50
|
ledger_store_pairing_rejected: 'Paired account ledger was rejected.',
|
|
51
|
+
ledger_store_pairing_revoked: "This computer's id was removed from the account before pairing completed.",
|
|
25
52
|
ledger_store_bound_rejected: 'Account ledger does not match this bridge identity.',
|
|
26
53
|
};
|
|
27
54
|
/** Not retryable; file contents and crypto errors never enter it. Read/write failures throw BridgeSecurityError('storage_unavailable'). */
|
|
@@ -35,7 +62,14 @@ export class LedgerStoreError extends Error {
|
|
|
35
62
|
}
|
|
36
63
|
}
|
|
37
64
|
const FILE_NAME = 'ledger.json';
|
|
38
|
-
const
|
|
65
|
+
const FILE_VERSION = 3;
|
|
66
|
+
const FILE_KEYS_V1 = ['version', 'ledger', 'checkpoint', 'sticky', 'lastServerReadAt'];
|
|
67
|
+
const FILE_KEYS_V2 = [...FILE_KEYS_V1, 'divergences'];
|
|
68
|
+
const FILE_KEYS = [...FILE_KEYS_V2, 'refused'];
|
|
69
|
+
const DIVERGENCE_KEYS_V2 = ['source', 'otherHead', 'otherRevision', 'firstSeen', 'lastSeen'];
|
|
70
|
+
const DIVERGENCE_KEYS = [...DIVERGENCE_KEYS_V2, 'forkRevision', 'removedOnOtherHistory'];
|
|
71
|
+
const SOURCES = ['server', 'relay'];
|
|
72
|
+
const NO_REVOCATIONS = { deviceIds: [], bridgeIds: [] };
|
|
39
73
|
const MAX_FILE_BYTES = 16 * 1024 * 1024;
|
|
40
74
|
const MAX_TIME = 253402300799999;
|
|
41
75
|
const TEMPORARY = /^\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.tmp$/;
|
|
@@ -91,12 +125,35 @@ function covers(sticky, ids) {
|
|
|
91
125
|
function sameSticky(left, right) {
|
|
92
126
|
return left.deviceIds.length === right.deviceIds.length && left.bridgeIds.length === right.bridgeIds.length && covers(left, right);
|
|
93
127
|
}
|
|
94
|
-
function
|
|
95
|
-
|
|
128
|
+
function isOwnBridge(bridge, own) {
|
|
129
|
+
return bridge.bridgeId === own.bridgeId && bridge.endpoint.id === own.endpointId && bridge.endpoint.publicKey === own.endpointPublicKey
|
|
130
|
+
&& bridge.authPublicKey === own.authPublicKey;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Proof in the RAW chain `ledger` that THIS bridge was removed: an add_bridge of exactly these keys (bridgeId, endpoint id, endpoint public
|
|
134
|
+
* key and auth key), followed by a revoke_bridge of its bridgeId. A revoke_bridge of an id the chain never added with these keys is only a
|
|
135
|
+
* tombstone (C1b: revoking a non-member is valid) and proves nothing about this bridge. Sticky entries and refused histories never count:
|
|
136
|
+
* this is the only evidence that resets the identity, on a running bridge and at startup alike.
|
|
137
|
+
*/
|
|
138
|
+
export function ledgerRemovesBridge(ledger, own) {
|
|
139
|
+
let member = false;
|
|
140
|
+
for (const record of ledger.transitions) {
|
|
141
|
+
const operation = record.payload.operation;
|
|
142
|
+
if (operation.type === 'add_bridge' && isOwnBridge(operation.bridge, own))
|
|
143
|
+
member = true;
|
|
144
|
+
if (operation.type === 'revoke_bridge' && operation.bridgeId === own.bridgeId && member)
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
/** 'active': trusted() lists this bridge with exactly its keys. 'revoked': the stored chain itself proves its removal (ledgerRemovesBridge).
|
|
150
|
+
* Anything else, a tombstone, a sticky-only id or a removal seen on a refused history included, is 'absent': fail closed, never a reset. */
|
|
151
|
+
function statusOf(state, own) {
|
|
152
|
+
if (state === null)
|
|
96
153
|
return 'absent';
|
|
97
|
-
if (trusted.
|
|
98
|
-
return '
|
|
99
|
-
return
|
|
154
|
+
if (state.trusted.bridges.some(bridge => isOwnBridge(bridge, own)))
|
|
155
|
+
return 'active';
|
|
156
|
+
return ledgerRemovesBridge(state.ledger, own) ? 'revoked' : 'absent';
|
|
100
157
|
}
|
|
101
158
|
/** Is a bridge with exactly these keys a member of the trusted roster (any bridgeId when `bridgeId` is null)? */
|
|
102
159
|
function lists(trusted, bridgeId, endpointId, authPublicKey) {
|
|
@@ -107,6 +164,79 @@ function cacheOf(stored) {
|
|
|
107
164
|
// The cache is the stored chain's raw checkpoint, never the trusted one.
|
|
108
165
|
return { head: stored.checkpoint.head, checkpoint: stored.checkpoint };
|
|
109
166
|
}
|
|
167
|
+
async function tipOf(ledger) {
|
|
168
|
+
const last = ledger.transitions[ledger.transitions.length - 1];
|
|
169
|
+
return { revision: ledger.transitions.length, head: last === undefined ? await accountIdOf(ledger.genesis.payload) : await ledgerHead(last.payload) };
|
|
170
|
+
}
|
|
171
|
+
/** The sticky set after an adoption: the adopted chain's own revocations, plus, for an owner-chosen history, those of the local records it
|
|
172
|
+
* discarded (C1 adopts a resolution only when it carries them, so those are normally already in the chain; merged regardless). */
|
|
173
|
+
function adoptedSticky(stored, choice) {
|
|
174
|
+
const discarded = choice.relation === 'diverged' ? { deviceIds: choice.discarded.deviceIds, bridgeIds: choice.discarded.bridgeIds } : NO_REVOCATIONS;
|
|
175
|
+
return union(union(stored, ledgerRevocations(choice.ledger)), discarded);
|
|
176
|
+
}
|
|
177
|
+
function withoutSource(divergences, source) {
|
|
178
|
+
return divergences.filter(entry => entry.source !== source);
|
|
179
|
+
}
|
|
180
|
+
function ordered(divergences) {
|
|
181
|
+
return divergences.sort((left, right) => SOURCES.indexOf(left.source) - SOURCES.indexOf(right.source));
|
|
182
|
+
}
|
|
183
|
+
function adoptionLine(stored, choice) {
|
|
184
|
+
const discarded = choice.discarded.records.length;
|
|
185
|
+
return `[ledger] adopted an owner-chosen history: fork at revision ${stored.checkpoint.revision - discarded}, discarded ${discarded} of our records`;
|
|
186
|
+
}
|
|
187
|
+
/** "ignored an invalid chain from <source>", at most once per INVALID_CHAIN_LOG_SPACING_MS per source. */
|
|
188
|
+
function ignored(replica, source, now) {
|
|
189
|
+
const last = replica.ignoredAt.get(source);
|
|
190
|
+
if (last !== undefined && now >= last && now - last < INVALID_CHAIN_LOG_SPACING_MS)
|
|
191
|
+
return;
|
|
192
|
+
replica.ignoredAt.set(source, now);
|
|
193
|
+
replica.log(`[ledger] ignored an invalid chain from ${source}`);
|
|
194
|
+
}
|
|
195
|
+
const isRevision = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= LEDGER_LIMITS.maxTransitions;
|
|
196
|
+
/** Version 3 entries; a version 2 entry (no forkRevision / removedOnOtherHistory) reads as fork unknown, not removing us. */
|
|
197
|
+
function parseDivergences(value, version) {
|
|
198
|
+
if (!Array.isArray(value) || value.length > SOURCES.length)
|
|
199
|
+
return corrupt();
|
|
200
|
+
const expected = version === 2 ? DIVERGENCE_KEYS_V2 : DIVERGENCE_KEYS;
|
|
201
|
+
const items = value;
|
|
202
|
+
const result = [];
|
|
203
|
+
for (const item of items) {
|
|
204
|
+
if (!isRecord(item))
|
|
205
|
+
return corrupt();
|
|
206
|
+
const keys = Object.keys(item);
|
|
207
|
+
if (keys.length !== expected.length || keys.some(key => expected.indexOf(key) === -1))
|
|
208
|
+
return corrupt();
|
|
209
|
+
const { source, otherHead, otherRevision, firstSeen, lastSeen } = item;
|
|
210
|
+
const forkRevision = version === 2 ? null : item.forkRevision;
|
|
211
|
+
const removedOnOtherHistory = version === 2 ? false : item.removedOnOtherHistory;
|
|
212
|
+
if ((source !== 'server' && source !== 'relay') || result.some(entry => entry.source === source))
|
|
213
|
+
return corrupt();
|
|
214
|
+
if (!isAccountId(otherHead) || !isRevision(otherRevision) || !isTime(firstSeen) || !isTime(lastSeen) || firstSeen > lastSeen)
|
|
215
|
+
return corrupt();
|
|
216
|
+
if ((forkRevision !== null && (!isRevision(forkRevision) || forkRevision >= otherRevision)) || typeof removedOnOtherHistory !== 'boolean')
|
|
217
|
+
return corrupt();
|
|
218
|
+
result.push({ source, otherHead, otherRevision, forkRevision, removedOnOtherHistory, firstSeen, lastSeen });
|
|
219
|
+
}
|
|
220
|
+
return ordered(result);
|
|
221
|
+
}
|
|
222
|
+
function parseRefused(value) {
|
|
223
|
+
if (!isRecord(value))
|
|
224
|
+
return corrupt();
|
|
225
|
+
const keys = Object.keys(value);
|
|
226
|
+
if (keys.length !== 2 || keys.indexOf('server') === -1 || keys.indexOf('relay') === -1)
|
|
227
|
+
return corrupt();
|
|
228
|
+
const heads = (list) => {
|
|
229
|
+
const items = uniqueStrings(list);
|
|
230
|
+
return items.length <= REFUSED_HEADS_PER_SOURCE && items.every(isAccountId) ? items : corrupt();
|
|
231
|
+
};
|
|
232
|
+
return { server: heads(value.server), relay: heads(value.relay) };
|
|
233
|
+
}
|
|
234
|
+
/** Refused heads of a version 2 file: the heads its entries name. */
|
|
235
|
+
function refusedOf(divergences) {
|
|
236
|
+
return { server: divergences.filter(entry => entry.source === 'server').map(entry => entry.otherHead),
|
|
237
|
+
relay: divergences.filter(entry => entry.source === 'relay').map(entry => entry.otherHead) };
|
|
238
|
+
}
|
|
239
|
+
const NO_REFUSED = { server: [], relay: [] };
|
|
110
240
|
/** Operations on one file run one at a time within this process, whichever store instance or helper issues them. */
|
|
111
241
|
function serialized(file, run) {
|
|
112
242
|
const previous = queues.get(file) ?? Promise.resolve();
|
|
@@ -155,7 +285,11 @@ function replicaOf(dataDir, options) {
|
|
|
155
285
|
const accountId = options.accountId ?? null;
|
|
156
286
|
if (accountId !== null && !isAccountId(accountId))
|
|
157
287
|
throw new BridgeSecurityError('wrong_context');
|
|
158
|
-
return {
|
|
288
|
+
return {
|
|
289
|
+
dataDir, file: resolve(join(dataDir, 'security', FILE_NAME)), accountId, clock: options.now ?? Date.now,
|
|
290
|
+
log: options.log ?? (line => console.warn(line)), bridgeId: options.bridgeId ?? null, ignoredAt: new Map(), refusedLoggedAt: new Map(),
|
|
291
|
+
verdicts: new Map(), memo: null,
|
|
292
|
+
};
|
|
159
293
|
}
|
|
160
294
|
async function readText(replica) {
|
|
161
295
|
let handle;
|
|
@@ -189,8 +323,12 @@ async function decode(replica, text) {
|
|
|
189
323
|
if (!isRecord(record))
|
|
190
324
|
return corrupt();
|
|
191
325
|
const keys = Object.keys(record);
|
|
192
|
-
|
|
326
|
+
const version = record.version === 1 || record.version === 2 || record.version === FILE_VERSION ? record.version : corrupt();
|
|
327
|
+
const expected = version === 1 ? FILE_KEYS_V1 : version === 2 ? FILE_KEYS_V2 : FILE_KEYS;
|
|
328
|
+
if (keys.length !== expected.length || keys.some(key => expected.indexOf(key) === -1))
|
|
193
329
|
return corrupt();
|
|
330
|
+
const divergences = version === 1 ? [] : parseDivergences(record.divergences, version);
|
|
331
|
+
const refused = version === 1 ? NO_REFUSED : version === 2 ? refusedOf(divergences) : parseRefused(record.refused);
|
|
194
332
|
const lastServerReadAt = record.lastServerReadAt === null ? null : isTime(record.lastServerReadAt) ? record.lastServerReadAt : corrupt();
|
|
195
333
|
if (!isRecord(record.sticky))
|
|
196
334
|
return corrupt();
|
|
@@ -205,7 +343,7 @@ async function decode(replica, text) {
|
|
|
205
343
|
return corrupt();
|
|
206
344
|
if (!covers(sticky, ledgerRevocations(ledger)))
|
|
207
345
|
return corrupt();
|
|
208
|
-
return { ledger, checkpoint, trusted: applyRevocations(checkpoint, sticky), sticky, lastServerReadAt };
|
|
346
|
+
return { ledger, checkpoint, trusted: applyRevocations(checkpoint, sticky), sticky, lastServerReadAt, divergences, refused };
|
|
209
347
|
}
|
|
210
348
|
catch {
|
|
211
349
|
return corrupt();
|
|
@@ -227,7 +365,8 @@ async function load(replica) {
|
|
|
227
365
|
}
|
|
228
366
|
async function save(replica, state) {
|
|
229
367
|
const text = JSON.stringify({
|
|
230
|
-
version:
|
|
368
|
+
version: FILE_VERSION, ledger: state.ledger, checkpoint: state.checkpoint, sticky: state.sticky, lastServerReadAt: state.lastServerReadAt,
|
|
369
|
+
divergences: state.divergences, refused: state.refused,
|
|
231
370
|
});
|
|
232
371
|
const directory = dirname(replica.file);
|
|
233
372
|
const temporary = `${replica.file}.${randomUUID()}.tmp`;
|
|
@@ -262,36 +401,211 @@ async function erase(replica) {
|
|
|
262
401
|
}
|
|
263
402
|
await syncDirectory(dirname(replica.file));
|
|
264
403
|
}
|
|
265
|
-
/** The replica
|
|
404
|
+
/** The removals of a refused history this replica enforces (C1c `seen`): every device id, every bridge id except this bridge's own, and no
|
|
405
|
+
* bridge id at all when this store does not know its own. */
|
|
406
|
+
function enforced(replica, seen) {
|
|
407
|
+
const own = replica.bridgeId;
|
|
408
|
+
return { deviceIds: seen.deviceIds.slice(), bridgeIds: own === null ? [] : seen.bridgeIds.filter(id => id !== own) };
|
|
409
|
+
}
|
|
410
|
+
function verdictKey(source, ours, tip) {
|
|
411
|
+
return `${source}\n${ours}\n${tip.revision}:${tip.head}`;
|
|
412
|
+
}
|
|
413
|
+
function rememberVerdict(replica, key, verdict) {
|
|
414
|
+
replica.verdicts.delete(key);
|
|
415
|
+
replica.verdicts.set(key, verdict);
|
|
416
|
+
for (const oldest of replica.verdicts.keys()) {
|
|
417
|
+
if (replica.verdicts.size <= REFUSED_VERDICT_CACHE_SIZE)
|
|
418
|
+
break;
|
|
419
|
+
replica.verdicts.delete(oldest);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Is the diverged `theirs` (record heads) the branch a resolve_fork in OUR chain already discarded? The resolve_fork must name our fork
|
|
424
|
+
* point with them (forkHead) and their first record after it (discardedFirst, as C1's resolves() identifies a branch), and their tip must
|
|
425
|
+
* be the very tip it discarded (discardedHead at discardedRevision) or that first record itself. A tip elsewhere on that branch is not
|
|
426
|
+
* covered: past discardedHead it may hold records the owner never saw (C1c: the owner resolves again), and below it nothing stored proves
|
|
427
|
+
* it is a prefix of the discarded tip. Pure; O(our records).
|
|
428
|
+
*/
|
|
429
|
+
function coveredBy(ledger, ours, theirs, fork) {
|
|
430
|
+
if (fork + 1 >= theirs.length || fork >= ours.length)
|
|
431
|
+
return false;
|
|
432
|
+
const tipRevision = theirs.length - 1;
|
|
433
|
+
for (const record of ledger.transitions) {
|
|
434
|
+
const operation = record.payload.operation;
|
|
435
|
+
if (operation.type !== 'resolve_fork' || operation.forkHead !== ours[fork] || operation.discardedFirst !== theirs[fork + 1])
|
|
436
|
+
continue;
|
|
437
|
+
if (tipRevision === fork + 1 || (tipRevision === operation.discardedRevision && theirs[tipRevision] === operation.discardedHead))
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
/** What a keep/diverged verdict needs beyond chooseLedger's: the fork point and whether one of our resolve_forks covers the candidate. */
|
|
443
|
+
async function refusedVerdict(local, other, seen) {
|
|
444
|
+
let ours;
|
|
445
|
+
let theirs;
|
|
446
|
+
try {
|
|
447
|
+
ours = await ledgerHeads(local);
|
|
448
|
+
theirs = await ledgerHeads(other);
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
return { seen, forkRevision: null, covered: false };
|
|
452
|
+
}
|
|
453
|
+
const forkRevision = forkPoint(ours, theirs)?.revision ?? null;
|
|
454
|
+
return { seen, forkRevision, covered: forkRevision !== null && coveredBy(local, ours, theirs, forkRevision) };
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Clearing rule 2: the divergences an adopted `ledger` settles. An entry whose recorded other tip is exactly the tip a resolve_fork in
|
|
458
|
+
* `ledger` discards (discardedHead at discardedRevision) is over, whatever source recorded it: the owner chose our side against it. Only
|
|
459
|
+
* the exact tip is matched; any other entry stays until its own source agrees (clearing rule 1).
|
|
460
|
+
*/
|
|
461
|
+
function unresolved(ledger, divergences) {
|
|
462
|
+
const discarded = new Set();
|
|
463
|
+
for (const record of ledger.transitions) {
|
|
464
|
+
const operation = record.payload.operation;
|
|
465
|
+
if (operation.type === 'resolve_fork')
|
|
466
|
+
discarded.add(`${operation.discardedRevision}:${operation.discardedHead}`);
|
|
467
|
+
}
|
|
468
|
+
return divergences.filter(entry => !discarded.has(`${entry.otherRevision}:${entry.otherHead}`));
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* The append-only rule for an untrusted candidate on a bound store (C1's chooseLedger table). Never binds an empty store.
|
|
472
|
+
* - adopt (ahead, or diverged with an applicable owner-signed resolve_fork): store choice.ledger; sticky += its revocations (+ the discarded
|
|
473
|
+
* ones); this source's divergence is cleared (every divergence on an owner-chosen adoption).
|
|
474
|
+
* - keep / same, behind: nothing changes, except that a divergence recorded FROM THIS SOURCE is cleared (the histories agree again).
|
|
475
|
+
* - keep / diverged: ours is kept and decides; the divergence is recorded (refuse) and the removals chooseLedger saw on it (`seen`) are
|
|
476
|
+
* added to the sticky set, except this bridge's own id (status only). A chain already judged (same source, our head, its tip) is not
|
|
477
|
+
* verified again. A diverged chain that one of OUR resolve_forks already discards is `covered` (cover): answered keep/'behind', its
|
|
478
|
+
* removals enforced, never recorded or logged.
|
|
479
|
+
* Clearing a recorded divergence: (1) an offer from the SAME source that is same, behind, covered or adopted ahead clears that source's
|
|
480
|
+
* entry; (2) any adoption clears every entry whose tip a resolve_fork of the adopted chain discards (exact tip), and an owner-chosen
|
|
481
|
+
* adoption clears all of them.
|
|
482
|
+
* - reject: ignored (logged, spaced).
|
|
483
|
+
*/
|
|
266
484
|
async function decide(replica, candidate, source) {
|
|
267
485
|
const now = replica.clock();
|
|
268
486
|
const stored = await load(replica);
|
|
269
|
-
const
|
|
487
|
+
const answer = (decision, relation, tip, state = stored, discarded = []) => ({ decision, relation, candidate: tip, state, discarded, covered: false });
|
|
488
|
+
const settled = async (verdict, tip, from) => verdict.covered
|
|
489
|
+
? { ...answer('keep', 'behind', tip, await cover(replica, from, source, verdict)), covered: true }
|
|
490
|
+
: answer('keep', 'diverged', tip, await refuse(replica, from, source, tip, now, verdict));
|
|
270
491
|
if (source !== 'server' && source !== 'relay')
|
|
271
|
-
return
|
|
492
|
+
return answer('reject', null, null);
|
|
272
493
|
if (stored === null)
|
|
273
|
-
return
|
|
494
|
+
return answer('reject', null, null);
|
|
274
495
|
let ledger;
|
|
275
496
|
try {
|
|
276
497
|
ledger = parseLedger(candidate);
|
|
277
498
|
}
|
|
278
499
|
catch {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
500
|
+
ignored(replica, source, now);
|
|
501
|
+
return answer('reject', null, null);
|
|
502
|
+
}
|
|
503
|
+
const tip = await tipOf(ledger);
|
|
504
|
+
if (await accountIdOf(ledger.genesis.payload) !== stored.checkpoint.accountId) {
|
|
505
|
+
ignored(replica, source, now);
|
|
506
|
+
return answer('reject', null, tip);
|
|
507
|
+
}
|
|
508
|
+
const key = verdictKey(source, stored.checkpoint.head, tip);
|
|
509
|
+
const known = replica.verdicts.get(key);
|
|
510
|
+
// Same payloads (hash-linked to this tip) against the same chain of ours: chooseLedger would answer keep/diverged again.
|
|
511
|
+
if (known !== undefined)
|
|
512
|
+
return settled(known, tip, stored);
|
|
283
513
|
const choice = await chooseLedger(stored.ledger, ledger, now, cacheOf(stored));
|
|
284
|
-
if (choice.decision === 'reject')
|
|
285
|
-
|
|
286
|
-
|
|
514
|
+
if (choice.decision === 'reject') {
|
|
515
|
+
ignored(replica, source, now);
|
|
516
|
+
return answer('reject', choice.relation, tip);
|
|
517
|
+
}
|
|
287
518
|
if (choice.decision === 'adopt') {
|
|
288
|
-
const
|
|
289
|
-
|
|
519
|
+
const divergent = choice.relation === 'diverged';
|
|
520
|
+
// choice.ledger, never the candidate as passed in: our own prefix byte-for-byte, then the candidate's verified records.
|
|
521
|
+
const adopted = await commit(replica, {
|
|
522
|
+
ledger: choice.ledger, checkpoint: choice.checkpoint, sticky: adoptedSticky(stored.sticky, choice), lastServerReadAt: stored.lastServerReadAt,
|
|
523
|
+
// An owner-chosen history replaces ours: every recorded divergence was measured against the chain it discarded. An extension clears
|
|
524
|
+
// this source's entry (rule 1) and every entry whose tip one of its resolve_forks discards (rule 2), whichever source brought it.
|
|
525
|
+
divergences: divergent ? [] : unresolved(choice.ledger, withoutSource(stored.divergences, source)), refused: stored.refused,
|
|
526
|
+
});
|
|
527
|
+
if (adopted === null)
|
|
528
|
+
return answer('reject', choice.relation, tip);
|
|
529
|
+
if (divergent)
|
|
530
|
+
replica.log(adoptionLine(stored, choice));
|
|
531
|
+
return answer('adopt', choice.relation, tip, adopted, choice.discarded.records);
|
|
532
|
+
}
|
|
533
|
+
if (choice.relation === 'diverged') {
|
|
534
|
+
const verdict = await refusedVerdict(stored.ledger, ledger, choice.seen);
|
|
535
|
+
rememberVerdict(replica, key, verdict);
|
|
536
|
+
return settled(verdict, tip, stored);
|
|
537
|
+
}
|
|
538
|
+
// same / behind: the histories agree; a divergence recorded FROM THIS SOURCE is over (another source's stays).
|
|
539
|
+
if (!stored.divergences.some(entry => entry.source === source))
|
|
540
|
+
return answer('keep', choice.relation, tip);
|
|
541
|
+
return answer('keep', choice.relation, tip, await persist(replica, { ...stored, divergences: withoutSource(stored.divergences, source) }));
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* keep / diverged, but covered: our own resolve_fork already discards that chain, so the source is behind the owner's decision. Like
|
|
545
|
+
* behind: this source's divergence entry is over (rule 1) and nothing is logged or recorded (rule 3); the chain's removals are still
|
|
546
|
+
* enforced (`seen`, never our own id), exactly as for any refused chain. Written only when something changes.
|
|
547
|
+
*/
|
|
548
|
+
async function cover(replica, stored, source, verdict) {
|
|
549
|
+
const sticky = union(stored.sticky, enforced(replica, verdict.seen));
|
|
550
|
+
const divergences = withoutSource(stored.divergences, source);
|
|
551
|
+
const stickyChanged = !sameSticky(sticky, stored.sticky);
|
|
552
|
+
if (!stickyChanged && divergences.length === stored.divergences.length)
|
|
553
|
+
return stored;
|
|
554
|
+
if (stickyChanged) {
|
|
555
|
+
const merged = await commit(replica, { ledger: stored.ledger, checkpoint: stored.checkpoint, sticky, lastServerReadAt: stored.lastServerReadAt, divergences, refused: stored.refused });
|
|
556
|
+
if (merged !== null)
|
|
557
|
+
return merged;
|
|
558
|
+
}
|
|
559
|
+
return persist(replica, { ...stored, divergences });
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* keep / diverged. Our chain and checkpoint are untouched; the file is written only when something changes:
|
|
563
|
+
* - sticky += the removals the refused history shows (enforced(): never this bridge's own id, which only sets removedOnOtherHistory);
|
|
564
|
+
* - the source's entry is created, or replaced when the source shows a head it never showed before (an older refused head coming back
|
|
565
|
+
* changes nothing), and lastSeen is refreshed at most every DIVERGENCE_LAST_SEEN_RESOLUTION_MS;
|
|
566
|
+
* - the head joins the source's refused heads (the last REFUSED_HEADS_PER_SOURCE). A REFUSED line is logged only for a head no source
|
|
567
|
+
* refused before, and at most once per REFUSED_LOG_SPACING_MS per source.
|
|
568
|
+
*/
|
|
569
|
+
async function refuse(replica, stored, source, tip, now, verdict) {
|
|
570
|
+
const removesUs = replica.bridgeId !== null && verdict.seen.bridgeIds.indexOf(replica.bridgeId) !== -1;
|
|
571
|
+
const sticky = union(stored.sticky, enforced(replica, verdict.seen));
|
|
572
|
+
const heads = stored.refused[source];
|
|
573
|
+
const fresh = heads.indexOf(tip.head) === -1;
|
|
574
|
+
const refused = fresh ? { ...stored.refused, [source]: [...heads, tip.head].slice(-REFUSED_HEADS_PER_SOURCE) } : stored.refused;
|
|
575
|
+
if (fresh && stored.refused.server.indexOf(tip.head) === -1 && stored.refused.relay.indexOf(tip.head) === -1) {
|
|
576
|
+
const last = replica.refusedLoggedAt.get(source);
|
|
577
|
+
if (last === undefined || now < last || now - last >= REFUSED_LOG_SPACING_MS) {
|
|
578
|
+
replica.refusedLoggedAt.set(source, now);
|
|
579
|
+
replica.log(`[ledger] REFUSED a different account history from ${source} (head ${shortHead(tip.head)}, revision ${tip.revision}); `
|
|
580
|
+
+ `keeping ours (head ${shortHead(stored.checkpoint.head)}, revision ${stored.checkpoint.revision})${removesUs ? '; that history removes this computer' : ''}`);
|
|
581
|
+
}
|
|
290
582
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
583
|
+
const known = stored.divergences.find(entry => entry.source === source);
|
|
584
|
+
let entry = known;
|
|
585
|
+
if (known === undefined || (fresh && known.otherHead !== tip.head)) {
|
|
586
|
+
const firstSeen = known?.firstSeen ?? now;
|
|
587
|
+
entry = { source, otherHead: tip.head, otherRevision: tip.revision, forkRevision: verdict.forkRevision, removedOnOtherHistory: removesUs,
|
|
588
|
+
firstSeen, lastSeen: Math.max(now, firstSeen) };
|
|
589
|
+
}
|
|
590
|
+
else if ((removesUs && !known.removedOnOtherHistory) || now < known.lastSeen || now - known.lastSeen >= DIVERGENCE_LAST_SEEN_RESOLUTION_MS) {
|
|
591
|
+
entry = { ...known, removedOnOtherHistory: known.removedOnOtherHistory || removesUs, lastSeen: Math.max(now, known.lastSeen) };
|
|
592
|
+
}
|
|
593
|
+
const stickyChanged = !sameSticky(sticky, stored.sticky);
|
|
594
|
+
if (!stickyChanged && entry === known && refused === stored.refused)
|
|
595
|
+
return stored;
|
|
596
|
+
const divergences = entry === known ? stored.divergences : ordered([...withoutSource(stored.divergences, source), entry]);
|
|
597
|
+
if (stickyChanged) {
|
|
598
|
+
const merged = await commit(replica, { ledger: stored.ledger, checkpoint: stored.checkpoint, sticky, lastServerReadAt: stored.lastServerReadAt, divergences, refused });
|
|
599
|
+
if (merged !== null)
|
|
600
|
+
return merged;
|
|
601
|
+
replica.log('[ledger] could not enforce the removals of a refused history: too many removals recorded on this computer');
|
|
602
|
+
}
|
|
603
|
+
return persist(replica, { ...stored, divergences, refused });
|
|
604
|
+
}
|
|
605
|
+
/** Writes a state whose chain and sticky set are unchanged (only divergences or lastServerReadAt differ). */
|
|
606
|
+
async function persist(replica, state) {
|
|
607
|
+
await save(replica, state);
|
|
608
|
+
return state;
|
|
295
609
|
}
|
|
296
610
|
/** Persists a decided state; null (nothing written) when its sticky set exceeds applyRevocations' limits. */
|
|
297
611
|
async function commit(replica, next) {
|
|
@@ -307,29 +621,41 @@ async function commit(replica, next) {
|
|
|
307
621
|
return state;
|
|
308
622
|
}
|
|
309
623
|
/**
|
|
310
|
-
* Binds an empty store, or applies the
|
|
311
|
-
* roster must list this bridge (`isMember`); on any failure throws
|
|
624
|
+
* Binds an empty store, or applies the append-only rule to a bound one, for a chain the caller has authenticated (a refused or behind
|
|
625
|
+
* chain leaves the stored one deciding). The resulting trusted roster must list this bridge (`isMember`); on any failure throws
|
|
626
|
+
* LedgerStoreError(code) and writes nothing. Returns trusted().
|
|
312
627
|
*/
|
|
313
628
|
async function admit(replica, stored, ledger, now, isMember, code) {
|
|
314
629
|
let next;
|
|
630
|
+
let adoption = null;
|
|
315
631
|
if (stored === null) {
|
|
316
|
-
let
|
|
632
|
+
let first;
|
|
317
633
|
try {
|
|
318
|
-
|
|
634
|
+
first = await chooseLedger(null, ledger, now);
|
|
319
635
|
}
|
|
320
636
|
catch {
|
|
321
637
|
throw new LedgerStoreError(code);
|
|
322
638
|
}
|
|
323
|
-
next = { ledger, checkpoint, sticky: union(
|
|
639
|
+
next = { ledger: first.ledger, checkpoint: first.checkpoint, sticky: union(NO_REVOCATIONS, ledgerRevocations(first.ledger)), lastServerReadAt: null,
|
|
640
|
+
divergences: [], refused: NO_REFUSED };
|
|
324
641
|
}
|
|
325
642
|
else {
|
|
326
643
|
const choice = await chooseLedger(stored.ledger, ledger, now, cacheOf(stored));
|
|
327
644
|
if (choice.decision === 'reject')
|
|
328
645
|
throw new LedgerStoreError(code);
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
646
|
+
if (choice.decision === 'adopt') {
|
|
647
|
+
const divergent = choice.relation === 'diverged';
|
|
648
|
+
next = { ledger: choice.ledger, checkpoint: choice.checkpoint, sticky: adoptedSticky(stored.sticky, choice), lastServerReadAt: stored.lastServerReadAt,
|
|
649
|
+
divergences: divergent ? [] : unresolved(choice.ledger, stored.divergences), refused: stored.refused };
|
|
650
|
+
if (divergent)
|
|
651
|
+
adoption = adoptionLine(stored, choice);
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
// keep (same, behind or a refused divergence): ours decides; of a refused history only its removals are enforced (C1c `seen`, never
|
|
655
|
+
// this bridge's own id).
|
|
656
|
+
next = { ledger: stored.ledger, checkpoint: stored.checkpoint, sticky: union(stored.sticky, enforced(replica, choice.seen)),
|
|
657
|
+
lastServerReadAt: stored.lastServerReadAt, divergences: stored.divergences, refused: stored.refused };
|
|
658
|
+
}
|
|
333
659
|
}
|
|
334
660
|
let trusted;
|
|
335
661
|
try {
|
|
@@ -342,6 +668,8 @@ async function admit(replica, stored, ledger, now, isMember, code) {
|
|
|
342
668
|
throw new LedgerStoreError(code);
|
|
343
669
|
if (stored === null || next.ledger !== stored.ledger || !sameSticky(next.sticky, stored.sticky))
|
|
344
670
|
await save(replica, { ...next, trusted });
|
|
671
|
+
if (adoption !== null)
|
|
672
|
+
replica.log(adoption);
|
|
345
673
|
return structuredClone(trusted);
|
|
346
674
|
}
|
|
347
675
|
export class BridgeLedgerStore {
|
|
@@ -360,27 +688,26 @@ export class BridgeLedgerStore {
|
|
|
360
688
|
return state === null ? null : structuredClone(state.trusted);
|
|
361
689
|
});
|
|
362
690
|
}
|
|
363
|
-
/**
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
async ownBridgeRevoked(bridgeId) {
|
|
369
|
-
return statusOf(await this.trusted(), bridgeId, '') === 'revoked';
|
|
691
|
+
/** The startup decision (ledger-startup ownStatusAction): 'active' iff trusted() lists this bridge with exactly its keys; 'revoked' iff the
|
|
692
|
+
* stored RAW chain proves its removal (ledgerRemovesBridge: it added these keys, then revoked the id); else 'absent' (a tombstone, a
|
|
693
|
+
* sticky-only id or a removal seen on a refused history is no removal). The same rule as BridgeSecurityService's running check. */
|
|
694
|
+
async ownBridgeStatus(own) {
|
|
695
|
+
return serialized(this.replica.file, async () => statusOf(await load(this.replica), own));
|
|
370
696
|
}
|
|
371
697
|
/**
|
|
372
|
-
* Offers an untrusted candidate chain from the server or the relay. Never binds an empty store, and never
|
|
373
|
-
* it is rejected. Throws LedgerStoreError for a corrupted or wrong-account local file, and
|
|
374
|
-
* when the file cannot be read or written.
|
|
698
|
+
* Offers an untrusted candidate chain from the server or the relay (see decide for the rule). Never binds an empty store, and never
|
|
699
|
+
* throws for a bad candidate: it is rejected. Throws LedgerStoreError for a corrupted or wrong-account local file, and
|
|
700
|
+
* BridgeSecurityError('storage_unavailable') when the file cannot be read or written.
|
|
375
701
|
*/
|
|
376
702
|
offer(candidate, source) {
|
|
377
703
|
return serialized(this.replica.file, async () => structuredClone(await decide(this.replica, candidate, source)));
|
|
378
704
|
}
|
|
379
705
|
/**
|
|
380
706
|
* The pairing path, after openPairApproval. Runs confirmPairedLedger(link, approval, ledger, now, sticky) with this replica's sticky
|
|
381
|
-
* set, then binds the empty store (or applies the
|
|
382
|
-
* the resulting trusted roster. Returns trusted(). Throws LedgerStoreError('
|
|
383
|
-
* pairing failure
|
|
707
|
+
* set, then binds the empty store (or applies the append-only rule to a bound store of the same account); the request's bridge must be in
|
|
708
|
+
* the resulting trusted roster. Returns trusted(). Throws LedgerStoreError('ledger_store_pairing_revoked') when the chain (or this
|
|
709
|
+
* replica's sticky set) revokes the request's bridge id, LedgerStoreError('ledger_store_pairing_rejected') on any other pairing failure;
|
|
710
|
+
* writes nothing in either case.
|
|
384
711
|
*/
|
|
385
712
|
adoptPaired(link, approval, ledger, now) {
|
|
386
713
|
const replica = this.replica;
|
|
@@ -403,6 +730,19 @@ export class BridgeLedgerStore {
|
|
|
403
730
|
await confirmPairedLedger(link, approval, parsed, at, sticky);
|
|
404
731
|
}
|
|
405
732
|
catch {
|
|
733
|
+
// A VALID chain of the approved account that revokes this request's bridge id (a tombstone, or a removal right after the approval):
|
|
734
|
+
// the id can never be a member, so retrying the same pairing would loop. It is not the removal of a paired bridge: nothing is reset.
|
|
735
|
+
if (sticky.bridgeIds.indexOf(link.request.bridgeId) !== -1 && await accountIdOf(parsed.genesis.payload) === approval.accountId) {
|
|
736
|
+
let valid = true;
|
|
737
|
+
try {
|
|
738
|
+
await verifyLedger(parsed, at);
|
|
739
|
+
}
|
|
740
|
+
catch {
|
|
741
|
+
valid = false;
|
|
742
|
+
}
|
|
743
|
+
if (valid)
|
|
744
|
+
throw new LedgerStoreError('ledger_store_pairing_revoked');
|
|
745
|
+
}
|
|
406
746
|
throw rejected();
|
|
407
747
|
}
|
|
408
748
|
const request = link.request;
|