@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 CHANGED
@@ -1,5 +1,5 @@
1
1
  [![npm](https://img.shields.io/npm/v/@docstack/client)](https://www.npmjs.com/package/@docstack/client)
2
- [![Docs](https://img.shields.io/badge/docs-onyx--og.github.io-blue)](https://onyx-og.github.io/docstack/)
2
+ [![Docs](https://img.shields.io/badge/docs-onyx.ac-blue)](https://onyx.ac/products/docstack/docs)
3
3
  [![License](https://img.shields.io/badge/license-CC--BY--SA--4.0-lightgrey)](https://github.com/onyx-og/docstack/blob/main/LICENSE.md)
4
4
  [![Donate](https://img.shields.io/badge/Donate-PayPal-blue.svg)](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, role-based access policies, 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.
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 policies 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.
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 and policies are consulted first, because a filter applied to ciphertext would answer the wrong question.
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('sync-status', () => {
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, policy 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.
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
- async function execute(stack, params) {
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 policies
273
+ ### 7. Access scopes
268
274
 
269
- Rule-based read and write control, evaluated per session against the document in question.
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
- await stack.db.bulkDocs([
273
- {
274
- _id: 'Policy-Article-EditorsWrite',
275
- '~class': '~Policy',
276
- targetClass: ['Class-Article'],
277
- groupId: 'Group-Editors',
278
- rule: `return session && session.sessionStatus === 'active';`,
279
- },
280
- {
281
- _id: 'Policy-Article-PublicRead',
282
- '~class': '~Policy',
283
- targetClass: ['Class-Article'],
284
- rule: `if (document.status === 'published') return true;`,
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
- Because policies are documents scoped by group and user, one database can serve multiple tenants without per-tenant application code and the query engine consults them before deciding whether a filter can be pushed down.
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 encrypted with a document key (PBKDF2-derived, AES-GCM) before it reaches storage. It is ciphertext on disk **and on every remote it replicates to** — decrypted only on the way out, for a session holding the key.
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 | ✅ policy engine, per class and session |
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 policies are documents that replicate and can change at runtime, rather than code compiled into a release. Behaviour ships like data.
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** | PBKDF2 key derivation and AES-GCM field encryption |
400
- | **Policy Engine** | Read/write rules per class, group and session |
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, policy enforcement, subqueries, replication filters, late-joining stacks, patch chains.
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-og.github.io/docstack/) — architecture, guides, API reference
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 key that can open a payload.
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 current key
168
- * - which is what they meant when only one key could exist.
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 resolveKeyFor;
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
- encryptDocument(document: Document, classObj: Class): Promise<void>;
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
- export declare const encryptWithAesGcm: (plaintext: string, key: CryptoKey, kid?: string) => Promise<EncryptedPayload>;
48
- export declare const decryptWithAesGcm: (payload: EncryptedPayload, key: CryptoKey) => Promise<string>;
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
  /**
@@ -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
  /**
@@ -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
- policyEngine: PolicyEngine;
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` filters per document *after* the query - policy checks drop
809
- * unreadable documents, and a locked crypto engine drops documents whose visible
810
- * fields are all encrypted. A limit applied before either would under-fill. The
811
- * query engine asks this before pushing a SQL LIMIT into the fetch.
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
  /**