mindoodb 0.0.65 → 0.0.67
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/README-server.md +77 -16
- package/README.md +41 -58
- package/dist/appendonlystores/network/HttpTransport.d.ts +2 -0
- package/dist/appendonlystores/network/HttpTransport.d.ts.map +1 -1
- package/dist/appendonlystores/network/HttpTransport.js +31 -6
- package/dist/appendonlystores/network/HttpTransport.js.map +1 -1
- package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts +18 -8
- package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts.map +1 -1
- package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js +30 -16
- package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js.map +1 -1
- package/dist/browser/index.mjs +29 -0
- package/dist/core/BaseMindooDB.d.ts +129 -8
- package/dist/core/BaseMindooDB.d.ts.map +1 -1
- package/dist/core/BaseMindooDB.js +631 -60
- package/dist/core/BaseMindooDB.js.map +1 -1
- package/dist/core/appendonlystores/network/IrohNetworkTransport.d.ts.map +1 -1
- package/dist/core/appendonlystores/network/IrohNetworkTransport.js +5 -1
- package/dist/core/appendonlystores/network/IrohNetworkTransport.js.map +1 -1
- package/dist/core/appendonlystores/network/IrohStreamIO.d.ts +10 -0
- package/dist/core/appendonlystores/network/IrohStreamIO.d.ts.map +1 -1
- package/dist/core/appendonlystores/network/IrohStreamIO.js.map +1 -1
- package/dist/core/appendonlystores/network/types.d.ts +20 -0
- package/dist/core/appendonlystores/network/types.d.ts.map +1 -1
- package/dist/core/appendonlystores/network/types.js +33 -0
- package/dist/core/appendonlystores/network/types.js.map +1 -1
- package/dist/core/appendonlystores/syncStores.d.ts +80 -2
- package/dist/core/appendonlystores/syncStores.d.ts.map +1 -1
- package/dist/core/appendonlystores/syncStores.js +82 -9
- package/dist/core/appendonlystores/syncStores.js.map +1 -1
- package/dist/core/appendonlystores/types.d.ts +88 -6
- package/dist/core/appendonlystores/types.d.ts.map +1 -1
- package/dist/core/appendonlystores/types.js +61 -1
- package/dist/core/appendonlystores/types.js.map +1 -1
- package/dist/core/builtinDbInvariants.d.ts +8 -5
- package/dist/core/builtinDbInvariants.d.ts.map +1 -1
- package/dist/core/builtinDbInvariants.js +8 -5
- package/dist/core/builtinDbInvariants.js.map +1 -1
- package/dist/core/index.d.ts +8 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +45 -11
- package/dist/core/index.js.map +1 -1
- package/dist/core/indexing/summary/DocumentSummaryStore.d.ts +26 -3
- package/dist/core/indexing/summary/DocumentSummaryStore.d.ts.map +1 -1
- package/dist/core/indexing/summary/DocumentSummaryStore.js +100 -15
- package/dist/core/indexing/summary/DocumentSummaryStore.js.map +1 -1
- package/dist/core/quarantine/QuarantineDocument.d.ts +211 -0
- package/dist/core/quarantine/QuarantineDocument.d.ts.map +1 -0
- package/dist/core/quarantine/QuarantineDocument.js +314 -0
- package/dist/core/quarantine/QuarantineDocument.js.map +1 -0
- package/dist/core/quarantine/QuarantineView.d.ts +90 -0
- package/dist/core/quarantine/QuarantineView.d.ts.map +1 -0
- package/dist/core/quarantine/QuarantineView.js +106 -0
- package/dist/core/quarantine/QuarantineView.js.map +1 -0
- package/dist/core/quarantine/recordPushRejections.d.ts +73 -0
- package/dist/core/quarantine/recordPushRejections.d.ts.map +1 -0
- package/dist/core/quarantine/recordPushRejections.js +112 -0
- package/dist/core/quarantine/recordPushRejections.js.map +1 -0
- package/dist/core/quarantine/resubmitQuarantined.d.ts +81 -0
- package/dist/core/quarantine/resubmitQuarantined.d.ts.map +1 -0
- package/dist/core/quarantine/resubmitQuarantined.js +120 -0
- package/dist/core/quarantine/resubmitQuarantined.js.map +1 -0
- package/dist/core/types.d.ts +132 -4
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/core/userkeys/UserKeyManager.d.ts.map +1 -1
- package/dist/core/userkeys/UserKeyManager.js +11 -1
- package/dist/core/userkeys/UserKeyManager.js.map +1 -1
- package/dist/core/values.d.ts +72 -0
- package/dist/core/values.d.ts.map +1 -0
- package/dist/core/values.js +107 -0
- package/dist/core/values.js.map +1 -0
- package/dist/node/server/MindooDBServer.d.ts.map +1 -1
- package/dist/node/server/MindooDBServer.js +6 -1
- package/dist/node/server/MindooDBServer.js.map +1 -1
- package/dist/node/server/peer/PeerReplicator.d.ts.map +1 -1
- package/dist/node/server/peer/PeerReplicator.js +35 -15
- package/dist/node/server/peer/PeerReplicator.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quarantine records: how a device remembers a write the server turned away,
|
|
3
|
+
* so it can be found again and offered again.
|
|
4
|
+
*
|
|
5
|
+
* A device that pushes an entry and gets a `"policy"` rejection back is holding
|
|
6
|
+
* something awkward: an entry that was legitimate when it was written, is
|
|
7
|
+
* cryptographically sound, and is refused only because a right was withdrawn in
|
|
8
|
+
* the meantime. It must not be dropped — the right may come back — and it must
|
|
9
|
+
* not block the database's push either. So the push moves on and leaves a
|
|
10
|
+
* record here.
|
|
11
|
+
*
|
|
12
|
+
* This matters most for entries that arrived over peer-to-peer sync: a device
|
|
13
|
+
* can hold, and have already merged, a change it did not write itself and whose
|
|
14
|
+
* author has since lost the right to write it. Without a record, the refusal
|
|
15
|
+
* would be a line in a log that disappears on reload.
|
|
16
|
+
*
|
|
17
|
+
* ## Why `userdirectory`
|
|
18
|
+
*
|
|
19
|
+
* The record deliberately does *not* live in the database it is about. The
|
|
20
|
+
* whole reason it exists is that someone lost their write right there, so
|
|
21
|
+
* writing the record there would earn the same refusal as the entry it
|
|
22
|
+
* documents. `userdirectory` is a database every member can write their own
|
|
23
|
+
* documents in without an administrator approving anything.
|
|
24
|
+
*
|
|
25
|
+
* - **`qtn_` prefix** — makes it a *personal document* (see
|
|
26
|
+
* {@link PERSONAL_DOC_ID_PREFIXES}), so the write invariant lets its creator
|
|
27
|
+
* change and delete it, and the admin delete it.
|
|
28
|
+
* - **`default` key, not `$publicinfos`** — the hoster holds the
|
|
29
|
+
* `$publicinfos` key, and which of a tenant's writes were refused is none of
|
|
30
|
+
* the hoster's business. `default` keeps it readable for every member, so it
|
|
31
|
+
* can serve as an audit trail, and opaque to the server.
|
|
32
|
+
*
|
|
33
|
+
* ## One record per author, database, document and class
|
|
34
|
+
*
|
|
35
|
+
* The id is derived rather than random, which makes recording idempotent: a
|
|
36
|
+
* retry that fails the same way updates the record in place instead of adding
|
|
37
|
+
* another. A cascade of refused entries on one document therefore collapses
|
|
38
|
+
* into one record holding a capped list of entry ids.
|
|
39
|
+
*
|
|
40
|
+
* The **author's key fingerprint is part of the derivation**, and that is
|
|
41
|
+
* load-bearing rather than decorative. Without it, two members refused on the
|
|
42
|
+
* same document for the same reason would derive the same id — and since the
|
|
43
|
+
* personal-document invariant only lets the *creator* write a document, the
|
|
44
|
+
* second member's record would be refused by the very invariant that is
|
|
45
|
+
* supposed to protect it. Folding the author in makes id and ownership agree.
|
|
46
|
+
*
|
|
47
|
+
* ## Why records must be verified on read
|
|
48
|
+
*
|
|
49
|
+
* Any tenant member can write a `qtn_` document claiming anything. Since these
|
|
50
|
+
* records say "this person tried to write something they were not allowed to
|
|
51
|
+
* write", an unverified one is a way to put words in someone else's mouth.
|
|
52
|
+
* {@link verifyQuarantineRecord} accepts a record only when:
|
|
53
|
+
*
|
|
54
|
+
* 1. the doc id is derived from the payload's own fields, including the
|
|
55
|
+
* fingerprint of the `signingPublicKey` it names,
|
|
56
|
+
* 2. the `doc_create` entry was signed by exactly that key, and
|
|
57
|
+
* 3. that key is an active (non-revoked) grant in the tenant.
|
|
58
|
+
*
|
|
59
|
+
* **Known limit — a record is evidence of an honest client, not of an honest
|
|
60
|
+
* one's absence.** Nothing compels a device to write a record, so the absence
|
|
61
|
+
* of one proves nothing. Like the `dev_` records, this is useful for auditing
|
|
62
|
+
* cooperating devices, not for catching a malicious one.
|
|
63
|
+
*/
|
|
64
|
+
import type { MindooDB, MindooDoc, SigningKeyPair } from "../types";
|
|
65
|
+
import type { PutRejectionClass } from "../appendonlystores/types";
|
|
66
|
+
export declare const QUARANTINE_DOC_ID_PREFIX = "qtn_";
|
|
67
|
+
export declare const QUARANTINE_FORM = "userdirectory";
|
|
68
|
+
export declare const QUARANTINE_TYPE = "quarantine";
|
|
69
|
+
export declare const QUARANTINE_SCHEMA_VERSION = 1;
|
|
70
|
+
/**
|
|
71
|
+
* How many entry ids one record carries.
|
|
72
|
+
*
|
|
73
|
+
* A quarantined write usually drags its causal dependents along, so one
|
|
74
|
+
* refusal can mean a long list. The list is for finding the entries again, and
|
|
75
|
+
* a bounded one does that just as well while keeping the record — which syncs
|
|
76
|
+
* to every member of the tenant — from growing without limit.
|
|
77
|
+
*/
|
|
78
|
+
export declare const MAX_QUARANTINED_ENTRY_IDS = 200;
|
|
79
|
+
/** Payload of a `qtn_` document. Encrypted with {@link DEFAULT_TENANT_KEY_ID}. */
|
|
80
|
+
export interface QuarantineDocumentPayload {
|
|
81
|
+
form: typeof QUARANTINE_FORM;
|
|
82
|
+
type: typeof QUARANTINE_TYPE;
|
|
83
|
+
schemaVersion: number;
|
|
84
|
+
/**
|
|
85
|
+
* Signing public key (PEM) of the device that recorded this. The whole
|
|
86
|
+
* verification chain hangs off this field.
|
|
87
|
+
*/
|
|
88
|
+
signingPublicKey: string;
|
|
89
|
+
/** Database the refused entries belong to. Not `userdirectory` itself. */
|
|
90
|
+
dbid: string;
|
|
91
|
+
/** Document the refused entries belong to. */
|
|
92
|
+
quarantinedDocId: string;
|
|
93
|
+
/** Why the target refused them. */
|
|
94
|
+
rejectionClass: PutRejectionClass;
|
|
95
|
+
/** The target's own wording, kept verbatim for the audit view. */
|
|
96
|
+
reason: string;
|
|
97
|
+
/** Refused entry ids, capped at {@link MAX_QUARANTINED_ENTRY_IDS}. */
|
|
98
|
+
entryIds: string[];
|
|
99
|
+
/** How many refused entries did not fit into {@link entryIds}. */
|
|
100
|
+
omittedEntryCount?: number;
|
|
101
|
+
firstSeenAt: number;
|
|
102
|
+
updatedAt: number;
|
|
103
|
+
}
|
|
104
|
+
/** A record that passed {@link verifyQuarantineRecord}. */
|
|
105
|
+
export interface QuarantineRecordDoc {
|
|
106
|
+
docId: string;
|
|
107
|
+
signingPublicKey: string;
|
|
108
|
+
/** Fingerprint of {@link signingPublicKey}, colon-separated hex. */
|
|
109
|
+
signingKeyFingerprint: string;
|
|
110
|
+
dbid: string;
|
|
111
|
+
quarantinedDocId: string;
|
|
112
|
+
rejectionClass: PutRejectionClass;
|
|
113
|
+
reason: string;
|
|
114
|
+
entryIds: string[];
|
|
115
|
+
omittedEntryCount: number;
|
|
116
|
+
firstSeenAt: number;
|
|
117
|
+
updatedAt: number;
|
|
118
|
+
}
|
|
119
|
+
/** The directory calls a quarantine record needs to verify a claim. */
|
|
120
|
+
export interface QuarantineDirectory {
|
|
121
|
+
/** True when the key belongs to an active, non-revoked grant. */
|
|
122
|
+
validatePublicSigningKey(publicKey: string, opts?: {
|
|
123
|
+
forceRefresh?: boolean;
|
|
124
|
+
}): Promise<boolean>;
|
|
125
|
+
}
|
|
126
|
+
/** What identifies one quarantine record. */
|
|
127
|
+
export interface QuarantineRecordKey {
|
|
128
|
+
/** Fingerprint of the recording device's signing key, colon-separated hex. */
|
|
129
|
+
signingKeyFingerprint: string;
|
|
130
|
+
dbid: string;
|
|
131
|
+
quarantinedDocId: string;
|
|
132
|
+
rejectionClass: PutRejectionClass;
|
|
133
|
+
}
|
|
134
|
+
/** Is this a quarantine record id? */
|
|
135
|
+
export declare function isQuarantineDocId(docId: string | undefined | null): boolean;
|
|
136
|
+
/**
|
|
137
|
+
* Deterministic document id for one (author, database, document, class).
|
|
138
|
+
*
|
|
139
|
+
* Deterministic so that recording the same refusal again updates the record
|
|
140
|
+
* instead of accumulating one per attempt. Hashed rather than concatenated
|
|
141
|
+
* because custom ids must match `^[a-z][a-z0-9_]*$` (`CUSTOM_DOC_ID_REGEX`) and
|
|
142
|
+
* a document id can hold anything.
|
|
143
|
+
*/
|
|
144
|
+
export declare function quarantineDocumentId(key: QuarantineRecordKey, subtle: SubtleCrypto): Promise<string>;
|
|
145
|
+
/** Thrown when a caller tries to record a quarantine about a quarantine record. */
|
|
146
|
+
export declare class QuarantineRecursionError extends Error {
|
|
147
|
+
constructor(docId: string);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Record (or extend) the quarantine for one document's refused entries.
|
|
151
|
+
*
|
|
152
|
+
* Idempotent in the sense that matters: calling it again with entry ids the
|
|
153
|
+
* record already holds writes nothing at all. That is not an optimisation. A
|
|
154
|
+
* write here appends an entry, which syncs, which can trigger the next push,
|
|
155
|
+
* which would record again — so a record that rewrote itself on every attempt
|
|
156
|
+
* would feed a loop the user pays for in bandwidth and history size.
|
|
157
|
+
*
|
|
158
|
+
* @throws QuarantineRecursionError when asked to record a `qtn_` document,
|
|
159
|
+
* which would let one refusal generate an unbounded chain of records.
|
|
160
|
+
*/
|
|
161
|
+
export declare function recordPushQuarantine(input: {
|
|
162
|
+
/** The tenant's `userdirectory`, not the database the entries belong to. */
|
|
163
|
+
db: MindooDB;
|
|
164
|
+
/** Signing public key (PEM) of this device — must match the identity writing the document. */
|
|
165
|
+
signingPublicKey: string;
|
|
166
|
+
dbid: string;
|
|
167
|
+
quarantinedDocId: string;
|
|
168
|
+
rejectionClass: PutRejectionClass;
|
|
169
|
+
reason: string;
|
|
170
|
+
entryIds: readonly string[];
|
|
171
|
+
subtle: SubtleCrypto;
|
|
172
|
+
signingKeyPair?: SigningKeyPair;
|
|
173
|
+
signingKeyPassword?: string;
|
|
174
|
+
now?: number;
|
|
175
|
+
}): Promise<MindooDoc>;
|
|
176
|
+
/**
|
|
177
|
+
* Withdraw a record, because the entries were finally accepted or the user gave
|
|
178
|
+
* up on them.
|
|
179
|
+
*/
|
|
180
|
+
export declare function clearQuarantineRecord(input: {
|
|
181
|
+
db: MindooDB;
|
|
182
|
+
key: QuarantineRecordKey;
|
|
183
|
+
subtle: SubtleCrypto;
|
|
184
|
+
}): Promise<void>;
|
|
185
|
+
/**
|
|
186
|
+
* Check one record's claim. Returns the verified record, or `null` when any step
|
|
187
|
+
* of the chain described in the module docs fails.
|
|
188
|
+
*/
|
|
189
|
+
export declare function verifyQuarantineRecord(input: {
|
|
190
|
+
db: MindooDB;
|
|
191
|
+
directory: QuarantineDirectory;
|
|
192
|
+
doc: MindooDoc;
|
|
193
|
+
subtle: SubtleCrypto;
|
|
194
|
+
}): Promise<QuarantineRecordDoc | null>;
|
|
195
|
+
/**
|
|
196
|
+
* Every verified quarantine record in this `userdirectory` replica.
|
|
197
|
+
*
|
|
198
|
+
* Requires the tenant `default` key to be unlocked; records that cannot be
|
|
199
|
+
* decrypted are simply absent. Unverifiable records are skipped silently —
|
|
200
|
+
* they are the expected shape of an attack, not an error to surface.
|
|
201
|
+
*/
|
|
202
|
+
export declare function listQuarantineRecords(input: {
|
|
203
|
+
db: MindooDB;
|
|
204
|
+
directory: QuarantineDirectory;
|
|
205
|
+
subtle: SubtleCrypto;
|
|
206
|
+
/** Restrict to one database, e.g. for the sync view of a single database. */
|
|
207
|
+
dbid?: string;
|
|
208
|
+
/** Restrict to records this device wrote itself. */
|
|
209
|
+
signingKeyFingerprint?: string;
|
|
210
|
+
}): Promise<QuarantineRecordDoc[]>;
|
|
211
|
+
//# sourceMappingURL=QuarantineDocument.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"QuarantineDocument.d.ts","sourceRoot":"","sources":["../../../src/core/quarantine/QuarantineDocument.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Quarantine records: how a device remembers a write the server turned away,
|
|
4
|
+
* so it can be found again and offered again.
|
|
5
|
+
*
|
|
6
|
+
* A device that pushes an entry and gets a `"policy"` rejection back is holding
|
|
7
|
+
* something awkward: an entry that was legitimate when it was written, is
|
|
8
|
+
* cryptographically sound, and is refused only because a right was withdrawn in
|
|
9
|
+
* the meantime. It must not be dropped — the right may come back — and it must
|
|
10
|
+
* not block the database's push either. So the push moves on and leaves a
|
|
11
|
+
* record here.
|
|
12
|
+
*
|
|
13
|
+
* This matters most for entries that arrived over peer-to-peer sync: a device
|
|
14
|
+
* can hold, and have already merged, a change it did not write itself and whose
|
|
15
|
+
* author has since lost the right to write it. Without a record, the refusal
|
|
16
|
+
* would be a line in a log that disappears on reload.
|
|
17
|
+
*
|
|
18
|
+
* ## Why `userdirectory`
|
|
19
|
+
*
|
|
20
|
+
* The record deliberately does *not* live in the database it is about. The
|
|
21
|
+
* whole reason it exists is that someone lost their write right there, so
|
|
22
|
+
* writing the record there would earn the same refusal as the entry it
|
|
23
|
+
* documents. `userdirectory` is a database every member can write their own
|
|
24
|
+
* documents in without an administrator approving anything.
|
|
25
|
+
*
|
|
26
|
+
* - **`qtn_` prefix** — makes it a *personal document* (see
|
|
27
|
+
* {@link PERSONAL_DOC_ID_PREFIXES}), so the write invariant lets its creator
|
|
28
|
+
* change and delete it, and the admin delete it.
|
|
29
|
+
* - **`default` key, not `$publicinfos`** — the hoster holds the
|
|
30
|
+
* `$publicinfos` key, and which of a tenant's writes were refused is none of
|
|
31
|
+
* the hoster's business. `default` keeps it readable for every member, so it
|
|
32
|
+
* can serve as an audit trail, and opaque to the server.
|
|
33
|
+
*
|
|
34
|
+
* ## One record per author, database, document and class
|
|
35
|
+
*
|
|
36
|
+
* The id is derived rather than random, which makes recording idempotent: a
|
|
37
|
+
* retry that fails the same way updates the record in place instead of adding
|
|
38
|
+
* another. A cascade of refused entries on one document therefore collapses
|
|
39
|
+
* into one record holding a capped list of entry ids.
|
|
40
|
+
*
|
|
41
|
+
* The **author's key fingerprint is part of the derivation**, and that is
|
|
42
|
+
* load-bearing rather than decorative. Without it, two members refused on the
|
|
43
|
+
* same document for the same reason would derive the same id — and since the
|
|
44
|
+
* personal-document invariant only lets the *creator* write a document, the
|
|
45
|
+
* second member's record would be refused by the very invariant that is
|
|
46
|
+
* supposed to protect it. Folding the author in makes id and ownership agree.
|
|
47
|
+
*
|
|
48
|
+
* ## Why records must be verified on read
|
|
49
|
+
*
|
|
50
|
+
* Any tenant member can write a `qtn_` document claiming anything. Since these
|
|
51
|
+
* records say "this person tried to write something they were not allowed to
|
|
52
|
+
* write", an unverified one is a way to put words in someone else's mouth.
|
|
53
|
+
* {@link verifyQuarantineRecord} accepts a record only when:
|
|
54
|
+
*
|
|
55
|
+
* 1. the doc id is derived from the payload's own fields, including the
|
|
56
|
+
* fingerprint of the `signingPublicKey` it names,
|
|
57
|
+
* 2. the `doc_create` entry was signed by exactly that key, and
|
|
58
|
+
* 3. that key is an active (non-revoked) grant in the tenant.
|
|
59
|
+
*
|
|
60
|
+
* **Known limit — a record is evidence of an honest client, not of an honest
|
|
61
|
+
* one's absence.** Nothing compels a device to write a record, so the absence
|
|
62
|
+
* of one proves nothing. Like the `dev_` records, this is useful for auditing
|
|
63
|
+
* cooperating devices, not for catching a malicious one.
|
|
64
|
+
*/
|
|
65
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
66
|
+
exports.QuarantineRecursionError = exports.MAX_QUARANTINED_ENTRY_IDS = exports.QUARANTINE_SCHEMA_VERSION = exports.QUARANTINE_TYPE = exports.QUARANTINE_FORM = exports.QUARANTINE_DOC_ID_PREFIX = void 0;
|
|
67
|
+
exports.isQuarantineDocId = isQuarantineDocId;
|
|
68
|
+
exports.quarantineDocumentId = quarantineDocumentId;
|
|
69
|
+
exports.recordPushQuarantine = recordPushQuarantine;
|
|
70
|
+
exports.clearQuarantineRecord = clearQuarantineRecord;
|
|
71
|
+
exports.verifyQuarantineRecord = verifyQuarantineRecord;
|
|
72
|
+
exports.listQuarantineRecords = listQuarantineRecords;
|
|
73
|
+
const types_1 = require("../types");
|
|
74
|
+
const fingerprint_1 = require("../userkeys/fingerprint");
|
|
75
|
+
exports.QUARANTINE_DOC_ID_PREFIX = "qtn_";
|
|
76
|
+
exports.QUARANTINE_FORM = "userdirectory";
|
|
77
|
+
exports.QUARANTINE_TYPE = "quarantine";
|
|
78
|
+
exports.QUARANTINE_SCHEMA_VERSION = 1;
|
|
79
|
+
/**
|
|
80
|
+
* How many entry ids one record carries.
|
|
81
|
+
*
|
|
82
|
+
* A quarantined write usually drags its causal dependents along, so one
|
|
83
|
+
* refusal can mean a long list. The list is for finding the entries again, and
|
|
84
|
+
* a bounded one does that just as well while keeping the record — which syncs
|
|
85
|
+
* to every member of the tenant — from growing without limit.
|
|
86
|
+
*/
|
|
87
|
+
exports.MAX_QUARANTINED_ENTRY_IDS = 200;
|
|
88
|
+
/** Is this a quarantine record id? */
|
|
89
|
+
function isQuarantineDocId(docId) {
|
|
90
|
+
return typeof docId === "string" && docId.startsWith(exports.QUARANTINE_DOC_ID_PREFIX);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Deterministic document id for one (author, database, document, class).
|
|
94
|
+
*
|
|
95
|
+
* Deterministic so that recording the same refusal again updates the record
|
|
96
|
+
* instead of accumulating one per attempt. Hashed rather than concatenated
|
|
97
|
+
* because custom ids must match `^[a-z][a-z0-9_]*$` (`CUSTOM_DOC_ID_REGEX`) and
|
|
98
|
+
* a document id can hold anything.
|
|
99
|
+
*/
|
|
100
|
+
async function quarantineDocumentId(key, subtle) {
|
|
101
|
+
const material = [
|
|
102
|
+
key.signingKeyFingerprint.replace(/:/g, "").toLowerCase(),
|
|
103
|
+
key.dbid,
|
|
104
|
+
key.quarantinedDocId,
|
|
105
|
+
key.rejectionClass,
|
|
106
|
+
].join("\u0000");
|
|
107
|
+
const digest = await subtle.digest("SHA-256", new TextEncoder().encode(material));
|
|
108
|
+
const hex = [...new Uint8Array(digest)]
|
|
109
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
110
|
+
.join("");
|
|
111
|
+
return `${exports.QUARANTINE_DOC_ID_PREFIX}${hex.slice(0, 40)}`;
|
|
112
|
+
}
|
|
113
|
+
/** Thrown when a caller tries to record a quarantine about a quarantine record. */
|
|
114
|
+
class QuarantineRecursionError extends Error {
|
|
115
|
+
constructor(docId) {
|
|
116
|
+
super(`Refusing to record a quarantine for ${docId}: quarantine records are not themselves quarantined`);
|
|
117
|
+
this.name = "QuarantineRecursionError";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
exports.QuarantineRecursionError = QuarantineRecursionError;
|
|
121
|
+
/**
|
|
122
|
+
* Record (or extend) the quarantine for one document's refused entries.
|
|
123
|
+
*
|
|
124
|
+
* Idempotent in the sense that matters: calling it again with entry ids the
|
|
125
|
+
* record already holds writes nothing at all. That is not an optimisation. A
|
|
126
|
+
* write here appends an entry, which syncs, which can trigger the next push,
|
|
127
|
+
* which would record again — so a record that rewrote itself on every attempt
|
|
128
|
+
* would feed a loop the user pays for in bandwidth and history size.
|
|
129
|
+
*
|
|
130
|
+
* @throws QuarantineRecursionError when asked to record a `qtn_` document,
|
|
131
|
+
* which would let one refusal generate an unbounded chain of records.
|
|
132
|
+
*/
|
|
133
|
+
async function recordPushQuarantine(input) {
|
|
134
|
+
if (isQuarantineDocId(input.quarantinedDocId)) {
|
|
135
|
+
throw new QuarantineRecursionError(input.quarantinedDocId);
|
|
136
|
+
}
|
|
137
|
+
const fingerprint = await (0, fingerprint_1.fingerprintPublicKeyPem)(input.signingPublicKey, input.subtle);
|
|
138
|
+
const docId = await quarantineDocumentId({
|
|
139
|
+
signingKeyFingerprint: fingerprint,
|
|
140
|
+
dbid: input.dbid,
|
|
141
|
+
quarantinedDocId: input.quarantinedDocId,
|
|
142
|
+
rejectionClass: input.rejectionClass,
|
|
143
|
+
}, input.subtle);
|
|
144
|
+
const doc = (await input.db.getDocument(docId).catch(() => null)) ??
|
|
145
|
+
// Not `assumeUniqueId`: the id is derived rather than random, so two
|
|
146
|
+
// replicas creating it concurrently must share Automerge ancestry to
|
|
147
|
+
// converge instead of forking. That path also forbids `initialValues`,
|
|
148
|
+
// which is why the payload goes in through `changeDoc` below — the same
|
|
149
|
+
// call that extends an existing record.
|
|
150
|
+
(await input.db.createDocument({
|
|
151
|
+
id: docId,
|
|
152
|
+
decryptionKeyId: types_1.DEFAULT_TENANT_KEY_ID,
|
|
153
|
+
signingKeyPair: input.signingKeyPair,
|
|
154
|
+
signingKeyPassword: input.signingKeyPassword,
|
|
155
|
+
}));
|
|
156
|
+
const current = doc.getData();
|
|
157
|
+
const known = new Set(Array.isArray(current.entryIds) ? current.entryIds : []);
|
|
158
|
+
const added = input.entryIds.filter((id) => !known.has(id));
|
|
159
|
+
const alreadyDescribed = current.type === exports.QUARANTINE_TYPE &&
|
|
160
|
+
current.schemaVersion === exports.QUARANTINE_SCHEMA_VERSION &&
|
|
161
|
+
current.signingPublicKey === input.signingPublicKey &&
|
|
162
|
+
current.dbid === input.dbid &&
|
|
163
|
+
current.quarantinedDocId === input.quarantinedDocId &&
|
|
164
|
+
current.rejectionClass === input.rejectionClass &&
|
|
165
|
+
current.reason === input.reason;
|
|
166
|
+
if (alreadyDescribed && added.length === 0) {
|
|
167
|
+
return doc;
|
|
168
|
+
}
|
|
169
|
+
const merged = [...known, ...added];
|
|
170
|
+
const kept = merged.slice(0, exports.MAX_QUARANTINED_ENTRY_IDS);
|
|
171
|
+
const omitted = (current.omittedEntryCount ?? 0) + (merged.length - kept.length);
|
|
172
|
+
const now = input.now ?? Date.now();
|
|
173
|
+
await input.db.changeDoc(doc, (editable) => {
|
|
174
|
+
const data = editable.getData();
|
|
175
|
+
data.form = exports.QUARANTINE_FORM;
|
|
176
|
+
data.type = exports.QUARANTINE_TYPE;
|
|
177
|
+
data.schemaVersion = exports.QUARANTINE_SCHEMA_VERSION;
|
|
178
|
+
data.signingPublicKey = input.signingPublicKey;
|
|
179
|
+
data.dbid = input.dbid;
|
|
180
|
+
data.quarantinedDocId = input.quarantinedDocId;
|
|
181
|
+
data.rejectionClass = input.rejectionClass;
|
|
182
|
+
data.reason = input.reason;
|
|
183
|
+
data.entryIds = kept;
|
|
184
|
+
if (omitted > 0) {
|
|
185
|
+
data.omittedEntryCount = omitted;
|
|
186
|
+
}
|
|
187
|
+
data.firstSeenAt = typeof current.firstSeenAt === "number" ? current.firstSeenAt : now;
|
|
188
|
+
data.updatedAt = now;
|
|
189
|
+
});
|
|
190
|
+
return doc;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Withdraw a record, because the entries were finally accepted or the user gave
|
|
194
|
+
* up on them.
|
|
195
|
+
*/
|
|
196
|
+
async function clearQuarantineRecord(input) {
|
|
197
|
+
const docId = await quarantineDocumentId(input.key, input.subtle);
|
|
198
|
+
await input.db.deleteDocument(docId).catch(() => {
|
|
199
|
+
// Already gone, or never recorded from this device.
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Check one record's claim. Returns the verified record, or `null` when any step
|
|
204
|
+
* of the chain described in the module docs fails.
|
|
205
|
+
*/
|
|
206
|
+
async function verifyQuarantineRecord(input) {
|
|
207
|
+
const docId = input.doc.getId();
|
|
208
|
+
const data = input.doc.getData();
|
|
209
|
+
const signingPublicKey = typeof data.signingPublicKey === "string" ? data.signingPublicKey : "";
|
|
210
|
+
const dbid = typeof data.dbid === "string" ? data.dbid : "";
|
|
211
|
+
const quarantinedDocId = typeof data.quarantinedDocId === "string" ? data.quarantinedDocId : "";
|
|
212
|
+
const rejectionClass = data.rejectionClass;
|
|
213
|
+
if (data.type !== exports.QUARANTINE_TYPE ||
|
|
214
|
+
!signingPublicKey ||
|
|
215
|
+
!dbid ||
|
|
216
|
+
!quarantinedDocId ||
|
|
217
|
+
typeof rejectionClass !== "string") {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
// 1. The id must be derived from the payload's own fields, so a record cannot
|
|
221
|
+
// sit under an id describing a different author, database or document than
|
|
222
|
+
// the one it names.
|
|
223
|
+
const fingerprint = await (0, fingerprint_1.fingerprintPublicKeyPem)(signingPublicKey, input.subtle);
|
|
224
|
+
const expectedId = await quarantineDocumentId({
|
|
225
|
+
signingKeyFingerprint: fingerprint,
|
|
226
|
+
dbid,
|
|
227
|
+
quarantinedDocId,
|
|
228
|
+
rejectionClass: rejectionClass,
|
|
229
|
+
}, input.subtle);
|
|
230
|
+
if (expectedId !== docId) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
// 2. That key must be the one that actually created the document. Without
|
|
234
|
+
// this, any member could record a refusal in someone else's name.
|
|
235
|
+
const creatorKey = await resolveCreateSigner(input.db, docId);
|
|
236
|
+
if (!creatorKey || normalizePem(creatorKey) !== normalizePem(signingPublicKey)) {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
// 3. And it must still be a live grant, so revoked devices drop out.
|
|
240
|
+
if (!(await input.directory.validatePublicSigningKey(signingPublicKey).catch(() => false))) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
docId,
|
|
245
|
+
signingPublicKey,
|
|
246
|
+
signingKeyFingerprint: fingerprint,
|
|
247
|
+
dbid,
|
|
248
|
+
quarantinedDocId,
|
|
249
|
+
rejectionClass: rejectionClass,
|
|
250
|
+
reason: typeof data.reason === "string" ? data.reason : "",
|
|
251
|
+
entryIds: Array.isArray(data.entryIds)
|
|
252
|
+
? data.entryIds.filter((id) => typeof id === "string")
|
|
253
|
+
: [],
|
|
254
|
+
omittedEntryCount: typeof data.omittedEntryCount === "number" ? data.omittedEntryCount : 0,
|
|
255
|
+
firstSeenAt: typeof data.firstSeenAt === "number" ? data.firstSeenAt : 0,
|
|
256
|
+
updatedAt: typeof data.updatedAt === "number" ? data.updatedAt : 0,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Every verified quarantine record in this `userdirectory` replica.
|
|
261
|
+
*
|
|
262
|
+
* Requires the tenant `default` key to be unlocked; records that cannot be
|
|
263
|
+
* decrypted are simply absent. Unverifiable records are skipped silently —
|
|
264
|
+
* they are the expected shape of an attack, not an error to surface.
|
|
265
|
+
*/
|
|
266
|
+
async function listQuarantineRecords(input) {
|
|
267
|
+
// `idPrefix` matching is boundary-aware: "qtn" matches `qtn_…`, not `qtnx…`.
|
|
268
|
+
const docIds = await input.db.getAllDocumentIds({ idPrefix: "qtn" });
|
|
269
|
+
const records = [];
|
|
270
|
+
for (const docId of docIds) {
|
|
271
|
+
if (!isQuarantineDocId(docId)) {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const doc = await input.db.getDocument(docId).catch(() => null);
|
|
275
|
+
if (!doc) {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const record = await verifyQuarantineRecord({
|
|
279
|
+
db: input.db,
|
|
280
|
+
directory: input.directory,
|
|
281
|
+
doc,
|
|
282
|
+
subtle: input.subtle,
|
|
283
|
+
});
|
|
284
|
+
if (!record) {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (input.dbid !== undefined && record.dbid !== input.dbid) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (input.signingKeyFingerprint !== undefined &&
|
|
291
|
+
record.signingKeyFingerprint !== input.signingKeyFingerprint) {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
records.push(record);
|
|
295
|
+
}
|
|
296
|
+
return records;
|
|
297
|
+
}
|
|
298
|
+
/** Signing key that wrote the `doc_create` entry, or null when it is not here yet. */
|
|
299
|
+
async function resolveCreateSigner(db, docId) {
|
|
300
|
+
try {
|
|
301
|
+
// History is ordered causally, so `doc_create` is on the first page.
|
|
302
|
+
const page = await db.getDocumentHistoryPage(docId, { limit: 10 });
|
|
303
|
+
const create = page.entries.find((entry) => entry.entryType === "doc_create");
|
|
304
|
+
return create?.changeCreatedByPublicKey ?? null;
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/** PEM formatting varies by transport; compare the key material, not the wrapping. */
|
|
311
|
+
function normalizePem(pem) {
|
|
312
|
+
return pem.replace(/\s+/g, "");
|
|
313
|
+
}
|
|
314
|
+
//# sourceMappingURL=QuarantineDocument.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"QuarantineDocument.js","sourceRoot":"","sources":["../../../src/core/quarantine/QuarantineDocument.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One list for both kinds of quarantine.
|
|
3
|
+
*
|
|
4
|
+
* A change can be held back in two directions, and from the user's side they are
|
|
5
|
+
* the same complaint — "something I have is not where it should be, and nobody
|
|
6
|
+
* is retrying it":
|
|
7
|
+
*
|
|
8
|
+
* - **inbound** — an entry arrived here (over peer-to-peer sync, usually) and
|
|
9
|
+
* was refused at materialization, so it sits in the local store without
|
|
10
|
+
* affecting any document. Recorded in the metadata checkpoint, local to this
|
|
11
|
+
* device, keyed per document. See `QuarantineRecord`.
|
|
12
|
+
* - **outbound** — an entry was written here and the server refused to ingest
|
|
13
|
+
* it, so the push moved on without it. Recorded as a `qtn_` document in
|
|
14
|
+
* `userdirectory`, which syncs to the whole tenant. See
|
|
15
|
+
* {@link QuarantineRecordDoc}.
|
|
16
|
+
*
|
|
17
|
+
* The two live in different places for good reasons — an inbound refusal is a
|
|
18
|
+
* local verdict that every replica reaches on its own and would be pointless to
|
|
19
|
+
* broadcast, whereas an outbound one is exactly what other members need to see —
|
|
20
|
+
* but a user looking for a missing change does not know which of the two
|
|
21
|
+
* happened. So the storage stays split and the *reading* is unified here.
|
|
22
|
+
*
|
|
23
|
+
* ## Why the directions keep their own vocabulary
|
|
24
|
+
*
|
|
25
|
+
* Their reasons are genuinely different: an inbound refusal names the check that
|
|
26
|
+
* failed on this device (`tier2_denied`, `cascade_dependent`, …), an outbound one
|
|
27
|
+
* names what the server said (`policy`, `purged`, …). Flattening both into one
|
|
28
|
+
* enum would invent equivalences that do not hold, so {@link QuarantineViewRecord}
|
|
29
|
+
* is a discriminated union: `direction` tells a caller which vocabulary `reason`
|
|
30
|
+
* is drawn from, and which fields are even present.
|
|
31
|
+
*/
|
|
32
|
+
import type { QuarantineRecord, QuarantineReason } from "../accesscontrol/materializationGuard";
|
|
33
|
+
import type { PutRejectionClass } from "../appendonlystores/types";
|
|
34
|
+
import { type QuarantineRecordDoc } from "./QuarantineDocument";
|
|
35
|
+
/** Which way the refused change was going. */
|
|
36
|
+
export type QuarantineDirection = "inbound" | "outbound";
|
|
37
|
+
/** What both directions can answer. */
|
|
38
|
+
interface QuarantineViewCommon {
|
|
39
|
+
/** Database the refused entries belong to. */
|
|
40
|
+
dbid: string;
|
|
41
|
+
/** Document the refused entries belong to. */
|
|
42
|
+
documentId: string;
|
|
43
|
+
/** The refusing side's own wording, for the audit view. */
|
|
44
|
+
detail: string;
|
|
45
|
+
/** Refused entry ids, capped at {@link MAX_QUARANTINED_ENTRY_IDS}. */
|
|
46
|
+
entryIds: string[];
|
|
47
|
+
/** How many refused entries did not fit into {@link entryIds}. */
|
|
48
|
+
omittedEntryCount: number;
|
|
49
|
+
/** When this refusal was first seen. */
|
|
50
|
+
firstSeenAt: number;
|
|
51
|
+
/** When it was last confirmed. */
|
|
52
|
+
updatedAt: number;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* One row of the unified quarantine view: all the entries of one document held
|
|
56
|
+
* back for one reason, in one direction.
|
|
57
|
+
*/
|
|
58
|
+
export type QuarantineViewRecord = (QuarantineViewCommon & {
|
|
59
|
+
direction: "inbound";
|
|
60
|
+
/** Which materialization check refused the entries on this device. */
|
|
61
|
+
reason: QuarantineReason;
|
|
62
|
+
}) | (QuarantineViewCommon & {
|
|
63
|
+
direction: "outbound";
|
|
64
|
+
/** How the target classified its refusal. */
|
|
65
|
+
reason: PutRejectionClass;
|
|
66
|
+
/** Fingerprint of the signing key whose push was refused. */
|
|
67
|
+
signingKeyFingerprint: string;
|
|
68
|
+
/** The `qtn_` document holding this record, so a caller can withdraw it. */
|
|
69
|
+
recordDocId: string;
|
|
70
|
+
});
|
|
71
|
+
/**
|
|
72
|
+
* Fold the local materialization log into view rows.
|
|
73
|
+
*
|
|
74
|
+
* The log holds one record per entry, because an inbound refusal is decided per
|
|
75
|
+
* entry; the view groups them per (database, document, reason) so one poisoned
|
|
76
|
+
* entry dragging fifty dependents along reads as one problem rather than
|
|
77
|
+
* fifty-one — matching how the outbound records are already shaped.
|
|
78
|
+
*/
|
|
79
|
+
export declare function inboundQuarantineView(log: readonly QuarantineRecord[]): QuarantineViewRecord[];
|
|
80
|
+
/** Turn verified `qtn_` records into view rows. */
|
|
81
|
+
export declare function outboundQuarantineView(records: readonly QuarantineRecordDoc[]): QuarantineViewRecord[];
|
|
82
|
+
/**
|
|
83
|
+
* The whole quarantine view, newest first.
|
|
84
|
+
*
|
|
85
|
+
* Newest first because the list is read when a change has gone missing, and the
|
|
86
|
+
* change someone is looking for is almost always the last one they made.
|
|
87
|
+
*/
|
|
88
|
+
export declare function mergeQuarantineViews(...groups: readonly (readonly QuarantineViewRecord[])[]): QuarantineViewRecord[];
|
|
89
|
+
export {};
|
|
90
|
+
//# sourceMappingURL=QuarantineView.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"QuarantineView.d.ts","sourceRoot":"","sources":["../../../src/core/quarantine/QuarantineView.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,uCAAuC,CAAC;AAChG,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,2BAA2B,CAAC;AACnE,OAAO,EAA6B,KAAK,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAE3F,8CAA8C;AAC9C,MAAM,MAAM,mBAAmB,GAAG,SAAS,GAAG,UAAU,CAAC;AAEzD,uCAAuC;AACvC,UAAU,oBAAoB;IAC5B,8CAA8C;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,8CAA8C;IAC9C,UAAU,EAAE,MAAM,CAAC;IACnB,2DAA2D;IAC3D,MAAM,EAAE,MAAM,CAAC;IACf,sEAAsE;IACtE,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,kEAAkE;IAClE,iBAAiB,EAAE,MAAM,CAAC;IAC1B,wCAAwC;IACxC,WAAW,EAAE,MAAM,CAAC;IACpB,kCAAkC;IAClC,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,MAAM,MAAM,oBAAoB,GAC5B,CAAC,oBAAoB,GAAG;IACtB,SAAS,EAAE,SAAS,CAAC;IACrB,sEAAsE;IACtE,MAAM,EAAE,gBAAgB,CAAC;CAC1B,CAAC,GACF,CAAC,oBAAoB,GAAG;IACtB,SAAS,EAAE,UAAU,CAAC;IACtB,6CAA6C;IAC7C,MAAM,EAAE,iBAAiB,CAAC;IAC1B,6DAA6D;IAC7D,qBAAqB,EAAE,MAAM,CAAC;IAC9B,4EAA4E;IAC5E,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC,CAAC;AAEP;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,SAAS,gBAAgB,EAAE,GAC/B,oBAAoB,EAAE,CA4BxB;AAED,mDAAmD;AACnD,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,SAAS,mBAAmB,EAAE,GACtC,oBAAoB,EAAE,CAcxB;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,MAAM,EAAE,SAAS,CAAC,SAAS,oBAAoB,EAAE,CAAC,EAAE,GACtD,oBAAoB,EAAE,CAUxB"}
|