@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.
- package/lib/access-control.d.ts +1 -1
- package/lib/access-control.js +250 -1
- package/lib/catalog-match.js +34 -1
- package/lib/cipher.d.ts +6 -0
- package/lib/cipher.js +40 -0
- package/lib/engine-events.d.ts +24 -0
- package/lib/engine-events.js +6 -1
- package/lib/engine.d.ts +88 -11
- package/lib/engine.js +1877 -1
- package/lib/errors.d.ts +35 -0
- package/lib/errors.js +52 -0
- package/lib/events.d.ts +3 -3
- package/lib/events.js +85 -1
- package/lib/executor.d.ts +7 -0
- package/lib/executor.js +1 -1
- package/lib/index.d.ts +4 -1
- package/lib/index.js +10 -1
- package/lib/membership-check.js +99 -1
- package/lib/mutation-hash.js +6 -1
- package/lib/plugin.d.ts +31 -3
- package/lib/plugin.js +7 -1
- package/lib/policies.js +55 -1
- package/lib/registry.js +25 -1
- package/package.json +31 -28
package/lib/access-control.d.ts
CHANGED
package/lib/access-control.js
CHANGED
|
@@ -1 +1,250 @@
|
|
|
1
|
-
import{checkCapability
|
|
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
|
+
}
|
package/lib/catalog-match.js
CHANGED
|
@@ -1 +1,34 @@
|
|
|
1
|
-
|
|
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
|
+
}
|
package/lib/cipher.d.ts
ADDED
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
|
+
}
|
package/lib/engine-events.d.ts
CHANGED
|
@@ -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
|
};
|
package/lib/engine-events.js
CHANGED
package/lib/engine.d.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
-
*
|
|
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?:
|
|
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.
|
|
136
|
-
*
|
|
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?:
|
|
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
|
|
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
|
}
|