@docstack/client 0.2.0 → 0.3.1
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.md +52 -39
- package/lib/core/crypto-engine/index.d.ts +63 -5
- package/lib/core/crypto-engine/utils.d.ts +10 -2
- package/lib/core/index.d.ts +1 -1
- package/lib/core/stack.d.ts +76 -8
- package/lib/core/transaction-engine/sweep.d.ts +0 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +583 -422
- package/lib/index.umd.js +1232 -421
- package/lib/index2.js +641 -0
- package/lib/plugins/pouchdb.d.ts +16 -1
- package/package.json +3 -2
- package/lib/core/policy-engine/index.d.ts +0 -132
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
[](https://www.npmjs.com/package/@docstack/client)
|
|
2
|
-
[](https://onyx.ac/products/docstack/docs)
|
|
3
3
|
[](https://github.com/onyx-og/docstack/blob/main/LICENSE.md)
|
|
4
4
|
[](https://www.paypal.com/donate/?hosted_button_id=4QSQ8L9AK2C74)
|
|
5
5
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
**One does not simply stack documents.**
|
|
9
9
|
|
|
10
|
-
An **offline-first embedded database for the browser**, built on PouchDB and IndexedDB. It brings the things you would otherwise build yourself — **schema validation, a SQL query engine, triggers, background jobs,
|
|
10
|
+
An **offline-first embedded database for the browser**, built on PouchDB and IndexedDB. It brings the things you would otherwise build yourself — **schema validation, a SQL query engine, triggers, background jobs, cryptographic access scopes, field-level encryption, versioned migrations and named write transactions** — into the client, where your application actually runs. When a connection exists, everything replicates to any PouchDB- or CouchDB-compatible remote, including the user's own Google Drive.
|
|
11
11
|
|
|
12
12
|
No server required. No network round trip on the read path. TypeScript throughout.
|
|
13
13
|
|
|
@@ -24,7 +24,7 @@ No server required. No network round trip on the read path. TypeScript throughou
|
|
|
24
24
|
### What it means for you building it
|
|
25
25
|
|
|
26
26
|
* **Skip the backend for a whole class of app.** Validation, access control, migrations and background work usually justify a server. Here they are engine features, so a genuinely useful application can ship with no backend to run, secure, scale or pay for.
|
|
27
|
-
* **Logic as data.** Triggers, jobs and
|
|
27
|
+
* **Logic as data.** Triggers, jobs, migrations and access scopes are documents. Change a validation rule or a business process by writing a document — no redeploy, and the change replicates to every device like any other data.
|
|
28
28
|
* **Migrations you can trust.** Schema changes are declarative patch documents with a semver ledger: applied exactly once, all-or-nothing, and gated at sync so a device with an older model cannot pull documents its schema can't describe.
|
|
29
29
|
* **Encryption you don't have to hand-roll.** Mark an attribute `encrypted` and it is ciphertext on disk and on the remote, transparently decrypted on read for the session that holds the key.
|
|
30
30
|
* **SQL instead of map/reduce.** Joins, aggregation, subqueries and pagination against local documents, with index pushdown where the planner can prove it is safe.
|
|
@@ -35,7 +35,7 @@ No server required. No network round trip on the read path. TypeScript throughou
|
|
|
35
35
|
npm install @docstack/client pouchdb-browser pouchdb-find
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
`pouchdb-browser` and `pouchdb-find` are **peer dependencies** — DocStack does not bundle the storage layer, so you control its version.
|
|
38
|
+
`pouchdb-browser` and `pouchdb-find` are **peer dependencies** — DocStack does not bundle the storage layer, so you control its version. `@docstack/abe`, the CP-ABE primitive behind access scopes, is installed as a dependency and loaded lazily, only when a stack declares scopes.
|
|
39
39
|
|
|
40
40
|
## ⚡ Quick start
|
|
41
41
|
|
|
@@ -95,7 +95,7 @@ const { rows: busy } = await stack.query(`
|
|
|
95
95
|
`);
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
-
`WHERE`, `ORDER BY … LIMIT` and range predicates push down into the index where the planner can prove the result is identical; encryption
|
|
98
|
+
`WHERE`, `ORDER BY … LIMIT` and range predicates push down into the index where the planner can prove the result is identical; encryption is consulted first, because a filter applied to ciphertext would answer the wrong question.
|
|
99
99
|
|
|
100
100
|
For results too large to materialise, stream them — the scan pages by keyset and stops early when a `LIMIT` is satisfied:
|
|
101
101
|
|
|
@@ -164,8 +164,8 @@ const handle = await stack.sync({
|
|
|
164
164
|
classes: { exclude: ['Draft'] }, // what travels
|
|
165
165
|
});
|
|
166
166
|
|
|
167
|
-
handle.addEventListener('
|
|
168
|
-
const status = stack.getSyncStatus()
|
|
167
|
+
handle.addEventListener('status', (event) => {
|
|
168
|
+
const status = event.detail; // also `stack.getSyncStatus()`, or `sync-status` on the stack
|
|
169
169
|
// `lastConvergedAt` is the honest "last synced": a cycle finished with nothing
|
|
170
170
|
// left to send. `lastActiveAt` only says documents moved.
|
|
171
171
|
render(status.state, status.lastConvergedAt);
|
|
@@ -210,7 +210,7 @@ try {
|
|
|
210
210
|
}
|
|
211
211
|
```
|
|
212
212
|
|
|
213
|
-
A write that fails validation
|
|
213
|
+
A write that fails validation or the locked-stack check stages nothing, and a batch with one bad document unwinds entirely. Commit re-runs that sweep against the current world and refuses with `TransactionConflictError` if a document changed underneath — persisting nothing and leaving the transaction open to retry.
|
|
214
214
|
|
|
215
215
|
**Atomicity is reported, not assumed.** Every commit report carries the storage adapter's honest answer in `adapter.atomicBatch`: adapters that commit a batch as one storage transaction report `true`; on IndexedDB, results are per-document, and a revision pre-flight shrinks — but does not eliminate — the window. A partial commit leaves `status: "partial"` with only the failed entries retained, so a raced document conflicts on retry instead of being silently overwritten.
|
|
216
216
|
|
|
@@ -236,6 +236,16 @@ console.log(post.slug); // 'hello-world'
|
|
|
236
236
|
`JobEngine` executes a job when asked. `JobScheduler` decides when to ask, under the constraints a client actually imposes — an app that is closed most of the time, timers that freeze, and several devices holding replicas of the same job.
|
|
237
237
|
|
|
238
238
|
```typescript
|
|
239
|
+
const content = `
|
|
240
|
+
async function execute(stack, params) {
|
|
241
|
+
const { rows } = await stack.query("SELECT _id FROM Task WHERE isComplete = true");
|
|
242
|
+
return { metadata: { archivedCount: rows.length } };
|
|
243
|
+
}
|
|
244
|
+
`;
|
|
245
|
+
// `hash` is mandatory: the SHA-256 of `content`, verified before every run.
|
|
246
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(content));
|
|
247
|
+
const hash = Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join('');
|
|
248
|
+
|
|
239
249
|
await stack.db.bulkDocs([{
|
|
240
250
|
_id: 'Job-ArchiveOldTasks',
|
|
241
251
|
'~class': '~Job',
|
|
@@ -243,12 +253,8 @@ await stack.db.bulkDocs([{
|
|
|
243
253
|
type: 'user',
|
|
244
254
|
workerPlatform: 'client',
|
|
245
255
|
isEnabled: true,
|
|
246
|
-
content
|
|
247
|
-
|
|
248
|
-
const { rows } = await stack.query("SELECT _id FROM Task WHERE isComplete = true");
|
|
249
|
-
return { metadata: { archivedCount: rows.length } };
|
|
250
|
-
}
|
|
251
|
-
`,
|
|
256
|
+
content,
|
|
257
|
+
hash,
|
|
252
258
|
}]);
|
|
253
259
|
|
|
254
260
|
// Run it now
|
|
@@ -264,29 +270,36 @@ stack.jobScheduler.start({
|
|
|
264
270
|
|
|
265
271
|
There is deliberately no "run everything": job content replicates and is executable, so unattended execution is an allow-list. `pinnedHashes` lets the application pin the code it expects a job to have.
|
|
266
272
|
|
|
267
|
-
### 7. Access
|
|
273
|
+
### 7. Access scopes
|
|
268
274
|
|
|
269
|
-
|
|
275
|
+
Access is a property of the ciphertext, not a rule that runs. Content belongs to a **scope** whose content key is sealed under an **attribute policy** (CP-ABE, the AC17 scheme, through [`@docstack/abe`](https://github.com/onyx-og/docstack/blob/main/packages/abe/README.md)). A device whose attribute key satisfies the policy opens the scope; one whose key does not holds the same ciphertext and reads `null`. There is no client-side check to bypass, so the guarantee holds against the device owner too.
|
|
270
276
|
|
|
271
277
|
```typescript
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
278
|
+
// Authority side — your server, an admin ceremony. Master keys never reach a device.
|
|
279
|
+
import { setup, keygen } from '@docstack/abe';
|
|
280
|
+
|
|
281
|
+
const { pk, msk } = await setup();
|
|
282
|
+
const hrScope = await ClientStack.buildAccessScope({
|
|
283
|
+
scopeId: 'hr',
|
|
284
|
+
policyString: '"role:hr" or "clearance:exec"',
|
|
285
|
+
pk,
|
|
286
|
+
});
|
|
287
|
+
const aliceKey = await keygen(msk, ['role:hr']);
|
|
288
|
+
// Ship `hrScope` in an application patch; hand `aliceKey` to Alice's devices.
|
|
289
|
+
|
|
290
|
+
// Device side.
|
|
291
|
+
const stack = await ClientStack.create('my-app', {
|
|
292
|
+
documentKey,
|
|
293
|
+
accessKeys: { attributeKey: aliceKey }, // or later: await stack.unlockScopes(aliceKey)
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
await salaryClass.add({ who: 'alice', amount: '100000', '~scope': 'hr' }); // seals under hr's key
|
|
297
|
+
stack.isScopeLocked('hr'); // false for Alice; true for a device whose key does not satisfy the policy
|
|
287
298
|
```
|
|
288
299
|
|
|
289
|
-
|
|
300
|
+
A document joins a scope with the reserved `~scope` field, or inherits its class's `defaultScope`. The scope decides *under which key* the class's `encrypted: true` attributes seal; the schema still decides *which* attributes. Writing into a scope the session cannot open throws `StackLockedError` with `scopeId` set, and a write whose label disagrees with its payload's key is refused with `StackScopeMismatchError` rather than re-sealed. Revoking a member is publishing a new scope version with a policy the departed key no longer satisfies.
|
|
301
|
+
|
|
302
|
+
Conditional access is write-time labeling ("published means public" is the write choosing the scope), and behavioural rules stay application code. The formula language, the guarantees and the limits, stated plainly, are in the [access control](https://onyx.ac/products/docstack/docs/concepts/access-control/) section of the documentation.
|
|
290
303
|
|
|
291
304
|
### 8. Field-level encryption
|
|
292
305
|
|
|
@@ -294,7 +307,7 @@ Because policies are documents scoped by group and user, one database can serve
|
|
|
294
307
|
await Attribute.create(userClass, 'socialSecurityNumber', 'string', 'SSN', { encrypted: true });
|
|
295
308
|
```
|
|
296
309
|
|
|
297
|
-
The value is
|
|
310
|
+
The value is AES-GCM ciphertext before it reaches storage, under the stack's document key or, for a document labeled with a scope, that scope's key. It is ciphertext on disk **and on every remote it replicates to** — decrypted only on the way out, for a session holding the key.
|
|
298
311
|
|
|
299
312
|
DocStack never invents that key: one generated per session could not outlive it, and a second device would generate a different one. Supply it at open time, or open **locked** and unlock later:
|
|
300
313
|
|
|
@@ -373,7 +386,7 @@ Against raw PouchDB — the honest baseline, since DocStack is built on it:
|
|
|
373
386
|
| Querying | Mango selectors, hand-written map/reduce | ✅ SQL — joins, aggregation, subqueries, pushdown |
|
|
374
387
|
| Business logic on write | application code | ✅ triggers, stored as data |
|
|
375
388
|
| Background work | application code | ✅ job engine + unattended scheduler |
|
|
376
|
-
| Access control | none | ✅
|
|
389
|
+
| Access control | none | ✅ cryptographic scopes: attribute policies enforced by decryption |
|
|
377
390
|
| Field-level encryption | build it | ✅ transparent, opaque to the remote |
|
|
378
391
|
| Schema migrations | build it | ✅ versioned patches with a ledger and a sync gate |
|
|
379
392
|
| Multi-document atomicity | none | ✅ staged transactions, with reported guarantees |
|
|
@@ -382,7 +395,7 @@ Against raw PouchDB — the honest baseline, since DocStack is built on it:
|
|
|
382
395
|
|
|
383
396
|
What distinguishes DocStack is a narrower bet than "a better local database":
|
|
384
397
|
|
|
385
|
-
* **Logic as data.** Triggers, jobs and
|
|
398
|
+
* **Logic as data.** Triggers, jobs, migrations and scopes are documents that replicate and can change at runtime, rather than code compiled into a release. Behaviour ships like data.
|
|
386
399
|
* **Encryption the remote cannot read.** Field-level encryption is applied before storage and before replication, so the sync target is a place to keep bytes, not a party you trust.
|
|
387
400
|
* **Bring your own remote.** Replication targets any PouchDB-compatible database — including a folder in the end user's own Drive, which makes "we don't hold your data" an architecture rather than a promise.
|
|
388
401
|
|
|
@@ -396,12 +409,12 @@ Pick accordingly: these are different bets, not rankings.
|
|
|
396
409
|
| **Schema Engine** | Zod-backed validation, class hydration, schema propagation |
|
|
397
410
|
| **Query Engine** | SQL parser, planner and executor |
|
|
398
411
|
| **Job Engine** | Background jobs, runs, and the unattended scheduler |
|
|
399
|
-
| **Crypto Engine** |
|
|
400
|
-
| **
|
|
412
|
+
| **Crypto Engine** | AES-GCM field encryption under a keyring: the document key, retired keys and admitted scope keys |
|
|
413
|
+
| **Access scopes** | CP-ABE-sealed content keys (`@docstack/abe`), attribute-key admission, per-scope locks |
|
|
401
414
|
| **Transaction Engine** | Staged writes, overlay reads, one-batch commit |
|
|
402
415
|
| **Sync Layer** | Lifecycle, replication filters, convergence state, schema gate |
|
|
403
416
|
|
|
404
|
-
Every one of these is pinned by the Playwright suite in [`src-test/`](https://github.com/onyx-og/docstack/tree/main/packages/client/src-test) — transactions and their overlay, crypto-aware queries,
|
|
417
|
+
Every one of these is pinned by the Playwright suite in [`src-test/`](https://github.com/onyx-og/docstack/tree/main/packages/client/src-test) — transactions and their overlay, crypto-aware queries, access scopes, subqueries, replication filters, late-joining stacks, patch chains.
|
|
405
418
|
|
|
406
419
|
## 💾 Storage and sync transports
|
|
407
420
|
|
|
@@ -428,7 +441,7 @@ await docstack.sync({
|
|
|
428
441
|
|
|
429
442
|
## 📖 Documentation
|
|
430
443
|
|
|
431
|
-
* [Full documentation](https://onyx
|
|
444
|
+
* [Full documentation](https://onyx.ac/products/docstack/docs) — architecture, guides, API reference
|
|
432
445
|
* [Architecture decisions](https://github.com/onyx-og/docstack/tree/main/specs/adr) — why the engine is shaped this way
|
|
433
446
|
* [Changelog](https://github.com/onyx-og/docstack/blob/main/packages/client/CHANGELOG.md)
|
|
434
447
|
* [Contributing](https://github.com/onyx-og/docstack/blob/main/CONTRIBUTING.md)
|
|
@@ -42,6 +42,18 @@ export declare class CryptoEngine {
|
|
|
42
42
|
* interrupted, and resumed.
|
|
43
43
|
*/
|
|
44
44
|
private retiredKeys;
|
|
45
|
+
/**
|
|
46
|
+
* Scope CEKs admitted by {@link admitScopeKey}, by key id (ADR-0045).
|
|
47
|
+
*
|
|
48
|
+
* The keyring the single document key generalizes into: reads dispatch by a
|
|
49
|
+
* payload's `kid` across the legacy key, retired keys, and these; writes
|
|
50
|
+
* into a scope-labeled document select through {@link scopeWriteKeys}. A
|
|
51
|
+
* scope whose CEK is absent here is simply LOCKED - its payloads stay
|
|
52
|
+
* sealed, which is the access decision.
|
|
53
|
+
*/
|
|
54
|
+
private scopeKeys;
|
|
55
|
+
/** The read-write entry per scope id - the CEK that seals new writes. */
|
|
56
|
+
private scopeWriteKeys;
|
|
45
57
|
private readonly logger;
|
|
46
58
|
/** Reference to the parent stack. */
|
|
47
59
|
private readonly stack;
|
|
@@ -106,6 +118,37 @@ export declare class CryptoEngine {
|
|
|
106
118
|
* @returns Key identifiers; empty when no key is held.
|
|
107
119
|
*/
|
|
108
120
|
getReadableKeyIds(): string[];
|
|
121
|
+
/**
|
|
122
|
+
* Admits a scope's CEK into the keyring after its canary verified
|
|
123
|
+
* (spec 02 §2.1 - admission is the caller's `verifyScopeCek` first, this
|
|
124
|
+
* second). The winning version of a scope enters read-write and becomes
|
|
125
|
+
* the key new writes into the scope seal under; older versions enter
|
|
126
|
+
* read-only, the retired-keys discipline applied per scope.
|
|
127
|
+
*
|
|
128
|
+
* @returns The admitted key's id.
|
|
129
|
+
*/
|
|
130
|
+
admitScopeKey(scopeId: string, cekHex: string, version: number, mode: "read-write" | "read-only"): Promise<string>;
|
|
131
|
+
/**
|
|
132
|
+
* Tests a candidate CEK against a scope's canary WITHOUT admitting it -
|
|
133
|
+
* the ADR-0018 admission discipline per scope: a corrupted or
|
|
134
|
+
* rotated-away ciphertext is an error at unlock, not garbage later. The
|
|
135
|
+
* marker's AAD binds it to the scope and key it was minted for.
|
|
136
|
+
*/
|
|
137
|
+
verifyScopeCek(scopeId: string, cekHex: string, marker: unknown): Promise<boolean>;
|
|
138
|
+
/** Whether new writes into this scope can seal - a read-write CEK is held. */
|
|
139
|
+
isScopeWritable(scopeId: string): boolean;
|
|
140
|
+
/** Scope ids holding a read-write CEK. */
|
|
141
|
+
unlockedScopeIds(): string[];
|
|
142
|
+
/** The key id new writes into a scope seal under, when the scope is open. */
|
|
143
|
+
getScopeWriteKeyId(scopeId: string): string | undefined;
|
|
144
|
+
/** The scope a held key id belongs to, if it is a scope key. */
|
|
145
|
+
scopeOfKid(kid: string): string | undefined;
|
|
146
|
+
/**
|
|
147
|
+
* Drops every admitted scope CEK - the session's material is gone, the
|
|
148
|
+
* scopes are locked again. Mirrors what clearing the document key does for
|
|
149
|
+
* the legacy path.
|
|
150
|
+
*/
|
|
151
|
+
dropScopeKeys(): void;
|
|
109
152
|
/**
|
|
110
153
|
* Generates a cryptographically secure random string.
|
|
111
154
|
* Useful for generating salts or nonces.
|
|
@@ -160,15 +203,23 @@ export declare class CryptoEngine {
|
|
|
160
203
|
unwrapAndStoreDocumentKey(wrappedDocumentKey?: string | null, derivedKey?: string | null): Promise<string>;
|
|
161
204
|
private getCryptoKey;
|
|
162
205
|
/**
|
|
163
|
-
* Chooses the
|
|
206
|
+
* Chooses the keyring entry that can open a payload.
|
|
164
207
|
*
|
|
165
208
|
* A payload names its key, so an old field found mid-re-key is decrypted with the key
|
|
166
209
|
* it was actually written under instead of failing against the current one. Payloads
|
|
167
|
-
* from before identifiers existed name nothing, and are tried against the
|
|
168
|
-
*
|
|
210
|
+
* from before identifiers existed name nothing, and are tried against the LEGACY
|
|
211
|
+
* document key only (spec 02 §3): under multiple keys, falling back to "whatever is
|
|
212
|
+
* current" would silently mis-route - a scope key never answers for an unnamed
|
|
213
|
+
* payload.
|
|
169
214
|
*/
|
|
170
|
-
private
|
|
215
|
+
private resolveEntryFor;
|
|
171
216
|
private encryptValue;
|
|
217
|
+
/**
|
|
218
|
+
* @param label - The document's `~scope` at read time. A scope-sealed
|
|
219
|
+
* payload authenticates against it (AAD, spec 02 §2.3 rule 3): a tampered
|
|
220
|
+
* or stripped label fails the GCM authentication and the payload stays
|
|
221
|
+
* sealed - the mismatch is detected, never silently honored.
|
|
222
|
+
*/
|
|
172
223
|
private decryptValue;
|
|
173
224
|
/**
|
|
174
225
|
* Identifies which document keys contain encrypted data.
|
|
@@ -187,7 +238,14 @@ export declare class CryptoEngine {
|
|
|
187
238
|
* @param document - The document to encrypt
|
|
188
239
|
* @param classObj - The class defining which fields to encrypt
|
|
189
240
|
*/
|
|
190
|
-
|
|
241
|
+
/**
|
|
242
|
+
* @param scopeId - The document's resolved scope label. When present, the
|
|
243
|
+
* scope's read-write CEK seals every attribute (stamped with its `kid`,
|
|
244
|
+
* bound to the label via AAD); the caller has already refused the write if
|
|
245
|
+
* the scope is not open. Absent, the legacy document key path applies
|
|
246
|
+
* unchanged.
|
|
247
|
+
*/
|
|
248
|
+
encryptDocument(document: Document, classObj: Class, scopeId?: string): Promise<void>;
|
|
191
249
|
/**
|
|
192
250
|
* Decrypts encrypted fields in a document after retrieval.
|
|
193
251
|
* Modifies the document in place.
|
|
@@ -44,8 +44,16 @@ export declare const isEncryptedPayload: (value: unknown) => value is EncryptedP
|
|
|
44
44
|
* ```
|
|
45
45
|
*/
|
|
46
46
|
export declare const deriveKeyId: (hexKey: string) => Promise<string>;
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
/**
|
|
48
|
+
* The additional-authenticated-data string binding a scope-sealed payload to
|
|
49
|
+
* its label (spec 02 §2.3 rule 3): decryption derives it from the DOCUMENT's
|
|
50
|
+
* `~scope` at read time, so a tampered or stripped label does not merely look
|
|
51
|
+
* inconsistent - the GCM authentication fails and the payload stays sealed.
|
|
52
|
+
* Legacy-key payloads pass no AAD, keeping pre-scope ciphertext readable.
|
|
53
|
+
*/
|
|
54
|
+
export declare const scopeAad: (scopeId: string, kid: string) => string;
|
|
55
|
+
export declare const encryptWithAesGcm: (plaintext: string, key: CryptoKey, kid?: string, aad?: string) => Promise<EncryptedPayload>;
|
|
56
|
+
export declare const decryptWithAesGcm: (payload: EncryptedPayload, key: CryptoKey, aad?: string) => Promise<string>;
|
|
49
57
|
export declare const wrapDocumentKey: (documentKey: string, derivedKeyHex: string) => Promise<string>;
|
|
50
58
|
export declare const unwrapDocumentKey: (wrappedDocumentKey: string, cryptoKey: CryptoKey, derivedKeyHex: string) => Promise<string>;
|
|
51
59
|
/**
|
package/lib/core/index.d.ts
CHANGED
|
@@ -312,7 +312,7 @@ export type { ContentExport, ContentExportOptions, ContentImportOptions, Content
|
|
|
312
312
|
export { SYSTEM_SEEDED_DOC_IDS } from "./datamodel/index.js";
|
|
313
313
|
export { collectQueryClasses } from "./query-engine/index.js";
|
|
314
314
|
export { StackWriteGuardError } from "./guarded-db.js";
|
|
315
|
-
export { StackLockedError } from "../plugins/pouchdb.js";
|
|
315
|
+
export { StackLockedError, StackScopeMismatchError } from "../plugins/pouchdb.js";
|
|
316
316
|
export { TransactionEngine, TransactionHandle, TransactionDb, TransactionsDisabledError, TransactionStateError, TransactionValidationError, TransactionConflictError, TransactionUnsupportedDocError, } from "./transaction-engine/index.js";
|
|
317
317
|
export type { TransactionCommitReport, TransactionStatus } from "./transaction-engine/index.js";
|
|
318
318
|
/**
|
package/lib/core/stack.d.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import Class from "./class.js";
|
|
2
2
|
import Domain from "./domain.js";
|
|
3
3
|
import { Stack, StackOptions, AuthSessionProof, ClientCredentials, CachedClass, ClassModelPropagationStart, ClassModelPropagationComplete, CachedDomain, DomainModel, ChangesSubscription } from "@docstack/shared";
|
|
4
|
-
import { SystemDoc, Patch, ClassModel, Document, RelationDocument } from "@docstack/shared";
|
|
4
|
+
import { SystemDoc, Patch, ClassModel, Document, RelationDocument, AccessScopeModel } from "@docstack/shared";
|
|
5
5
|
import { StackSyncHandle } from "./sync/index.js";
|
|
6
6
|
import type { StackSyncOptions, SyncStatus } from "./sync/index.js";
|
|
7
7
|
import type { SelectAST, UnionAST } from "./query-engine/index.js";
|
|
8
8
|
import { JobEngine } from "./job-engine/index.js";
|
|
9
9
|
import { JobScheduler } from "./job-engine/scheduler.js";
|
|
10
|
-
import { PolicyEngine } from "./policy-engine/index.js";
|
|
11
10
|
import { CryptoEngine } from "./crypto-engine/index.js";
|
|
12
11
|
import { TransactionEngine, TransactionHandle, TransactionStage, TransactionCommitReport } from "./transaction-engine/index.js";
|
|
13
12
|
import type { ContentExport, ContentExportOptions, ContentImportOptions, ContentImportReport } from "./content-transfer.js";
|
|
@@ -163,7 +162,15 @@ declare class ClientStack extends Stack {
|
|
|
163
162
|
* Engine for enforcing read/write access control policies.
|
|
164
163
|
* Policies are evaluated based on user session and document content.
|
|
165
164
|
*/
|
|
166
|
-
|
|
165
|
+
/**
|
|
166
|
+
* The access-scope registry: every `~AccessScope` document, grouped by
|
|
167
|
+
* `scopeId` (ADR-0045). Built from the database, independent of which CEKs
|
|
168
|
+
* the keyring actually holds - the registry knows a scope's key ids even
|
|
169
|
+
* when the session cannot open them, which is what the label↔kid mismatch
|
|
170
|
+
* guard needs (spec 02 §2.3 rule 2).
|
|
171
|
+
*/
|
|
172
|
+
private accessScopeRegistry;
|
|
173
|
+
private accessScopeRegistryDirty;
|
|
167
174
|
/**
|
|
168
175
|
* Engine for field-level encryption and decryption.
|
|
169
176
|
* Handles key derivation (PBKDF2) and AES-GCM encryption.
|
|
@@ -325,6 +332,56 @@ declare class ClientStack extends Stack {
|
|
|
325
332
|
* ```
|
|
326
333
|
*/
|
|
327
334
|
unlock(documentKey: string): Promise<this>;
|
|
335
|
+
/**
|
|
336
|
+
* The access-scope registry, loaded from `~AccessScope` documents and
|
|
337
|
+
* refreshed whenever one is written (ADR-0045). Raw read: scope docs are
|
|
338
|
+
* the machinery that DECIDES readability - they cannot sit behind it.
|
|
339
|
+
*/
|
|
340
|
+
private getAccessScopeRegistry;
|
|
341
|
+
/**
|
|
342
|
+
* The scope a document's write seals under: its own `~scope` label, else
|
|
343
|
+
* its class's `defaultScope` (spec 02 §2.2 - the document's value wins).
|
|
344
|
+
*/
|
|
345
|
+
resolveScopeLabel(doc: unknown, classModel?: {
|
|
346
|
+
defaultScope?: string;
|
|
347
|
+
} | null): string | undefined;
|
|
348
|
+
/** Every key id belonging to a scope, across rotation versions - or null for an unknown scope. */
|
|
349
|
+
getAccessScopeKids(scopeId: string): Promise<Set<string> | null>;
|
|
350
|
+
/** Whether a declared scope's CEK is absent from the keyring - its content is sealed. */
|
|
351
|
+
isScopeLocked(scopeId: string): boolean;
|
|
352
|
+
/** Declared scopes whose CEK the keyring lacks. Loaded scopes only - call after open. */
|
|
353
|
+
lockedScopeIds(): Promise<string[]>;
|
|
354
|
+
/**
|
|
355
|
+
* Attempts every declared access scope with the session's attribute key
|
|
356
|
+
* (ADR-0045): the ABE decryption either yields a scope's CEK - verified
|
|
357
|
+
* against the scope's canary, then admitted to the keyring - or fails,
|
|
358
|
+
* and the scope stays locked. There is no gate to ask; this IS the access
|
|
359
|
+
* decision. Idempotent: a later call with better material unlocks more.
|
|
360
|
+
* Deferred patches that were waiting on a scope replay after.
|
|
361
|
+
*/
|
|
362
|
+
unlockScopes(attributeKey: string): Promise<{
|
|
363
|
+
unlocked: string[];
|
|
364
|
+
locked: string[];
|
|
365
|
+
}>;
|
|
366
|
+
/**
|
|
367
|
+
* AUTHORITY-side helper: assembles a complete `~AccessScope` document from
|
|
368
|
+
* a fresh (or supplied) CEK - ABE-sealing it under the policy, stamping the
|
|
369
|
+
* kid, minting the per-scope canary. Runs wherever the application controls
|
|
370
|
+
* (its server, an admin ceremony, tests); it needs the authority PUBLIC key
|
|
371
|
+
* only, never the master secret. The document is returned, not written -
|
|
372
|
+
* publishing it (and distributing attribute keys) is the consumer's act.
|
|
373
|
+
*/
|
|
374
|
+
static buildAccessScope(input: {
|
|
375
|
+
scopeId: string;
|
|
376
|
+
policyString: string;
|
|
377
|
+
/** The authority public key (`@docstack/abe` setup().pk). */
|
|
378
|
+
pk: string;
|
|
379
|
+
/** 32-byte CEK as hex; minted when absent. */
|
|
380
|
+
cekHex?: string;
|
|
381
|
+
version?: number;
|
|
382
|
+
}): Promise<AccessScopeModel & {
|
|
383
|
+
_id: string;
|
|
384
|
+
}>;
|
|
328
385
|
/**
|
|
329
386
|
* Encrypts bootstrap documents that were seeded before this stack had a key.
|
|
330
387
|
*
|
|
@@ -487,7 +544,6 @@ declare class ClientStack extends Stack {
|
|
|
487
544
|
* ```
|
|
488
545
|
*/
|
|
489
546
|
importContent: (payload: ContentExport, options?: ContentImportOptions) => Promise<ContentImportReport>;
|
|
490
|
-
private ensureDefaultPolicyForClass;
|
|
491
547
|
/**
|
|
492
548
|
* Creates and initializes a new ClientStack instance.
|
|
493
549
|
* This is the primary way to instantiate a stack - the constructor is private.
|
|
@@ -600,6 +656,15 @@ declare class ClientStack extends Stack {
|
|
|
600
656
|
* have when patch N had committed.
|
|
601
657
|
* @returns `true` if any document in it belongs to a class with encrypted attributes.
|
|
602
658
|
*/
|
|
659
|
+
/**
|
|
660
|
+
* The deferral barrier, generalized per scope (spec 02 §5): a patch is
|
|
661
|
+
* blocked when the legacy half applies (stack locked and the patch needs
|
|
662
|
+
* the document key) OR any document it carries writes into a declared
|
|
663
|
+
* scope whose CEK the keyring lacks - sealing under the wrong key is never
|
|
664
|
+
* a fallback, so the patch waits for `unlockScopes` exactly as key-needing
|
|
665
|
+
* patches wait for `unlock`.
|
|
666
|
+
*/
|
|
667
|
+
private patchBlockedByLock;
|
|
603
668
|
private patchNeedsDocumentKey;
|
|
604
669
|
/**
|
|
605
670
|
* Authenticates a user and establishes a session.
|
|
@@ -805,10 +870,13 @@ declare class ClientStack extends Stack {
|
|
|
805
870
|
/**
|
|
806
871
|
* Whether a database-level `limit` returns the same rows as limiting in memory.
|
|
807
872
|
*
|
|
808
|
-
* `findDocuments`
|
|
809
|
-
*
|
|
810
|
-
*
|
|
811
|
-
*
|
|
873
|
+
* `findDocuments` drops a document whose visible fields are all sealed - a
|
|
874
|
+
* locked legacy key, or a scope the keyring cannot open. A limit applied
|
|
875
|
+
* before that filter would under-fill. So pushdown is allowed exactly when
|
|
876
|
+
* no row of this class can drop: the class has no encrypted attributes, or
|
|
877
|
+
* every key that might seal one is held (the legacy key, and every declared
|
|
878
|
+
* scope - a document may carry any label). The query engine asks this
|
|
879
|
+
* before pushing a SQL LIMIT into the fetch.
|
|
812
880
|
*
|
|
813
881
|
* @param className - The class being queried.
|
|
814
882
|
*/
|
|
@@ -22,5 +22,4 @@ export declare const classFromStage: (stack: ClientStack, stage: TransactionStag
|
|
|
22
22
|
*/
|
|
23
23
|
export declare const sweepEntry: (stack: ClientStack, stage: TransactionStage, entry: StagedEntry, options?: {
|
|
24
24
|
allowClassModels?: boolean;
|
|
25
|
-
skipPolicy?: boolean;
|
|
26
25
|
}) => Promise<void>;
|
package/lib/index.d.ts
CHANGED
|
@@ -18,7 +18,7 @@ export type { SchedulerOptions, SchedulerHost, JobScheduleState, TickReport, Ski
|
|
|
18
18
|
* application hands over, so DocStack never learns about Google Drive, Firestore or
|
|
19
19
|
* anything else, and no consumer pays for a transport it does not use.
|
|
20
20
|
*/
|
|
21
|
-
export { StackSyncHandle, DocStackSyncHandle, SyncSchemaMismatchError, SYNC_META_DOC_ID, readRemoteSchemaVersion, readRemoteConsumerSchemaVersion, publishSchemaVersion, createReplicationFilter, isInternalDoc, resolveInternalClasses, createClassFilter, hasClassRules, DATA_MODEL_CLASSES, withFilterIdentity, describeFilter, INTERNAL_DOC_IDS, INTERNAL_DOC_ID_PREFIXES, INTERNAL_DOC_CLASSES, OPTIONAL_INTERNAL_DOC_CLASSES, StackWriteGuardError, StackLockedError, deriveKeyId, isEncryptedPayload, deriveTenantScope, classTenants, } from "./core/index.js";
|
|
21
|
+
export { StackSyncHandle, DocStackSyncHandle, SyncSchemaMismatchError, SYNC_META_DOC_ID, readRemoteSchemaVersion, readRemoteConsumerSchemaVersion, publishSchemaVersion, createReplicationFilter, isInternalDoc, resolveInternalClasses, createClassFilter, hasClassRules, DATA_MODEL_CLASSES, withFilterIdentity, describeFilter, INTERNAL_DOC_IDS, INTERNAL_DOC_ID_PREFIXES, INTERNAL_DOC_CLASSES, OPTIONAL_INTERNAL_DOC_CLASSES, StackWriteGuardError, StackLockedError, StackScopeMismatchError, deriveKeyId, isEncryptedPayload, deriveTenantScope, classTenants, } from "./core/index.js";
|
|
22
22
|
export { SYSTEM_SEEDED_DOC_IDS, collectQueryClasses } from "./core/index.js";
|
|
23
23
|
export type { EncryptedPayload, ClassBuildOptions } from "./core/index.js";
|
|
24
24
|
/**
|