@kubun/engine 0.8.2 → 0.9.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 +37 -6
- package/lib/access-control.js +1 -1
- package/lib/engine-events.d.ts +26 -1
- package/lib/engine.d.ts +55 -8
- package/lib/engine.js +1 -1
- package/lib/index.d.ts +2 -2
- package/lib/index.js +1 -1
- package/lib/plugin.d.ts +2 -1
- package/package.json +19 -19
package/lib/access-control.d.ts
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import type { DocumentNode } from '@kubun/protocol';
|
|
2
|
+
import type { AccessLevel, StoredAccessRule } from '@kubun/store-graph';
|
|
2
3
|
/**
|
|
3
4
|
* Minimal database interface required by the access control system.
|
|
4
5
|
* Decoupled from KubunDB so any store implementation can provide these methods.
|
|
5
6
|
*/
|
|
6
7
|
export type AccessControlDB = {
|
|
7
|
-
getUserModelAccessDefault(ownerDID: string, modelID: string, permissionType: 'read' | 'write'): Promise<
|
|
8
|
-
level: string;
|
|
9
|
-
allowedDIDs: Array<string> | null;
|
|
10
|
-
} | null>;
|
|
8
|
+
getUserModelAccessDefault(ownerDID: string, modelID: string, permissionType: 'read' | 'write'): Promise<StoredAccessRule | null>;
|
|
11
9
|
isMemberOfAnyCircle(viewerDID: string, circleIDs: Array<string>): Promise<boolean>;
|
|
10
|
+
isMemberOfAnyGroup(viewerDID: string, groupIDs: Array<string>): Promise<boolean>;
|
|
12
11
|
};
|
|
13
|
-
export type AccessLevel
|
|
12
|
+
export type { AccessLevel };
|
|
14
13
|
export type AccessRule = {
|
|
15
14
|
level: AccessLevel;
|
|
16
15
|
allowedDIDs: Array<string> | null;
|
|
17
16
|
allowedCircles: Array<string> | null;
|
|
17
|
+
allowedGroups: Array<string> | null;
|
|
18
18
|
};
|
|
19
19
|
export type AccessPermissions = {
|
|
20
20
|
read?: AccessRule;
|
|
@@ -22,17 +22,48 @@ export type AccessPermissions = {
|
|
|
22
22
|
};
|
|
23
23
|
export type DefaultAccessLevel = {
|
|
24
24
|
read: AccessLevel;
|
|
25
|
-
write: 'only_owner' | '
|
|
25
|
+
write: 'only_owner' | 'restricted';
|
|
26
26
|
};
|
|
27
27
|
export type AccessChecker = (doc: DocumentNode, permissionType: 'read' | 'write') => Promise<boolean>;
|
|
28
28
|
/**
|
|
29
29
|
* Parse and validate access permissions from document data.
|
|
30
|
+
*
|
|
31
|
+
* Ensures every returned rule carries the `allowedGroups` field (defaulting
|
|
32
|
+
* to `null` when absent on input) and rejects rules with unknown `level`
|
|
33
|
+
* tokens.
|
|
30
34
|
*/
|
|
31
35
|
export declare function parseDocumentAccessPermissions(data: unknown): AccessPermissions | null;
|
|
32
36
|
/**
|
|
33
37
|
* Validate that DIDs have the correct format.
|
|
34
38
|
*/
|
|
35
39
|
export declare function validateDIDs(dids: Array<string>): void;
|
|
40
|
+
/**
|
|
41
|
+
* Validate that an identifier (circle or group ID) is a non-empty,
|
|
42
|
+
* non-whitespace string. There is no canonical format helper for circle and
|
|
43
|
+
* group IDs in `@kubun/id` (they are app-supplied opaque strings — `@kubun/id`
|
|
44
|
+
* helpers cover content-addressed Kubun IDs only), so this check rejects only
|
|
45
|
+
* obvious garbage. Downstream membership checks won't match if the format is
|
|
46
|
+
* malformed beyond this — callers should treat malformed IDs as an empty
|
|
47
|
+
* scope.
|
|
48
|
+
*/
|
|
49
|
+
export declare function validateID(id: string, kind: 'circle' | 'group'): void;
|
|
50
|
+
/**
|
|
51
|
+
* Apply {@link validateID} to every entry of an array.
|
|
52
|
+
*/
|
|
53
|
+
export declare function validateIDs(ids: Array<string>, kind: 'circle' | 'group'): void;
|
|
54
|
+
/**
|
|
55
|
+
* Resolve the effective access rule for a document and permission type.
|
|
56
|
+
*
|
|
57
|
+
* Order of precedence:
|
|
58
|
+
* 1. Document accessPermissions override
|
|
59
|
+
* 2. User's model default from database
|
|
60
|
+
* 3. Server configuration default
|
|
61
|
+
*
|
|
62
|
+
* Document overrides with `'anyone'` on a `write` permission are rejected
|
|
63
|
+
* (anyone-write is not a valid configuration) and fall through to the next
|
|
64
|
+
* tier.
|
|
65
|
+
*/
|
|
66
|
+
export declare function resolveAccessRule(document: DocumentNode, modelID: string, ownerDID: string, permissionType: 'read' | 'write', db: AccessControlDB, defaultAccessLevel: DefaultAccessLevel): Promise<AccessRule>;
|
|
36
67
|
/**
|
|
37
68
|
* Create an access checker function bound to specific viewer, delegation tokens,
|
|
38
69
|
* database instance, and server default access level.
|
package/lib/access-control.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{checkCapability as e}from"@enkaku/capability";export function parseDocumentAccessPermissions(e){try{if(!e||"object"!=typeof e||!("accessPermissions"in e))return null;let l=e.accessPermissions;if("object"!=typeof l)return null;return
|
|
1
|
+
import{checkCapability as e}from"@enkaku/capability";function l(e){return Array.isArray(e)&&e.every(e=>"string"==typeof e)?e:null}function r(e){var r;return e&&"object"==typeof e?"only_owner"!==(r=e.level)&&"anyone"!==r&&"restricted"!==r?null:{level:e.level,allowedDIDs:l(e.allowedDIDs),allowedCircles:l(e.allowedCircles),allowedGroups:l(e.allowedGroups)}:null}export function parseDocumentAccessPermissions(e){try{if(!e||"object"!=typeof e||!("accessPermissions"in e))return null;let l=e.accessPermissions;if(!l||"object"!=typeof l)return null;let t={},o=r(l.read);o&&(t.read=o);let n=r(l.write);return n&&(t.write=n),t}catch{return null}}export function validateDIDs(e){for(let l of e)if("string"!=typeof l||!l.startsWith("did:"))throw Error(`Invalid DID format: ${String(l)}`)}export function validateID(e,l){if("string"!=typeof e||0===e.trim().length)throw Error(`Invalid ${l} ID: must be a non-empty string`)}export function validateIDs(e,l){for(let r of e)validateID(r,l)}export async function resolveAccessRule(e,l,r,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(r,l,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(l,r,t,o,n){if(!n||0===n.length)return!1;let a=[`urn:kubun:document:${t.id}`,`urn:kubun:model:${t.model}`,"*"],i=`document/${o}`;for(let t of a)try{return await e({act:i,res:t},{iss:l,sub:r,cap:n}),!0}catch{}for(let t of n)for(let o of a)try{return await e({act:i,res:o},{iss:l,sub:r,cap:t}),!0}catch{}return!1}async function o(e,l,r,o,n,a){if(!l.owner)throw Error("Document missing owner field");if(e===l.owner)return!0;let i=await resolveAccessRule(l,l.model,l.owner,r,o,n);if("anyone"===i.level)return!0;if(!e)return!1;if("only_owner"===i.level)return await t(e,l.owner,l,r,a);if("restricted"===i.level){let n=i.allowedDIDs||[];if(n.includes(e)||"read"===r&&null!=i.allowedCircles&&i.allowedCircles.length>0&&await o.isMemberOfAnyCircle(e,i.allowedCircles)||"read"===r&&null!=i.allowedGroups&&i.allowedGroups.length>0&&await o.isMemberOfAnyGroup(e,i.allowedGroups))return!0;for(let o of n)if(await t(e,o,l,r,a))return!0}return!1}export function createAccessChecker(e){return async(l,r)=>await o(e.viewerDID,l,r,e.db,e.defaultAccessLevel,e.delegationTokens)}
|
package/lib/engine-events.d.ts
CHANGED
|
@@ -21,9 +21,34 @@ export type EngineEvents = {
|
|
|
21
21
|
documentID: string;
|
|
22
22
|
viewerDID: string;
|
|
23
23
|
};
|
|
24
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Emitted when a mutation signed locally (authored on this peer) is applied.
|
|
26
|
+
* Fires exactly once per local-origin mutation, regardless of classification.
|
|
27
|
+
*/
|
|
28
|
+
'engine:mutation:authored': {
|
|
25
29
|
type: 'create' | 'update' | 'remove';
|
|
26
30
|
documentID: string;
|
|
27
31
|
viewerDID: string;
|
|
32
|
+
/** Signed mutation JWT — consumed by broadcast senders to route to peers. */
|
|
33
|
+
mutationJWT: string;
|
|
34
|
+
/** HLC string identifying this mutation's version. */
|
|
35
|
+
version: string;
|
|
36
|
+
/** Document model ID — stable across a batch of mutations on the same doc. */
|
|
37
|
+
modelID: string;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Emitted when a mutation received from a peer (authored elsewhere) is applied.
|
|
41
|
+
* Fires exactly once per peer-origin mutation, regardless of classification.
|
|
42
|
+
*/
|
|
43
|
+
'engine:mutation:received': {
|
|
44
|
+
type: 'create' | 'update' | 'remove';
|
|
45
|
+
documentID: string;
|
|
46
|
+
viewerDID: string;
|
|
47
|
+
/** Signed mutation JWT — same shape as `engine:mutation:authored`. */
|
|
48
|
+
mutationJWT: string;
|
|
49
|
+
/** HLC string identifying this mutation's version. */
|
|
50
|
+
version: string;
|
|
51
|
+
/** Document model ID. */
|
|
52
|
+
modelID: string;
|
|
28
53
|
};
|
|
29
54
|
};
|
package/lib/engine.d.ts
CHANGED
|
@@ -33,28 +33,74 @@ export type EngineParams = {
|
|
|
33
33
|
plugins?: Array<(params: PluginFactoryParams) => KubunPlugin>;
|
|
34
34
|
eventBus?: EngineEventBus<EngineEvents>;
|
|
35
35
|
};
|
|
36
|
+
/**
|
|
37
|
+
* Optional pre-persist gate. Receives the synthesized post-apply
|
|
38
|
+
* `DocumentNode` after the mutation's effect has been computed in memory but
|
|
39
|
+
* before any write to the store (no `saveDocument`/`createDocument`/log
|
|
40
|
+
* insert). Returning `false` causes the engine to skip persist + event emit
|
|
41
|
+
* and report the mutation as `dropped: true`. Returning `true` (or no gate
|
|
42
|
+
* provided) lets the existing apply flow proceed.
|
|
43
|
+
*
|
|
44
|
+
* The gate is intentionally generic — it is not access-control-aware. Q4.1b
|
|
45
|
+
* composes `resolveAccessRule`/`checkAccess` in the receive wiring and passes
|
|
46
|
+
* the resulting decision through this hook.
|
|
47
|
+
*/
|
|
48
|
+
export type AccessGate = (postState: DocumentNode) => boolean | Promise<boolean>;
|
|
36
49
|
export type ApplyVerifiedMutationParams = {
|
|
37
50
|
/** The signed mutation JWT token */
|
|
38
51
|
token: string;
|
|
39
|
-
/**
|
|
40
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Source of the mutation.
|
|
54
|
+
* - `'local'` (default): authored on this peer — emits `engine:mutation:authored`.
|
|
55
|
+
* - `'peer'`: received from another peer — emits `engine:mutation:received`.
|
|
56
|
+
*/
|
|
57
|
+
origin?: 'local' | 'peer';
|
|
58
|
+
/**
|
|
59
|
+
* Optional gate evaluated after compute, before persist + event emit.
|
|
60
|
+
* On deny: no DB write, no events, result returns `dropped: true`.
|
|
61
|
+
*/
|
|
62
|
+
accessGate?: AccessGate;
|
|
41
63
|
};
|
|
42
64
|
export type ApplyVerifiedMutationResult = {
|
|
43
|
-
/** The resulting document after mutation
|
|
44
|
-
|
|
65
|
+
/** The resulting document after mutation. Null when the gate denied — the
|
|
66
|
+
* pre-state is preserved on disk and the post-state was discarded. */
|
|
67
|
+
document: DocumentNode | null;
|
|
68
|
+
/** The document snapshot before the mutation was applied, or null if it did not exist */
|
|
69
|
+
previousDoc: DocumentNode | null;
|
|
45
70
|
/** The verified mutation payload */
|
|
46
71
|
mutation: DocumentMutation;
|
|
47
72
|
/** The DID of the mutation author (from JWT issuer) */
|
|
48
73
|
authorDID: string;
|
|
49
74
|
/** BLAKE3 hash of the mutation JWT */
|
|
50
75
|
hash: string;
|
|
76
|
+
/** The signed mutation JWT — retained so downstream consumers (e.g. broadcast
|
|
77
|
+
* senders) can route the mutation to peers without re-signing. */
|
|
78
|
+
token: string;
|
|
79
|
+
/**
|
|
80
|
+
* `true` only when an `accessGate` denied this mutation post-compute.
|
|
81
|
+
* `false` otherwise (including verify-failure scenarios — those throw
|
|
82
|
+
* before this result is constructed).
|
|
83
|
+
*/
|
|
84
|
+
dropped: boolean;
|
|
51
85
|
};
|
|
52
86
|
export type ApplyVerifiedMutationsParams = {
|
|
53
87
|
tokens: Array<string>;
|
|
54
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Source of the mutations in this batch.
|
|
90
|
+
* - `'local'` (default): authored on this peer — emits `engine:mutation:authored`.
|
|
91
|
+
* - `'peer'`: received from another peer — emits `engine:mutation:received`.
|
|
92
|
+
*/
|
|
93
|
+
origin?: 'local' | 'peer';
|
|
94
|
+
/**
|
|
95
|
+
* Optional gate evaluated per entry. Each entry's gate decision is
|
|
96
|
+
* independent — some entries may be applied while others are dropped.
|
|
97
|
+
*/
|
|
98
|
+
accessGate?: AccessGate;
|
|
55
99
|
};
|
|
56
100
|
export type ApplyVerifiedMutationsResult = {
|
|
57
101
|
results: Array<ApplyVerifiedMutationResult>;
|
|
102
|
+
/** Count of entries dropped via the access gate. */
|
|
103
|
+
dropped: number;
|
|
58
104
|
};
|
|
59
105
|
export type ExecuteParams = {
|
|
60
106
|
graphID: string;
|
|
@@ -82,9 +128,10 @@ export declare class KubunEngine implements Engine {
|
|
|
82
128
|
*/
|
|
83
129
|
getGraphQLSource(params: GraphQLSourceParams): GraphQLSource;
|
|
84
130
|
/**
|
|
85
|
-
* Register a context factory that contributes per-request fields to
|
|
86
|
-
* GraphQL execution context.
|
|
87
|
-
*
|
|
131
|
+
* Register a context factory that contributes per-request fields flatly to
|
|
132
|
+
* the GraphQL execution context. Escape hatch for test fixtures — not
|
|
133
|
+
* subject to the plugin-namespace collision check. Prefer `KubunPlugin`'s
|
|
134
|
+
* `createContextFactory` for plugin-scoped, namespaced contributions.
|
|
88
135
|
*/
|
|
89
136
|
registerContextFactory(factory: ContextFactory): void;
|
|
90
137
|
getGraphQLSchema(id: string): Promise<GraphQLSchema>;
|
package/lib/engine.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{createRuntime as e}from"@enkaku/runtime";import{asType as t,createValidator as i}from"@enkaku/schema";import{isSigningIdentity as s,stringifyToken as r,verifyToken as a}from"@enkaku/token";import{KubunDB as n}from"@kubun/db";import{createReadContext as o,createSchema as l}from"@kubun/graphql";import{HLC as u}from"@kubun/hlc";import{getKubunLogger as h}from"@kubun/logger";import{applyMutation as c,convertPatchInput as d,createMutationOperations as p}from"@kubun/mutation";import{clusterToRecord as g,documentMutation as m,GraphModel as y}from"@kubun/protocol";import{GRAPH_STORE as v,getGraphStore as w,graphStoreDefinition as f}from"@kubun/store-graph";import{execute as x,GraphQLError as b,Kind as D,parse as I,subscribe as M}from"graphql";import{EngineEventBus as E}from"./events.js";import{computeMutationHash as G}from"./mutation-hash.js";import{runPolicies as k}from"./policies.js";import{createRegistry as O}from"./registry.js";let S=i(m);function j(){throw Error("Graph mutations require mutateGraph() — the core context does not sign or log writes. Use KubunEngine.mutateGraph() instead.")}function q(){throw Error("Access control mutations are not supported by the engine core — plugin override required.")}function C(){throw Error("Transaction mutations are not supported by the engine core — plugin override required (see plugin-rpc).")}let F={executeCreateMutation:j,executeSetMutation:j,executeUpdateMutation:j,executeRemoveMutation:j,executeSetModelAccessDefaults:q,executeRemoveModelAccessDefaults:q,executeSetDocumentAccessOverride:q,executeRemoveDocumentAccessOverride:q,beginTransaction:C,commitTransaction:C,rollbackTransaction:C};export class KubunEngine{#e=[];#t;#i;#s={};#r;#a;#n;#o={};#l=new Map;#u=[];#h;#c;#d={};#p={};constructor(t){this.#t=t.db instanceof n?t.db:new n({adapter:t.db}),this.#t.register(f),this.#a=t.identity,this.#r=new u({nodeID:t.identity.id}),this.#n=t.logger??h("engine"),this.#i=t.eventBus??new E({logger:this.#n.getChild("events")}),this.#c=e(t.runtime),this.#h=O();let i=t.plugins??[],s={engine:this,graph:{execute:e=>this.#g(e),subscribe:e=>this.#m(e),applyVerifiedMutation:e=>this.#y(e),applyVerifiedMutations:e=>this.#v(e)},db:this.#t,runtime:this.#c,identity:this.#a,eventBus:this.#i,hlc:this.#r,getLogger:e=>this.#n.getChild(e)};for(let e of i){let t=e(s);this.#u.push(t),null!=t.api&&this.#h.registerPlugin(t.name,t.api)}for(let e of(this.#h.closeGate(),this.#u))if(null!=e.schemaExtension&&this.#l.set(e.name,e.schemaExtension),null!=e.createContextFactory&&this.#e.push(e.createContextFactory()),null!=e.policies)for(let[t,i]of Object.entries(e.policies)){null==this.#o[t]&&(this.#o[t]={sync:[],async:[]});let e=this.#o[t];null!=i.sync&&(e.sync=[...e.sync??[],...i.sync]),null!=i.async&&(e.async=[...e.async??[],...i.async])}this.#n.info("engine initialized with {count} plugins",{count:this.#u.length})}get did(){return this.#a.id}get identity(){return this.#a}get eventBus(){return this.#i}async dispose(){await Promise.all(this.#u.filter(e=>null!=e.dispose).map(e=>e.dispose?.()))}getGraphQLSource(e){let{graphID:t}=e,i=e.getViewerDID??(()=>this.#a.id);return{query:async e=>await this.queryGraph({id:t,text:e.text,variables:e.variables,viewerDID:i()??void 0}),mutate:async e=>await this.mutateGraph({id:t,text:e.text,variables:e.variables,viewerDID:i()??void 0}),subscribe:e=>this.#m({graphID:t,text:e.text,variables:e.variables,viewerDID:i()??void 0})}}registerContextFactory(e){this.#e.push(e)}async #w(){return await w(this.#t)}async #f(e){var t;let i,s=e.viewerDID??this.#a.id,r=this.#x(s),a=e.stores??this.#t,n={};for(let e of this.#e)n={...n,...e(r,a)};null!=e.contextExtensions&&(n={...n,...e.contextExtensions});let l=e.graphID;return this.#p[l]??={},i={...o({store:(t={store:await w(a),viewerDID:s,eventBus:this.#i,contextExtensions:Object.keys(n).length>0?n:void 0}).store,viewerDID:t.viewerDID,events:t.eventBus}),...F},null!=t.contextExtensions?{...i,...t.contextExtensions}:i}#x(e){return{viewerDID:e}}async #b(e){return null==this.#s[e]&&(this.#s[e]=this.#w().then(async t=>{let i=await t.getGraph(e);if(null==i)throw this.#n.warn("graph {id} not found",{id:e}),delete this.#s[e],Error(`Graph not found: ${e}`);return this.#n.debug("cached model for graph {id}",{id:e}),{aliases:i.aliases,record:i.record,extensionSDL:i.extension_sdl??void 0,pluginConfig:i.plugin_config??void 0}})),await this.#s[e]}async getGraphQLSchema(e){return null==this.#d[e]&&(this.#d[e]=this.#b(e).then(t=>{let i;if(null!=t.pluginConfig){let e={},s={},r={},a={},n={};for(let[i,o]of Object.entries(t.pluginConfig)){let t=this.#l.get(i);if(null!=t){let i=t(o);if(Object.assign(e,i.resolvers.queryFields??{}),Object.assign(s,i.resolvers.mutationFields??{}),Object.assign(r,i.resolvers.subscriptionFields??{}),Object.assign(n,i.resolvers.nodeResolvers??{}),null!=i.resolvers.typeFields)for(let[e,t]of Object.entries(i.resolvers.typeFields))a[e]={...a[e],...t}}}i={queryFields:e,mutationFields:s,subscriptionFields:r,typeFields:a,nodeResolvers:n}}let s=l({...t,extensionResolvers:i});return this.#n.debug("cached schema for graph {id}",{id:e}),s}).catch(t=>{throw delete this.#d[e],t})),await this.#d[e]}async #D(e,t){let i=t??I(e.text);if(null==i.definitions[0])throw Error("Missing GraphQL document definition");return{schema:await this.getGraphQLSchema(e.graphID),document:i,contextValue:await this.#f(e),variableValues:e.variables}}#I(e){let t=e.definitions[0];return null!=t&&t.kind===D.OPERATION_DEFINITION?t.operation:"query"}async #M(e,t){let i=await k(this.#o,e,{viewerDID:t});return"issues"in i&&null!=i.issues&&i.issues.length>0?{errors:i.issues.map(e=>new b(e.message,{extensions:null!=e.path?{path:e.path}:void 0}))}:null}async listGraphs(){let e=await this.#w();return{graphs:(await e.listGraphs()).map(e=>({id:e.id,name:e.name}))}}async loadGraph(e){let t=await this.#b(e.id);return{aliases:t.aliases,record:t.record,extensionSDL:t.extensionSDL}}async deployGraph(e){let t,i,s={};for(let t of e.clusters)Object.assign(s,g(t));let r=y.fromClusters({clusters:s});if(null!=e.plugins){i=e.plugins;let s=[];for(let[e,t]of Object.entries(i)){let i=this.#l.get(e);if(null==i)throw Error(`Plugin "${e}" not found or does not provide schema extensions`);let r=i(t);s.push(r.sdl)}s.length>0&&(t=s.join("\n"))}let a=await this.#w(),n=await a.createGraph({id:e.id??this.#c.getRandomID(),name:e.name,record:r.record,extensionSDL:t,pluginConfig:i}),o=r.toJSON();return this.#s[n]=Promise.resolve({...o,extensionSDL:t,pluginConfig:i}),delete this.#d[n],delete this.#p[n],this.#n.info("deployed graph {id}",{id:n}),{id:n,...o}}async #E(e){for(let{document:t,mutation:i,authorDID:s}of e){let e="set"===i.typ?"create":"update",r={type:e,document:t,previous:{data:null},getCursor:()=>t.id};await this.#i.emit("document:saved",r),"create"===e?await this.#i.emit("engine:document:created",{documentID:t.id,modelID:t.model,viewerDID:s}):await this.#i.emit("engine:document:updated",{documentID:t.id,modelID:t.model,viewerDID:s}),await this.#i.emit("engine:mutation:applied",{type:e,documentID:t.id,viewerDID:s})}}async #y(e,i){let{token:s,graphID:r}=e,n=t(S,(await a(s)).payload);this.#p[r]??={};let o=null!=i?await w(i):await this.#w(),l={store:o,validators:this.#p[r],hlc:this.#r},u=await c(l,n),h=G(s),d=n.iss;await o.insertMutationLogEntry({mutation_hash:h,model_id:u.model,document_id:u.id,author_did:d,hlc:n.hlc,mutation_jwt:s,status:"applied"});let p={document:u,mutation:n,authorDID:d,hash:h};return null==i&&await this.#E([p]),p}async #v(e){let{tokens:i,graphID:s}=e;this.#p[s]??={};let r=await Promise.all(i.map(async e=>{let i=t(S,(await a(e)).payload);return{token:e,mutation:i}})),n=[];return await this.#t.withTransaction(async e=>{let t=await e.getStore(v);for(let{token:e,mutation:i}of r){let r={store:t,validators:this.#p[s],hlc:this.#r},a=await c(r,i),o=G(e);await t.insertMutationLogEntry({mutation_hash:o,model_id:a.model,document_id:a.id,author_did:i.iss,hlc:i.hlc,mutation_jwt:e,status:"applied"}),n.push({document:a,mutation:i,authorDID:i.iss,hash:o})}}),await this.#E(n),{results:n}}async #g(e){let t=I(e.text),i=this.#I(t),s=await this.#M(i,e.viewerDID??this.#a.id);if(null!=s)return s;let r=await this.#D(e,t);return await x(r)}async #m(e){let t=await this.#D(e);return await M(t)}async queryGraph(e){let t=I(e.text);if("mutation"===this.#I(t))throw Error("queryGraph() does not accept mutation operations. Use mutateGraph() instead.");return await this.#g({graphID:e.id,text:e.text,variables:e.variables??{},viewerDID:e.viewerDID})}async mutateGraph(e){if(!s(this.#a))throw Error("mutateGraph requires a SigningIdentity");let t=this.#a,i=e.id,a=[],n=await this.#t.withTransaction(async s=>{let n=async e=>{let n=r(await t.signToken(e)),o=await this.#y({token:n,graphID:i},s);return a.push(o),o.document},o=p({issuer:t.id,hlc:this.#r,getRandomValues:this.#c.getRandomValues,processSetMutation:e=>n(e),processChangeMutation:e=>n(e)});return await this.#g({graphID:i,text:e.text,variables:e.variables??{},viewerDID:e.viewerDID??t.id,stores:s,contextExtensions:{executeCreateMutation:async e=>await o.createDocument({modelID:e.modelID,data:e.data}),executeSetMutation:async e=>await o.setDocument({modelID:e.modelID,unique:e.unique,data:e.data}),executeUpdateMutation:async e=>await o.updateDocument({docID:e.input.id,patch:d(e.input.patch)}),executeRemoveMutation:async e=>{await o.removeDocument({docID:e.id})}}})});return await this.#E(a),n}async subscribeToGraph(e){return await this.#m({graphID:e.id,text:e.text,variables:e.variables??{},viewerDID:e.viewerDID})}async getAPI(e){return await this.#h.getAPI(e)}}
|
|
1
|
+
import{createRuntime as e}from"@enkaku/runtime";import{asType as t,createValidator as i}from"@enkaku/schema";import{isSigningIdentity as s,stringifyToken as a,verifyToken as n}from"@enkaku/token";import{KubunDB as r}from"@kubun/db";import{createReadContext as o,createSchema as l}from"@kubun/graphql";import{HLC as u}from"@kubun/hlc";import{DocumentID as d}from"@kubun/id";import{getKubunLogger as c}from"@kubun/logger";import{applyMutation as h,convertPatchInput as p,createMutationOperations as m}from"@kubun/mutation";import{clusterToRecord as g,documentMutation as v,GraphModel as f}from"@kubun/protocol";import{GRAPH_STORE as w,getGraphStore as y,graphStoreDefinition as D}from"@kubun/store-graph";import{execute as x,GraphQLError as b,Kind as I,parse as E,subscribe as M}from"graphql";import{EngineEventBus as G}from"./events.js";import{computeMutationHash as R}from"./mutation-hash.js";import{runPolicies as C}from"./policies.js";import{createRegistry as S}from"./registry.js";let k=i(v);function F(){throw Error("Graph mutations require mutateGraph() — the core context does not sign or log writes. Use KubunEngine.mutateGraph() instead.")}function O(){throw Error("Access control mutations are not supported by the engine core — plugin override required.")}function j(){throw Error("Transaction mutations are not supported by the engine core — plugin override required (see plugin-rpc).")}let q={executeCreateMutation:F,executeSetMutation:F,executeUpdateMutation:F,executeRemoveMutation:F,executeSetModelAccessDefaults:O,executeRemoveModelAccessDefaults:O,executeSetDocumentAccessOverride:O,executeRemoveDocumentAccessOverride:O,beginTransaction:j,commitTransaction:j,rollbackTransaction:j};export class KubunEngine{#e=[];#t=[];#i;#s;#a={};#n;#r;#o;#l={};#u=new Map;#d=[];#c;#h;#p={};#m={};constructor(t){this.#i=t.db instanceof r?t.db:new r({adapter:t.db}),this.#i.register(D),this.#r=t.identity,this.#n=new u({nodeID:t.identity.id}),this.#o=t.logger??c("engine"),this.#s=t.eventBus??new G({logger:this.#o.getChild("events")}),this.#h=e(t.runtime),this.#c=S();let i=t.plugins??[],s={engine:this,graph:{execute:e=>this.#g(e),subscribe:e=>this.#v(e),applyVerifiedMutation:e=>this.#f(e),applyVerifiedMutations:e=>this.#w(e)},db:this.#i,runtime:this.#h,identity:this.#r,eventBus:this.#s,hlc:this.#n,getLogger:e=>this.#o.getChild(e)};for(let e of i){let t=e(s);this.#d.push(t),null!=t.api&&this.#c.registerPlugin(t.name,t.api)}this.#c.closeGate();let a=new Set;for(let e of this.#d){if(null!=e.schemaExtension&&this.#u.set(e.name,e.schemaExtension),null!=e.createContextFactory){if(a.has(e.name))throw Error(`Duplicate plugin namespace: ${e.name}`);a.add(e.name),this.#e.push({name:e.name,factory:e.createContextFactory()})}if(null!=e.policies)for(let[t,i]of Object.entries(e.policies)){null==this.#l[t]&&(this.#l[t]={sync:[],async:[]});let e=this.#l[t];null!=i.sync&&(e.sync=[...e.sync??[],...i.sync]),null!=i.async&&(e.async=[...e.async??[],...i.async])}}this.#o.info("engine initialized with {count} plugin(s)",{count:this.#d.length})}get did(){return this.#r.id}get identity(){return this.#r}get eventBus(){return this.#s}async dispose(){await Promise.all(this.#d.filter(e=>null!=e.dispose).map(e=>e.dispose?.()))}getGraphQLSource(e){let{graphID:t}=e,i=e.getViewerDID??(()=>this.#r.id);return{query:async e=>await this.queryGraph({id:t,text:e.text,variables:e.variables,viewerDID:i()??void 0}),mutate:async e=>await this.mutateGraph({id:t,text:e.text,variables:e.variables,viewerDID:i()??void 0}),subscribe:e=>this.#v({graphID:t,text:e.text,variables:e.variables,viewerDID:i()??void 0})}}registerContextFactory(e){this.#t.push(e)}async #y(){return await y(this.#i)}async #D(e){var t;let i,s=e.viewerDID??this.#r.id,a=this.#x(s),n=e.stores??this.#i,r={};for(let{name:e,factory:t}of this.#e)r[e]=t(a,n);for(let e of this.#t)r={...r,...e(a,n)};return null!=e.contextExtensions&&(r={...r,...e.contextExtensions}),i={...o({store:(t={store:await y(n),viewerDID:s,eventBus:this.#s,contextExtensions:Object.keys(r).length>0?r:void 0}).store,viewerDID:t.viewerDID,events:t.eventBus}),...q},null!=t.contextExtensions?{...i,...t.contextExtensions}:i}#x(e){return{viewerDID:e}}async #b(e){return null==this.#a[e]&&(this.#a[e]=this.#y().then(async t=>{let i=await t.getGraph(e);if(null==i)throw this.#o.warn("graph {id} not found",{id:e}),delete this.#a[e],Error(`Graph not found: ${e}`);return this.#o.debug("cached model for graph {id}",{id:e}),{aliases:i.aliases,record:i.record,extensionSDL:i.extension_sdl??void 0,pluginConfig:i.plugin_config??void 0}})),await this.#a[e]}async getGraphQLSchema(e){return null==this.#p[e]&&(this.#p[e]=this.#b(e).then(t=>{let i;if(null!=t.pluginConfig){let e={},s={},a={},n={},r={};for(let[i,o]of Object.entries(t.pluginConfig)){let t=this.#u.get(i);if(null!=t){let i=t(o);if(Object.assign(e,i.resolvers.queryFields??{}),Object.assign(s,i.resolvers.mutationFields??{}),Object.assign(a,i.resolvers.subscriptionFields??{}),Object.assign(r,i.resolvers.nodeResolvers??{}),null!=i.resolvers.typeFields)for(let[e,t]of Object.entries(i.resolvers.typeFields))n[e]={...n[e],...t}}}i={queryFields:e,mutationFields:s,subscriptionFields:a,typeFields:n,nodeResolvers:r}}let s=l({...t,extensionResolvers:i});return this.#o.debug("cached schema for graph {id}",{id:e}),s}).catch(t=>{throw delete this.#p[e],t})),await this.#p[e]}async #I(e,t){let i=t??E(e.text);if(null==i.definitions[0])throw Error("Missing GraphQL document definition");return{schema:await this.getGraphQLSchema(e.graphID),document:i,contextValue:await this.#D(e),variableValues:e.variables}}#E(e){let t=e.definitions[0];return null!=t&&t.kind===I.OPERATION_DEFINITION?t.operation:"query"}async #M(e,t){let i=await C(this.#l,e,{viewerDID:t});return"issues"in i&&null!=i.issues&&i.issues.length>0?{errors:i.issues.map(e=>new b(e.message,{extensions:null!=e.path?{path:e.path}:void 0}))}:null}async listGraphs(){let e=await this.#y();return{graphs:(await e.listGraphs()).map(e=>({id:e.id,name:e.name}))}}async loadGraph(e){let t=await this.#b(e.id);return{aliases:t.aliases,record:t.record,extensionSDL:t.extensionSDL}}async deployGraph(e){let t,i,s={};for(let t of e.clusters)Object.assign(s,g(t));let a=f.fromClusters({clusters:s});if(null!=e.plugins){i=e.plugins;let s=[];for(let[e,t]of Object.entries(i)){let i=this.#u.get(e);if(null==i)throw Error(`Plugin "${e}" not found or does not provide schema extensions`);let a=i(t);s.push(a.sdl)}s.length>0&&(t=s.join("\n"))}let n=await this.#y(),r=await n.createGraph({id:e.id??this.#h.getRandomID(),name:e.name,record:a.record,extensionSDL:t,pluginConfig:i}),o=a.toJSON();return this.#a[r]=Promise.resolve({...o,extensionSDL:t,pluginConfig:i}),delete this.#p[r],this.#o.info("deployed graph {id}",{id:r}),{id:r,...o}}async #G(e,t){let i="local"===t?"engine:mutation:authored":"engine:mutation:received",s=e.filter(e=>!e.dropped),a=new Map,n=[];for(let e of s){let t=function(e){if(null==e.document)throw Error("Cannot classify a dropped mutation result");return null==e.previousDoc?"create":null===e.document.data?"remove":"update"}(e);n.push(t);let i=e.document.id,s=a.get(i);null==s&&(s={docID:i,hasCreate:!1,hasUpdate:!1,hasRemove:!1,lastUpdateResult:null,lastRemoveResult:null,lastResult:e},a.set(i,s)),s.lastResult=e,"create"===t?s.hasCreate=!0:"update"===t?(s.hasUpdate=!0,s.lastUpdateResult=e):(s.hasRemove=!0,s.lastRemoveResult=e)}for(let e of a.values()){if(e.hasCreate&&e.hasRemove)continue;let t=e.lastResult.document;if(e.hasRemove){let t=e.lastRemoveResult??e.lastResult,i=t.document;await this.#s.emit("engine:document:removed",{documentID:i.id,viewerDID:t.authorDID});continue}if(e.hasCreate&&await this.#s.emit("engine:document:created",{documentID:t.id,modelID:t.model,viewerDID:e.lastResult.authorDID}),e.hasUpdate){let t=e.lastUpdateResult??e.lastResult,i=t.document;await this.#s.emit("engine:document:updated",{documentID:i.id,modelID:i.model,viewerDID:t.authorDID})}let i={type:e.hasCreate&&!e.hasUpdate?"create":"update",document:t,previous:{data:e.lastResult.previousDoc?.data??null},getCursor:()=>t.id};await this.#s.emit("document:saved",i)}for(let e=0;e<s.length;e++){let t=s[e],a=t.document;await this.#s.emit(i,{type:n[e],documentID:a.id,viewerDID:t.authorDID,mutationJWT:t.token,version:t.mutation.hlc,modelID:a.model})}}async #f(e,i){let{token:s,accessGate:a}=e,r=e.origin??"local",o=t(k,(await n(s)).payload),l=null!=i?await y(i):await this.#y(),u={store:l,validators:this.#m,hlc:this.#n,postStateGate:a},c=await l.getDocument(d.fromString(o.sub)),p=await h(u,o),m=R(s),g=o.iss;if(null==p)return{document:null,previousDoc:c,mutation:o,authorDID:g,hash:m,token:s,dropped:!0};await l.insertMutationLogEntry({mutation_hash:m,model_id:p.model,document_id:p.id,author_did:g,hlc:o.hlc,mutation_jwt:s,status:"applied"});let v={document:p,previousDoc:c,mutation:o,authorDID:g,hash:m,token:s,dropped:!1};return null==i&&await this.#G([v],r),v}async #w(e){let{tokens:i,accessGate:s}=e,a=e.origin??"local",r=await Promise.all(i.map(async e=>{let i=t(k,(await n(e)).payload);return{token:e,mutation:i}})),o=[];return await this.#i.withTransaction(async e=>{let t=await e.getStore(w);for(let{token:e,mutation:i}of r){let a={store:t,validators:this.#m,hlc:this.#n,postStateGate:s},n=await t.getDocument(d.fromString(i.sub)),r=await h(a,i),l=R(e);if(null==r){o.push({document:null,previousDoc:n,mutation:i,authorDID:i.iss,hash:l,token:e,dropped:!0});continue}await t.insertMutationLogEntry({mutation_hash:l,model_id:r.model,document_id:r.id,author_did:i.iss,hlc:i.hlc,mutation_jwt:e,status:"applied"}),o.push({document:r,previousDoc:n,mutation:i,authorDID:i.iss,hash:l,token:e,dropped:!1})}}),await this.#G(o,a),{results:o,dropped:o.filter(e=>e.dropped).length}}async #g(e){let t=E(e.text),i=this.#E(t),s=await this.#M(i,e.viewerDID??this.#r.id);if(null!=s)return s;let a=await this.#I(e,t);return await x(a)}async #v(e){let t=await this.#I(e);return await M(t)}async queryGraph(e){let t=E(e.text);if("mutation"===this.#E(t))throw Error("queryGraph() does not accept mutation operations. Use mutateGraph() instead.");return await this.#g({graphID:e.id,text:e.text,variables:e.variables??{},viewerDID:e.viewerDID})}async mutateGraph(e){if(!s(this.#r))throw Error("mutateGraph requires a SigningIdentity");let t=this.#r,i=e.id,n=[],r=await this.#i.withTransaction(async s=>{let r=async e=>{let i=a(await t.signToken(e)),r=await this.#f({token:i},s);if(n.push(r),null==r.document)throw Error("Unexpected dropped mutation in mutateGraph (no gate configured)");return r.document},o=m({issuer:t.id,hlc:this.#n,getRandomValues:this.#h.getRandomValues,processSetMutation:e=>r(e),processChangeMutation:e=>r(e)});return await this.#g({graphID:i,text:e.text,variables:e.variables??{},viewerDID:e.viewerDID??t.id,stores:s,contextExtensions:{executeCreateMutation:async e=>await o.createDocument({modelID:e.modelID,data:e.data}),executeSetMutation:async e=>await o.setDocument({modelID:e.modelID,unique:e.unique,data:e.data}),executeUpdateMutation:async e=>await o.updateDocument({docID:e.input.id,patch:p(e.input.patch)}),executeRemoveMutation:async e=>{await o.removeDocument({docID:e.id})}}})});return await this.#G(n,"local"),r}async subscribeToGraph(e){return await this.#v({graphID:e.id,text:e.text,variables:e.variables??{},viewerDID:e.viewerDID})}async getAPI(e){return await this.#c.getAPI(e)}}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type { AccessChecker, AccessControlDB, AccessLevel, AccessPermissions, AccessRule, DefaultAccessLevel, } from './access-control.js';
|
|
2
|
-
export { createAccessChecker, parseDocumentAccessPermissions, validateDIDs, } from './access-control.js';
|
|
3
|
-
export type { ApplyVerifiedMutationParams, ApplyVerifiedMutationResult, ApplyVerifiedMutationsParams, ApplyVerifiedMutationsResult, ContextFactory, EngineParams, ExecuteParams, ExecutionContext, } from './engine.js';
|
|
2
|
+
export { createAccessChecker, parseDocumentAccessPermissions, resolveAccessRule, validateDIDs, validateID, validateIDs, } from './access-control.js';
|
|
3
|
+
export type { AccessGate, ApplyVerifiedMutationParams, ApplyVerifiedMutationResult, ApplyVerifiedMutationsParams, ApplyVerifiedMutationsResult, ContextFactory, EngineParams, ExecuteParams, ExecutionContext, } from './engine.js';
|
|
4
4
|
export { KubunEngine } from './engine.js';
|
|
5
5
|
export type { EngineEvents } from './engine-events.js';
|
|
6
6
|
export { EngineEventBus } from './events.js';
|
package/lib/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export{createAccessChecker,parseDocumentAccessPermissions,validateDIDs}from"./access-control.js";export{KubunEngine}from"./engine.js";export{EngineEventBus}from"./events.js";export{computeMutationHash}from"./mutation-hash.js";export{runPolicies}from"./policies.js";export{createRegistry}from"./registry.js";
|
|
1
|
+
export{createAccessChecker,parseDocumentAccessPermissions,resolveAccessRule,validateDIDs,validateID,validateIDs}from"./access-control.js";export{KubunEngine}from"./engine.js";export{EngineEventBus}from"./events.js";export{computeMutationHash}from"./mutation-hash.js";export{runPolicies}from"./policies.js";export{createRegistry}from"./registry.js";
|
package/lib/plugin.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { HLC } from '@kubun/hlc';
|
|
|
6
6
|
import type { Logger } from '@kubun/logger';
|
|
7
7
|
import type { ExecutionResult } from 'graphql';
|
|
8
8
|
import type { ApplyVerifiedMutationParams, ApplyVerifiedMutationResult, ApplyVerifiedMutationsParams, ApplyVerifiedMutationsResult, ContextFactory, ExecuteParams } from './engine.js';
|
|
9
|
+
import type { EngineEvents } from './engine-events.js';
|
|
9
10
|
import type { EngineEventBus } from './events.js';
|
|
10
11
|
import type { Engine } from './executor.js';
|
|
11
12
|
import type { PolicyGateMap } from './policies.js';
|
|
@@ -35,7 +36,7 @@ export type PluginFactoryParams = {
|
|
|
35
36
|
db: KubunDB;
|
|
36
37
|
runtime: Runtime;
|
|
37
38
|
identity: Identity;
|
|
38
|
-
eventBus: EngineEventBus
|
|
39
|
+
eventBus: EngineEventBus<EngineEvents>;
|
|
39
40
|
hlc: HLC;
|
|
40
41
|
getLogger: (name: string) => Logger;
|
|
41
42
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"license": "see LICENSE.md",
|
|
5
5
|
"keywords": [],
|
|
6
6
|
"type": "module",
|
|
@@ -15,29 +15,29 @@
|
|
|
15
15
|
],
|
|
16
16
|
"sideEffects": false,
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@enkaku/async": "^0.
|
|
19
|
-
"@enkaku/capability": "^0.
|
|
20
|
-
"@enkaku/event": "^0.
|
|
21
|
-
"@enkaku/runtime": "^0.
|
|
22
|
-
"@enkaku/schema": "^0.
|
|
23
|
-
"@enkaku/token": "0.
|
|
18
|
+
"@enkaku/async": "^0.15.0",
|
|
19
|
+
"@enkaku/capability": "^0.15.0",
|
|
20
|
+
"@enkaku/event": "^0.15.0",
|
|
21
|
+
"@enkaku/runtime": "^0.15.0",
|
|
22
|
+
"@enkaku/schema": "^0.15.0",
|
|
23
|
+
"@enkaku/token": "^0.15.0",
|
|
24
24
|
"@noble/hashes": "^2.2.0",
|
|
25
25
|
"graphql": "^16.13.2",
|
|
26
|
-
"@kubun/db": "^0.
|
|
27
|
-
"@kubun/
|
|
28
|
-
"@kubun/
|
|
29
|
-
"@kubun/
|
|
30
|
-
"@kubun/logger": "^0.
|
|
31
|
-
"@kubun/
|
|
32
|
-
"@kubun/
|
|
33
|
-
"@kubun/
|
|
26
|
+
"@kubun/db": "^0.9.0",
|
|
27
|
+
"@kubun/graphql": "^0.9.0",
|
|
28
|
+
"@kubun/db-adapter": "^0.9.0",
|
|
29
|
+
"@kubun/protocol": "^0.9.0",
|
|
30
|
+
"@kubun/logger": "^0.9.0",
|
|
31
|
+
"@kubun/hlc": "^0.9.0",
|
|
32
|
+
"@kubun/mutation": "^0.9.0",
|
|
33
|
+
"@kubun/store-graph": "^0.9.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@testcontainers/postgresql": "^11.14.0",
|
|
37
|
-
"@kubun/
|
|
38
|
-
"@kubun/
|
|
39
|
-
"@kubun/
|
|
40
|
-
"@kubun/test-utils": "^0.
|
|
37
|
+
"@kubun/id": "^0.9.0",
|
|
38
|
+
"@kubun/db-postgres": "^0.9.0",
|
|
39
|
+
"@kubun/store-p2p": "^0.9.0",
|
|
40
|
+
"@kubun/test-utils": "^0.9.0"
|
|
41
41
|
},
|
|
42
42
|
"scripts": {
|
|
43
43
|
"build:clean": "del lib",
|