@kubun/engine 0.10.3 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { type VerifyTokenHook } from '@enkaku/capability';
1
+ import { type VerifyTokenHook } from '@kokuin/capability';
2
2
  import type { DocumentNode } from '@kubun/protocol';
3
3
  import type { AccessLevel, StoredAccessRule } from '@kubun/store-graph';
4
4
  /**
@@ -1 +1,250 @@
1
- import{checkCapability as e}from"@enkaku/capability";function r(e){return Array.isArray(e)&&e.every(e=>"string"==typeof e)?e:null}function l(e){var l;return e&&"object"==typeof e?"only_owner"!==(l=e.level)&&"anyone"!==l&&"restricted"!==l?null:{level:e.level,allowedDIDs:r(e.allowedDIDs),allowedCircles:r(e.allowedCircles),allowedGroups:r(e.allowedGroups)}:null}export function parseDocumentAccessPermissions(e){try{if(!e||"object"!=typeof e||!("accessPermissions"in e))return null;let r=e.accessPermissions;if(!r||"object"!=typeof r)return null;let t={},o=l(r.read);o&&(t.read=o);let n=l(r.write);return n&&(t.write=n),t}catch{return null}}export function validateDIDs(e){for(let r of e)if("string"!=typeof r||!r.startsWith("did:"))throw Error(`Invalid DID format: ${String(r)}`)}export function validateID(e,r){if("string"!=typeof e||0===e.trim().length)throw Error(`Invalid ${r} ID: must be a non-empty string`)}export function validateIDs(e,r){for(let l of e)validateID(l,r)}export async function resolveAccessRule(e,r,l,t,o,n){let a=parseDocumentAccessPermissions(e.data),i=a?.[t];if(null!=i&&("write"!==t||"anyone"!==i.level))return i;let s=await o.getUserModelAccessDefault(l,r,t);return s?{level:s.level,allowedDIDs:s.allowedDIDs,allowedCircles:s.allowedCircles,allowedGroups:s.allowedGroups}:{level:n[t],allowedDIDs:null,allowedCircles:null,allowedGroups:null}}async function t(r,l,t,o,n,a,i){if(!a||0===a.length)return!1;let s=await n.getModelInterfaces(t.model),u=[`urn:kubun:document:${t.id}`,`urn:kubun:model:${t.model}`,...s.map(e=>`urn:kubun:model:${e}`),`urn:kubun:user:${t.owner}`,"*"],c=`document/${o}`;for(let t of u)try{return await e({act:c,res:t},{iss:r,sub:l,cap:a},{atTime:i,verifyToken:n.revocationChecker}),!0}catch{}for(let t of a)for(let o of u)try{return await e({act:c,res:o},{iss:r,sub:l,cap:t},{atTime:i,verifyToken:n.revocationChecker}),!0}catch{}return!1}async function o(e,r,l,o,n,a,i){if(!r.owner)throw Error("Document missing owner field");if(e===r.owner)return!0;let s=await resolveAccessRule(r,r.model,r.owner,l,o,n);if("anyone"===s.level)return!0;if(!e)return!1;if("only_owner"===s.level)return await t(e,r.owner,r,l,o,a,i);if("restricted"===s.level){let n=s.allowedDIDs||[];if(n.includes(e)||"read"===l&&null!=s.allowedCircles&&s.allowedCircles.length>0&&await o.isMemberOfAnyCircle(e,s.allowedCircles)||"read"===l&&null!=s.allowedGroups&&s.allowedGroups.length>0&&await o.isMemberOfAnyGroup(e,s.allowedGroups))return!0;for(let s of n)if(await t(e,s,r,l,o,a,i))return!0}return!1}export function createAccessChecker(e){return async(r,l)=>await o(e.viewerDID,r,l,e.db,e.defaultAccessLevel,e.delegationTokens,e.atTime)}
1
+ import { checkCapability } from '@kokuin/capability';
2
+ // ---- Helpers ----
3
+ /**
4
+ * Normalize a raw rule from persisted data into a runtime `AccessRule`.
5
+ *
6
+ * Empty arrays and missing fields both become `null`. Returns `null` if the
7
+ * input is not a well-formed rule object or carries an unknown `level`.
8
+ */ function stringArray(v) {
9
+ return Array.isArray(v) && v.every((item)=>typeof item === 'string') ? v : null;
10
+ }
11
+ function isAccessLevel(value) {
12
+ return value === 'only_owner' || value === 'anyone' || value === 'restricted';
13
+ }
14
+ function normalizeAccessRule(raw) {
15
+ if (!raw || typeof raw !== 'object') return null;
16
+ const rule = raw;
17
+ if (!isAccessLevel(rule.level)) return null;
18
+ return {
19
+ level: rule.level,
20
+ allowedDIDs: stringArray(rule.allowedDIDs),
21
+ allowedCircles: stringArray(rule.allowedCircles),
22
+ allowedGroups: stringArray(rule.allowedGroups)
23
+ };
24
+ }
25
+ /**
26
+ * Parse and validate access permissions from document data.
27
+ *
28
+ * Ensures every returned rule carries the `allowedGroups` field (defaulting
29
+ * to `null` when absent on input) and rejects rules with unknown `level`
30
+ * tokens.
31
+ */ export function parseDocumentAccessPermissions(data) {
32
+ try {
33
+ if (!data || typeof data !== 'object') return null;
34
+ if (!('accessPermissions' in data)) return null;
35
+ const perms = data.accessPermissions;
36
+ // Basic validation - check if the permission object has the expected structure
37
+ if (!perms || typeof perms !== 'object') {
38
+ return null;
39
+ }
40
+ const result = {};
41
+ const permsObj = perms;
42
+ const read = normalizeAccessRule(permsObj.read);
43
+ if (read) result.read = read;
44
+ const write = normalizeAccessRule(permsObj.write);
45
+ if (write) result.write = write;
46
+ return result;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+ /**
52
+ * Validate that DIDs have the correct format.
53
+ */ export function validateDIDs(dids) {
54
+ for (const did of dids){
55
+ if (typeof did !== 'string' || !did.startsWith('did:')) {
56
+ throw new Error(`Invalid DID format: ${String(did)}`);
57
+ }
58
+ }
59
+ }
60
+ /**
61
+ * Validate that an identifier (circle or group ID) is a non-empty,
62
+ * non-whitespace string. There is no canonical format helper for circle and
63
+ * group IDs in `@kubun/id` (they are app-supplied opaque strings — `@kubun/id`
64
+ * helpers cover content-addressed Kubun IDs only), so this check rejects only
65
+ * obvious garbage. Downstream membership checks won't match if the format is
66
+ * malformed beyond this — callers should treat malformed IDs as an empty
67
+ * scope.
68
+ */ export function validateID(id, kind) {
69
+ if (typeof id !== 'string' || id.trim().length === 0) {
70
+ throw new Error(`Invalid ${kind} ID: must be a non-empty string`);
71
+ }
72
+ }
73
+ /**
74
+ * Apply {@link validateID} to every entry of an array.
75
+ */ export function validateIDs(ids, kind) {
76
+ for (const id of ids){
77
+ validateID(id, kind);
78
+ }
79
+ }
80
+ /**
81
+ * Resolve the effective access rule for a document and permission type.
82
+ *
83
+ * Order of precedence:
84
+ * 1. Document accessPermissions override
85
+ * 2. User's model default from database
86
+ * 3. Server configuration default
87
+ *
88
+ * Document overrides with `'anyone'` on a `write` permission are rejected
89
+ * (anyone-write is not a valid configuration) and fall through to the next
90
+ * tier.
91
+ */ export async function resolveAccessRule(document, modelID, ownerDID, permissionType, db, defaultAccessLevel) {
92
+ // 1. Check document override
93
+ const docPerms = parseDocumentAccessPermissions(document.data);
94
+ const docRule = docPerms?.[permissionType];
95
+ if (docRule != null && !(permissionType === 'write' && docRule.level === 'anyone')) {
96
+ return docRule;
97
+ }
98
+ // 2. Check user's model default
99
+ const modelDefault = await db.getUserModelAccessDefault(ownerDID, modelID, permissionType);
100
+ if (modelDefault) {
101
+ return {
102
+ level: modelDefault.level,
103
+ allowedDIDs: modelDefault.allowedDIDs,
104
+ allowedCircles: modelDefault.allowedCircles,
105
+ allowedGroups: modelDefault.allowedGroups
106
+ };
107
+ }
108
+ // 3. Fall back to server default
109
+ const level = defaultAccessLevel[permissionType];
110
+ return {
111
+ level,
112
+ allowedDIDs: null,
113
+ allowedCircles: null,
114
+ allowedGroups: null
115
+ };
116
+ }
117
+ /**
118
+ * Check if viewer has access to document through delegation tokens.
119
+ */ async function checkDelegation(viewerDID, grantor, document, permissionType, db, delegationTokens, // Evaluate capability expiry at this time (epoch seconds) instead of now().
120
+ // Lets a capability that was valid when a mutation was signed remain valid
121
+ // across an offline sync delay, even if it has since expired.
122
+ atTime) {
123
+ if (!delegationTokens || delegationTokens.length === 0) {
124
+ return false;
125
+ }
126
+ // Build expected permission based on specificity. Interface URNs sit between
127
+ // the concrete model and the user/wildcard tiers so a capability granted on
128
+ // an interface model authorizes mutations on any document whose concrete
129
+ // model implements that interface. `checkCapability` returns on the first
130
+ // matching resource, so order is efficiency only — any matching token
131
+ // authorizes regardless of position.
132
+ const interfaceIDs = await db.getModelInterfaces(document.model);
133
+ const resources = [
134
+ `urn:kubun:document:${document.id}`,
135
+ `urn:kubun:model:${document.model}`,
136
+ ...interfaceIDs.map((id)=>`urn:kubun:model:${id}`),
137
+ `urn:kubun:user:${document.owner}`,
138
+ '*'
139
+ ];
140
+ const action = `document/${permissionType}` // document/read or document/write
141
+ ;
142
+ // First, try tokens as a delegation chain (for A→B→C scenarios)
143
+ for (const res of resources){
144
+ try {
145
+ await checkCapability({
146
+ act: action,
147
+ res
148
+ }, {
149
+ iss: viewerDID,
150
+ sub: grantor,
151
+ cap: delegationTokens
152
+ }, {
153
+ atTime,
154
+ verifyToken: db.revocationChecker
155
+ });
156
+ return true;
157
+ } catch {
158
+ // Token chain doesn't match this resource — continue
159
+ }
160
+ }
161
+ // If chain validation fails, try each token independently (for multiple independent grants)
162
+ for (const token of delegationTokens){
163
+ for (const res of resources){
164
+ try {
165
+ await checkCapability({
166
+ act: action,
167
+ res
168
+ }, {
169
+ iss: viewerDID,
170
+ sub: grantor,
171
+ cap: token
172
+ }, {
173
+ atTime,
174
+ verifyToken: db.revocationChecker
175
+ });
176
+ return true;
177
+ } catch {
178
+ // Token doesn't match this resource — continue
179
+ }
180
+ }
181
+ }
182
+ return false;
183
+ }
184
+ /**
185
+ * Check if viewer has access to a document for the specified permission type.
186
+ */ async function checkAccess(viewerDID, document, permissionType, db, defaultAccessLevel, delegationTokens, // Evaluate capability expiry at this time (epoch seconds) instead of now().
187
+ // Lets a capability that was valid when a mutation was signed remain valid
188
+ // across an offline sync delay, even if it has since expired.
189
+ atTime) {
190
+ // Validate document has owner
191
+ if (!document.owner) {
192
+ throw new Error('Document missing owner field');
193
+ }
194
+ // FAST PATH: Owner always has access
195
+ if (viewerDID === document.owner) {
196
+ return true;
197
+ }
198
+ // Resolve effective access rule
199
+ const rule = await resolveAccessRule(document, document.model, document.owner, permissionType, db, defaultAccessLevel);
200
+ // ANYONE: Always allow (read only — 'anyone' is never valid for write,
201
+ // so this branch only fires for read)
202
+ if (rule.level === 'anyone') {
203
+ return true;
204
+ }
205
+ // No viewer: Deny
206
+ if (!viewerDID) {
207
+ return false;
208
+ }
209
+ // ONLY_OWNER: Check delegation from owner
210
+ if (rule.level === 'only_owner') {
211
+ return await checkDelegation(viewerDID, document.owner, document, permissionType, db, delegationTokens, atTime);
212
+ }
213
+ // RESTRICTED: Check if viewer is in list, in a circle, or has delegation
214
+ if (rule.level === 'restricted') {
215
+ const allowedDIDs = rule.allowedDIDs || [];
216
+ if (allowedDIDs.includes(viewerDID)) {
217
+ return true;
218
+ }
219
+ // Check circle membership (read access only — circles don't grant write)
220
+ if (permissionType === 'read' && rule.allowedCircles != null && rule.allowedCircles.length > 0) {
221
+ if (await db.isMemberOfAnyCircle(viewerDID, rule.allowedCircles)) {
222
+ return true;
223
+ }
224
+ }
225
+ // Check group membership (read access only — groups don't grant write)
226
+ if (permissionType === 'read' && rule.allowedGroups != null && rule.allowedGroups.length > 0) {
227
+ if (await db.isMemberOfAnyGroup(viewerDID, rule.allowedGroups)) {
228
+ return true;
229
+ }
230
+ }
231
+ // Check if any allowedDID has delegated to viewer
232
+ for (const allowedDID of allowedDIDs){
233
+ if (await checkDelegation(viewerDID, allowedDID, document, permissionType, db, delegationTokens, atTime)) {
234
+ return true;
235
+ }
236
+ }
237
+ return false;
238
+ }
239
+ // Unknown access level
240
+ return false;
241
+ }
242
+ // ---- Public factory ----
243
+ /**
244
+ * Create an access checker function bound to specific viewer, delegation tokens,
245
+ * database instance, and server default access level.
246
+ */ export function createAccessChecker(params) {
247
+ return async (doc, permissionType)=>{
248
+ return await checkAccess(params.viewerDID, doc, permissionType, params.db, params.defaultAccessLevel, params.delegationTokens, params.atTime);
249
+ };
250
+ }
@@ -1 +1,34 @@
1
- export async function catalogMatchesDoc(e,l,n){if(null!=e.models&&e.models.length>0&&!e.models.includes(l.model))return!1;let c=null!=e.owners&&e.owners.length>0,r=null!=e.circles&&e.circles.length>0;return!!(!c&&!r||c&&e.owners?.includes(l.owner)||r&&null!=e.circles&&await n.isMemberOfAnyCircle(l.owner,e.circles))}
1
+ /**
2
+ * Determine whether a document matches a catalog's filter criteria.
3
+ *
4
+ * Each catalog field is AND'd, values within a field are OR'd, and the
5
+ * `circles` field expands to a union of circle members and merges with the
6
+ * explicit `owners` filter.
7
+ *
8
+ * Returns `true` iff the document would be included by this catalog.
9
+ */ export async function catalogMatchesDoc(criteria, doc, p2pStore) {
10
+ // Model filter: doc.model must be one of criteria.models (when specified).
11
+ if (criteria.models != null && criteria.models.length > 0) {
12
+ if (!criteria.models.includes(doc.model)) {
13
+ return false;
14
+ }
15
+ }
16
+ // Owner filter: union of explicit owners and circle members. When either
17
+ // `owners` or `circles` is specified, doc.owner must be in the union.
18
+ const hasExplicitOwners = criteria.owners != null && criteria.owners.length > 0;
19
+ const hasCircles = criteria.circles != null && criteria.circles.length > 0;
20
+ if (!hasExplicitOwners && !hasCircles) {
21
+ return true;
22
+ }
23
+ if (hasExplicitOwners && criteria.owners?.includes(doc.owner)) {
24
+ return true;
25
+ }
26
+ if (hasCircles && criteria.circles != null) {
27
+ // Single batched query: is the doc owner a member of ANY of the named
28
+ // circles? Replaces a per-circle `listCircleMembers` loop.
29
+ if (await p2pStore.isMemberOfAnyCircle(doc.owner, criteria.circles)) {
30
+ return true;
31
+ }
32
+ }
33
+ return false;
34
+ }
@@ -0,0 +1,6 @@
1
+ export type Cipher = {
2
+ encrypt(plaintext: string): string;
3
+ decrypt(ciphertext: string): string;
4
+ };
5
+ export declare function deriveAtRestKey(ikm: Uint8Array): Uint8Array;
6
+ export declare function createDefaultCipher(key: Uint8Array): Cipher;
package/lib/cipher.js ADDED
@@ -0,0 +1,40 @@
1
+ import { gcm } from '@noble/ciphers/aes.js';
2
+ import { randomBytes } from '@noble/ciphers/utils.js';
3
+ import { hkdf } from '@noble/hashes/hkdf.js';
4
+ import { sha256 } from '@noble/hashes/sha2.js';
5
+ import { fromB64, fromUTF, toB64, toUTF } from '@sozai/codec';
6
+ const ENVELOPE_PREFIX = 'v1:';
7
+ const IV_LENGTH = 12;
8
+ const KEY_LENGTH = 32;
9
+ // HKDF-SHA-256 domain separation for keys used to protect data at rest.
10
+ const AT_REST_SALT = fromUTF('kubun/at-rest');
11
+ const AT_REST_INFO = fromUTF('v1');
12
+ export function deriveAtRestKey(ikm) {
13
+ return hkdf(sha256, ikm, AT_REST_SALT, AT_REST_INFO, KEY_LENGTH);
14
+ }
15
+ export function createDefaultCipher(key) {
16
+ if (key.length !== KEY_LENGTH) {
17
+ throw new Error(`Cipher key must be ${KEY_LENGTH} bytes, received ${key.length}`);
18
+ }
19
+ return {
20
+ encrypt (plaintext) {
21
+ const iv = randomBytes(IV_LENGTH);
22
+ // gcm().encrypt appends the auth tag, so sealed = ciphertext || tag.
23
+ const sealed = gcm(key, iv).encrypt(fromUTF(plaintext));
24
+ const envelope = new Uint8Array(iv.length + sealed.length);
25
+ envelope.set(iv, 0);
26
+ envelope.set(sealed, iv.length);
27
+ return ENVELOPE_PREFIX + toB64(envelope);
28
+ },
29
+ decrypt (ciphertext) {
30
+ if (!ciphertext.startsWith(ENVELOPE_PREFIX)) {
31
+ throw new Error('Unrecognized cipher envelope, expected v1 prefix');
32
+ }
33
+ const envelope = fromB64(ciphertext.slice(ENVELOPE_PREFIX.length));
34
+ const iv = envelope.subarray(0, IV_LENGTH);
35
+ const sealed = envelope.subarray(IV_LENGTH);
36
+ // Wrong key or tampered bytes fail the GCM tag check and throw here.
37
+ return toUTF(gcm(key, iv).decrypt(sealed));
38
+ }
39
+ };
40
+ }
@@ -51,4 +51,28 @@ export type EngineEvents = {
51
51
  /** Document model ID. */
52
52
  modelID: string;
53
53
  };
54
+ /**
55
+ * Emitted when this peer sets its own model access-default for a
56
+ * (modelID, permissionType). Carries the full rule so a subscriber can
57
+ * replicate the owner's sharing policy to co-members without re-reading it.
58
+ */
59
+ 'engine:access-default:set': {
60
+ ownerDID: string;
61
+ modelID: string;
62
+ permissionType: 'read' | 'write';
63
+ accessLevel: string;
64
+ allowedDIDs: Array<string> | null;
65
+ allowedCircles: Array<string> | null;
66
+ allowedGroups: Array<string> | null;
67
+ /** LWW anchor stamped by this peer. */
68
+ hlc: string;
69
+ };
70
+ /** Emitted when this peer removes its own model access-default(s). */
71
+ 'engine:access-default:removed': {
72
+ ownerDID: string;
73
+ modelID: string;
74
+ permissionTypes: Array<'read' | 'write'>;
75
+ /** LWW anchor stamped by this peer. */
76
+ hlc: string;
77
+ };
54
78
  };
@@ -1 +1,6 @@
1
- export{};
1
+ /**
2
+ * Event types emitted by the engine during document operations.
3
+ *
4
+ * These events are emitted after successful mutations and can be
5
+ * observed by plugins or application code via the EngineEventBus.
6
+ */ export { };
package/lib/engine.d.ts CHANGED
@@ -1,14 +1,17 @@
1
- import { type VerifyTokenHook } from '@enkaku/capability';
2
- import { type Runtime } from '@enkaku/runtime';
3
- import type { Identity } from '@enkaku/token';
1
+ import type { VerifyTokenHook } from '@kokuin/capability';
2
+ import type { Identity } from '@kokuin/token';
4
3
  import { KubunDB, type StoreProvider } from '@kubun/db';
5
4
  import type { Adapter } from '@kubun/db-adapter';
5
+ import { type PatchOperation } from '@kubun/graphql';
6
6
  import { type Logger } from '@kubun/logger';
7
- import type { DeployGraphParams, DeployGraphResult, DocumentNode, ExecuteGraphResult, ListGraphResult, LoadGraphParams, LoadGraphResult } from '@kubun/protocol';
7
+ import type { DeployGraphParams, DeployGraphResult, DocumentData, DocumentNode, ExecuteGraphResult, ListGraphResult, LoadGraphParams, LoadGraphResult } from '@kubun/protocol';
8
8
  import { type DocumentMutation } from '@kubun/protocol';
9
9
  import { type GraphStoreAPI } from '@kubun/store-graph';
10
+ import { type P2PStoreAPI } from '@kubun/store-p2p';
11
+ import { type Runtime } from '@sozai/runtime';
10
12
  import type { GraphQLSchema } from 'graphql';
11
13
  import { type DefaultAccessLevel } from './access-control.js';
14
+ import { type Cipher } from './cipher.js';
12
15
  import type { EngineEvents } from './engine-events.js';
13
16
  import { EngineEventBus } from './events.js';
14
17
  import type { Engine, EngineGraphParams, GraphQLSource, GraphQLSourceParams } from './executor.js';
@@ -67,6 +70,20 @@ export type EngineParams = {
67
70
  * unbounded so offline-first writes always apply. Defaults to one hour.
68
71
  */
69
72
  maxDriftMS?: number;
73
+ /**
74
+ * Maximum number of undelivered events buffered per subscription generator
75
+ * before the oldest are dropped. Guards against a slow subscriber growing
76
+ * memory without bound. Defaults to the GraphQL context default (1000).
77
+ */
78
+ maxSubscriptionQueueSize?: number;
79
+ /**
80
+ * Explicit at-rest cipher override. When omitted the engine derives one from
81
+ * an `OwnIdentity` (full identity carrying a `privateKey`), and leaves it
82
+ * undefined for read-only identities. The app/CLI wiring layer is responsible
83
+ * for building an env-key-based cipher (e.g. from `KUBUN_AT_REST_KEY`) and
84
+ * passing it here — the engine itself never reads `process.env`.
85
+ */
86
+ cipher?: Cipher;
70
87
  };
71
88
  /**
72
89
  * Optional pre-persist gate. Receives the synthesized post-apply
@@ -81,6 +98,25 @@ export type EngineParams = {
81
98
  * resulting decision through this hook.
82
99
  */
83
100
  export type AccessGate = (postState: DocumentNode) => boolean | Promise<boolean>;
101
+ /**
102
+ * Transaction-scoped stores handed to an {@link AccessGateFactory} so the gate
103
+ * resolves access state through the apply transaction's connection.
104
+ */
105
+ export type AccessGateStores = {
106
+ graphStore: GraphStoreAPI;
107
+ p2pStore?: P2PStoreAPI;
108
+ };
109
+ /**
110
+ * Builds an {@link AccessGate} bound to the apply transaction's stores.
111
+ *
112
+ * The apply runs inside a DB transaction; a gate that reads access state must
113
+ * read through that transaction, not a main-connection handle captured at
114
+ * construction time (which deadlocks on single-connection SQLite and reads a
115
+ * stale snapshot on Postgres). The engine invokes this factory with its
116
+ * transaction-scoped stores and uses the returned gate. Returning `undefined`
117
+ * skips the gate (apply proceeds).
118
+ */
119
+ export type AccessGateFactory = (stores: AccessGateStores) => AccessGate | undefined;
84
120
  export type ApplyVerifiedMutationParams = {
85
121
  /** The signed mutation JWT token */
86
122
  token: string;
@@ -91,10 +127,12 @@ export type ApplyVerifiedMutationParams = {
91
127
  */
92
128
  origin?: 'local' | 'peer';
93
129
  /**
94
- * Optional gate evaluated after compute, before persist + event emit.
95
- * On deny: no DB write, no events, result returns `dropped: true`.
130
+ * Optional gate factory evaluated after compute, before persist + event emit.
131
+ * The engine invokes it with its transaction-scoped stores so gate reads run
132
+ * inside the apply transaction. On deny: no DB write, no events, result
133
+ * returns `dropped: true`.
96
134
  */
97
- accessGate?: AccessGate;
135
+ accessGate?: AccessGateFactory;
98
136
  /**
99
137
  * MLS group the mutation was delivered through, on the broadcast receive
100
138
  * path; absent on merkle/RPC where the doc's groups are resolved by walk.
@@ -132,16 +170,46 @@ export type ApplyVerifiedMutationsParams = {
132
170
  */
133
171
  origin?: 'local' | 'peer';
134
172
  /**
135
- * Optional gate evaluated per entry. Each entry's gate decision is
136
- * independent some entries may be applied while others are dropped.
173
+ * Optional gate factory evaluated per entry. The engine invokes it once with
174
+ * its transaction-scoped stores; each entry's gate decision is independent
175
+ * some entries may be applied while others are dropped.
137
176
  */
138
- accessGate?: AccessGate;
177
+ accessGate?: AccessGateFactory;
139
178
  };
140
179
  export type ApplyVerifiedMutationsResult = {
141
180
  results: Array<ApplyVerifiedMutationResult>;
142
181
  /** Count of entries dropped via the access gate. */
143
182
  dropped: number;
144
183
  };
184
+ /**
185
+ * A single document write for `mutateDocuments`. The engine signs each write
186
+ * with its own identity, optionally on behalf of `owner`, and applies it inside
187
+ * one signed write transaction — no GraphQL text is parsed or executed.
188
+ */
189
+ export type DocumentWrite = {
190
+ type: 'create';
191
+ modelID: string;
192
+ data: DocumentData;
193
+ } | {
194
+ type: 'set';
195
+ modelID: string;
196
+ unique: Uint8Array;
197
+ data: DocumentData;
198
+ } | {
199
+ type: 'update';
200
+ docID: string;
201
+ patch: Array<PatchOperation>;
202
+ } | {
203
+ type: 'remove';
204
+ docID: string;
205
+ };
206
+ export type MutateDocumentsParams = {
207
+ /** DID the written documents are owned by; defaults to the signer. */
208
+ owner?: string;
209
+ /** Delegation tokens to attach, unioned with tokens this device holds. */
210
+ delegationTokens?: Array<string>;
211
+ writes: Array<DocumentWrite>;
212
+ };
145
213
  export type ExecuteParams = {
146
214
  graphID: string;
147
215
  text: string;
@@ -158,7 +226,9 @@ export declare class KubunEngine implements Engine {
158
226
  get identity(): Identity;
159
227
  get eventBus(): EngineEventBus<EngineEvents>;
160
228
  /**
161
- * Dispose the engine by shutting down all plugins in parallel.
229
+ * Dispose the engine by shutting down all plugins in reverse registration order.
230
+ * Second and subsequent calls are no-ops. Each plugin's disposal failure is
231
+ * logged but does not prevent remaining plugins from being disposed.
162
232
  */
163
233
  dispose(): Promise<void>;
164
234
  /**
@@ -180,6 +250,13 @@ export declare class KubunEngine implements Engine {
180
250
  deployGraph(params: DeployGraphParams): Promise<DeployGraphResult>;
181
251
  queryGraph<Data extends Record<string, unknown> = Record<string, unknown>>(params: EngineGraphParams): Promise<ExecuteGraphResult<Data>>;
182
252
  mutateGraph<Data extends Record<string, unknown> = Record<string, unknown>>(params: EngineGraphParams): Promise<ExecuteGraphResult<Data>>;
253
+ /**
254
+ * Apply a batch of document writes signed by this engine's identity, all
255
+ * within a single signed write transaction. Unlike `mutateGraph` this does not
256
+ * parse or execute any GraphQL text — writes are driven directly onto the
257
+ * mutation operations. Returns the applied result for each write, in order.
258
+ */
259
+ mutateDocuments(params: MutateDocumentsParams): Promise<Array<ApplyVerifiedMutationResult>>;
183
260
  subscribeToGraph<Data extends Record<string, unknown> = Record<string, unknown>>(params: EngineGraphParams): Promise<AsyncGenerator<ExecuteGraphResult<Data>> | ExecuteGraphResult<Data>>;
184
261
  getAPI<T extends Record<string, unknown> = Record<string, unknown>>(name: string): Promise<T>;
185
262
  }