@kubun/engine 0.9.0 → 0.10.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 +18 -0
- package/lib/access-control.js +1 -1
- package/lib/catalog-match.d.ts +12 -0
- package/lib/catalog-match.js +1 -0
- package/lib/engine.d.ts +35 -3
- package/lib/engine.js +1 -1
- package/lib/executor.d.ts +8 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +1 -1
- package/lib/membership-check.d.ts +45 -0
- package/lib/membership-check.js +1 -0
- package/package.json +20 -20
package/lib/access-control.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type VerifyTokenHook } from '@enkaku/capability';
|
|
1
2
|
import type { DocumentNode } from '@kubun/protocol';
|
|
2
3
|
import type { AccessLevel, StoredAccessRule } from '@kubun/store-graph';
|
|
3
4
|
/**
|
|
@@ -8,6 +9,22 @@ export type AccessControlDB = {
|
|
|
8
9
|
getUserModelAccessDefault(ownerDID: string, modelID: string, permissionType: 'read' | 'write'): Promise<StoredAccessRule | null>;
|
|
9
10
|
isMemberOfAnyCircle(viewerDID: string, circleIDs: Array<string>): Promise<boolean>;
|
|
10
11
|
isMemberOfAnyGroup(viewerDID: string, groupIDs: Array<string>): Promise<boolean>;
|
|
12
|
+
/**
|
|
13
|
+
* Return the interface model IDs implemented by `modelID`.
|
|
14
|
+
*
|
|
15
|
+
* Used by delegation resource enumeration so a capability granted on an
|
|
16
|
+
* interface (e.g. `urn:kubun:model:<interfaceID>`) authorizes mutations on
|
|
17
|
+
* any concrete model that declares it. Returns an empty array for models
|
|
18
|
+
* that implement nothing.
|
|
19
|
+
*/
|
|
20
|
+
getModelInterfaces(modelID: string): Promise<Array<string>>;
|
|
21
|
+
/**
|
|
22
|
+
* Optional per-leaf-capability verification hook invoked by
|
|
23
|
+
* `checkCapability`. When set, capabilities whose `jti` has been revoked
|
|
24
|
+
* cause the access check to fail closed. Left undefined on deployments
|
|
25
|
+
* without a P2P store (e.g. light clients) where no revocation state exists.
|
|
26
|
+
*/
|
|
27
|
+
revocationChecker?: VerifyTokenHook;
|
|
11
28
|
};
|
|
12
29
|
export type { AccessLevel };
|
|
13
30
|
export type AccessRule = {
|
|
@@ -73,4 +90,5 @@ export declare function createAccessChecker(params: {
|
|
|
73
90
|
delegationTokens?: Array<string>;
|
|
74
91
|
db: AccessControlDB;
|
|
75
92
|
defaultAccessLevel: DefaultAccessLevel;
|
|
93
|
+
atTime?: number;
|
|
76
94
|
}): AccessChecker;
|
package/lib/access-control.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{checkCapability as e}from"@enkaku/capability";function
|
|
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)}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CatalogFilterCriteria, DocumentNode } from '@kubun/protocol';
|
|
2
|
+
import type { P2PStoreAPI } from '@kubun/store-p2p';
|
|
3
|
+
/**
|
|
4
|
+
* Determine whether a document matches a catalog's filter criteria.
|
|
5
|
+
*
|
|
6
|
+
* Each catalog field is AND'd, values within a field are OR'd, and the
|
|
7
|
+
* `circles` field expands to a union of circle members and merges with the
|
|
8
|
+
* explicit `owners` filter.
|
|
9
|
+
*
|
|
10
|
+
* Returns `true` iff the document would be included by this catalog.
|
|
11
|
+
*/
|
|
12
|
+
export declare function catalogMatchesDoc(criteria: CatalogFilterCriteria, doc: Pick<DocumentNode, 'model' | 'owner'>, p2pStore: P2PStoreAPI): Promise<boolean>;
|
|
@@ -0,0 +1 @@
|
|
|
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))}
|
package/lib/engine.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type VerifyTokenHook } from '@enkaku/capability';
|
|
1
2
|
import { type Runtime } from '@enkaku/runtime';
|
|
2
3
|
import type { Identity } from '@enkaku/token';
|
|
3
4
|
import { KubunDB, type StoreProvider } from '@kubun/db';
|
|
@@ -5,11 +6,30 @@ import type { Adapter } from '@kubun/db-adapter';
|
|
|
5
6
|
import { type Logger } from '@kubun/logger';
|
|
6
7
|
import type { DeployGraphParams, DeployGraphResult, DocumentNode, ExecuteGraphResult, ListGraphResult, LoadGraphParams, LoadGraphResult } from '@kubun/protocol';
|
|
7
8
|
import { type DocumentMutation } from '@kubun/protocol';
|
|
9
|
+
import { type GraphStoreAPI } from '@kubun/store-graph';
|
|
8
10
|
import type { GraphQLSchema } from 'graphql';
|
|
11
|
+
import { type DefaultAccessLevel } from './access-control.js';
|
|
9
12
|
import type { EngineEvents } from './engine-events.js';
|
|
10
13
|
import { EngineEventBus } from './events.js';
|
|
11
14
|
import type { Engine, EngineGraphParams, GraphQLSource, GraphQLSourceParams } from './executor.js';
|
|
12
15
|
import type { KubunPlugin, PluginFactoryParams } from './plugin.js';
|
|
16
|
+
/**
|
|
17
|
+
* Build a write-access checker closure bound to a graph store. When provided,
|
|
18
|
+
* `extraTokensByIssuer` supplies additional delegation tokens held locally for
|
|
19
|
+
* a given issuer DID; they are unioned with the mutation's inline `cap` so
|
|
20
|
+
* server-side application can authorize a write whose held credentials live in
|
|
21
|
+
* the receiving peer's p2p store rather than on the wire.
|
|
22
|
+
*
|
|
23
|
+
* Exported (without `#`) so unit tests can construct the checker with a
|
|
24
|
+
* hand-built map and exercise the union math directly. The Engine method
|
|
25
|
+
* `#buildWriteAccessChecker` delegates to this helper.
|
|
26
|
+
*/
|
|
27
|
+
export declare function buildWriteAccessChecker(params: {
|
|
28
|
+
store: GraphStoreAPI;
|
|
29
|
+
defaultAccessLevel: DefaultAccessLevel;
|
|
30
|
+
extraTokensByIssuer?: Map<string, Array<string>>;
|
|
31
|
+
revocationChecker?: VerifyTokenHook;
|
|
32
|
+
}): (doc: DocumentNode, mutation: DocumentMutation) => Promise<boolean>;
|
|
13
33
|
export type { Engine };
|
|
14
34
|
/**
|
|
15
35
|
* Per-request execution context provided to plugin operations.
|
|
@@ -32,6 +52,13 @@ export type EngineParams = {
|
|
|
32
52
|
/** Plugin factories — called with shared params, return plugin descriptors. */
|
|
33
53
|
plugins?: Array<(params: PluginFactoryParams) => KubunPlugin>;
|
|
34
54
|
eventBus?: EngineEventBus<EngineEvents>;
|
|
55
|
+
/**
|
|
56
|
+
* Server default access level applied when a document carries no override and
|
|
57
|
+
* its owner has set no model default. Defaults to open read
|
|
58
|
+
* (`{ read: 'anyone', write: 'only_owner' }`) so existing callers keep their
|
|
59
|
+
* current open-read behavior.
|
|
60
|
+
*/
|
|
61
|
+
defaultAccessLevel?: DefaultAccessLevel;
|
|
35
62
|
};
|
|
36
63
|
/**
|
|
37
64
|
* Optional pre-persist gate. Receives the synthesized post-apply
|
|
@@ -41,9 +68,9 @@ export type EngineParams = {
|
|
|
41
68
|
* and report the mutation as `dropped: true`. Returning `true` (or no gate
|
|
42
69
|
* provided) lets the existing apply flow proceed.
|
|
43
70
|
*
|
|
44
|
-
* The gate is intentionally generic — it is not access-control-aware.
|
|
45
|
-
* composes `resolveAccessRule`/`checkAccess`
|
|
46
|
-
*
|
|
71
|
+
* The gate is intentionally generic — it is not access-control-aware. The
|
|
72
|
+
* receive wiring composes `resolveAccessRule`/`checkAccess` and passes the
|
|
73
|
+
* resulting decision through this hook.
|
|
47
74
|
*/
|
|
48
75
|
export type AccessGate = (postState: DocumentNode) => boolean | Promise<boolean>;
|
|
49
76
|
export type ApplyVerifiedMutationParams = {
|
|
@@ -60,6 +87,11 @@ export type ApplyVerifiedMutationParams = {
|
|
|
60
87
|
* On deny: no DB write, no events, result returns `dropped: true`.
|
|
61
88
|
*/
|
|
62
89
|
accessGate?: AccessGate;
|
|
90
|
+
/**
|
|
91
|
+
* MLS group the mutation was delivered through, on the broadcast receive
|
|
92
|
+
* path; absent on merkle/RPC where the doc's groups are resolved by walk.
|
|
93
|
+
*/
|
|
94
|
+
arrivalGroupID?: string;
|
|
63
95
|
};
|
|
64
96
|
export type ApplyVerifiedMutationResult = {
|
|
65
97
|
/** The resulting document after mutation. Null when the gate denied — the
|
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 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)}}
|
|
1
|
+
import{createRevocationChecker as e}from"@enkaku/capability";import{createRuntime as t}from"@enkaku/runtime";import{asType as i,createValidator as s}from"@enkaku/schema";import{isSigningIdentity as r,stringifyToken as a,verifyToken as n}from"@enkaku/token";import{KubunDB as o}from"@kubun/db";import{createReadContext as l,createSchema as u}from"@kubun/graphql";import{HLC as c}from"@kubun/hlc";import{DocumentID as d}from"@kubun/id";import{getKubunLogger as h}from"@kubun/logger";import{applyMutation as p,convertPatchInput as m,createMutationOperations as g,WriteAccessDeniedError as f}from"@kubun/mutation";import{clusterToRecord as v,documentMutation as w,GraphModel as y}from"@kubun/protocol";import{GRAPH_STORE as D,getGraphStore as b,graphStoreDefinition as x}from"@kubun/store-graph";import{createP2PRevocationBackend as I,getP2PStore as M}from"@kubun/store-p2p";import{execute as A,GraphQLError as C,Kind as G,parse as k,subscribe as E}from"graphql";import{createAccessChecker as R}from"./access-control.js";import{EngineEventBus as S}from"./events.js";import{checkMembership as L}from"./membership-check.js";import{computeMutationHash as O}from"./mutation-hash.js";import{runPolicies as F}from"./policies.js";import{createRegistry as T}from"./registry.js";let j=s(w);function U(e){if(null==e)return;let t=c.parse(e).wallTime;return Number.isFinite(t)?Math.floor(t/1e3):void 0}export function buildWriteAccessChecker(e){let{store:t,defaultAccessLevel:i,extraTokensByIssuer:s,revocationChecker:r}=e,a=new Map,n={getUserModelAccessDefault:(e,i,s)=>t.getUserModelAccessDefault(e,i,s),isMemberOfAnyGroup:async()=>!1,isMemberOfAnyCircle:async()=>!1,getModelInterfaces:async e=>{let i=a.get(e);if(null!=i)return i;let s=await t.getModelInterfaces(e);return a.set(e,s),s},revocationChecker:r};return(e,t)=>{var r;let a=(null==(r=t.cap)?void 0:Array.isArray(r)?r:[r])??[],o=s?.get(t.iss)??[],l=0===a.length&&0===o.length?void 0:[...a,...o];return R({viewerDID:t.iss,delegationTokens:l,db:n,defaultAccessLevel:i,atTime:U(t.hlc)})(e,"write")}}function q(){throw Error("Graph mutations require mutateGraph() — the core context does not sign or log writes. Use KubunEngine.mutateGraph() instead.")}function P(){throw Error("Access control mutations are not supported by the engine core — plugin override required.")}function B(){throw Error("Transaction mutations are not supported by the engine core — plugin override required (see plugin-rpc).")}let _={executeCreateMutation:q,executeSetMutation:q,executeUpdateMutation:q,executeRemoveMutation:q,executeSetModelAccessDefaults:P,executeRemoveModelAccessDefaults:P,executeSetDocumentAccessOverride:P,executeRemoveDocumentAccessOverride:P,beginTransaction:B,commitTransaction:B,rollbackTransaction:B};export class KubunEngine{#e=[];#t=[];#i;#s;#r;#a={};#n;#o;#l;#u={};#c=new Map;#d=[];#h;#p;#m={};#g={};constructor(e){this.#i=e.db instanceof o?e.db:new o({adapter:e.db}),this.#i.register(x),this.#o=e.identity,this.#s=e.defaultAccessLevel??{read:"anyone",write:"only_owner"},this.#n=new c({nodeID:e.identity.id}),this.#l=e.logger??h("engine"),this.#r=e.eventBus??new S({logger:this.#l.getChild("events")}),this.#p=t(e.runtime),this.#h=T();let i=e.plugins??[],s={engine:this,graph:{execute:e=>this.#f(e),subscribe:e=>this.#v(e),applyVerifiedMutation:e=>this.#w(e),applyVerifiedMutations:e=>this.#y(e)},db:this.#i,runtime:this.#p,identity:this.#o,eventBus:this.#r,hlc:this.#n,getLogger:e=>this.#l.getChild(e)};for(let e of i){let t=e(s);this.#d.push(t),null!=t.api&&this.#h.registerPlugin(t.name,t.api)}this.#h.closeGate();let r=new Set;for(let e of this.#d){if(null!=e.schemaExtension&&this.#c.set(e.name,e.schemaExtension),null!=e.createContextFactory){if(r.has(e.name))throw Error(`Duplicate plugin namespace: ${e.name}`);r.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.#u[t]&&(this.#u[t]={sync:[],async:[]});let e=this.#u[t];null!=i.sync&&(e.sync=[...e.sync??[],...i.sync]),null!=i.async&&(e.async=[...e.async??[],...i.async])}}this.#l.info("engine initialized with {count} plugin(s)",{count:this.#d.length})}get did(){return this.#o.id}get identity(){return this.#o}get eventBus(){return this.#r}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.#o.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 #D(){return await b(this.#i)}async #b(t){var i;let s,r,a,n,o=t.viewerDID??this.#o.id,u=this.#x(o),c=t.stores??this.#i,d={};for(let{name:e,factory:t}of this.#e)d[e]=t(u,c);for(let e of this.#t)d={...d,...e(u,c)};null!=t.contextExtensions&&(d={...d,...t.contextExtensions});let h=await b(c);try{s=await M(c)}catch{s=void 0}if(null!=s){let t=s;r={getUserModelAccessDefault:(e,t,i)=>h.getUserModelAccessDefault(e,t,i),isMemberOfAnyGroup:(e,i)=>t.isMemberOfAnyGroup(e,i),isMemberOfAnyCircle:(e,i)=>t.isMemberOfAnyCircle(e,i),getModelInterfaces:e=>h.getModelInterfaces(e),revocationChecker:e(I(t))};let[i,n]=await Promise.all([t.getGroupsForMember(o),t.getCirclesForMember(o)]);a={viewerDID:o,groupIDs:i.map(e=>e.id),circleIDs:n.map(e=>e.id),serverDefault:this.#s.read}}else r={getUserModelAccessDefault:(e,t,i)=>h.getUserModelAccessDefault(e,t,i),isMemberOfAnyGroup:async()=>!1,isMemberOfAnyCircle:async()=>!1,getModelInterfaces:e=>h.getModelInterfaces(e)},a={viewerDID:o,groupIDs:[],circleIDs:[],serverDefault:this.#s.read};let p=R({viewerDID:o,db:r,defaultAccessLevel:this.#s});return n={...l({store:(i={store:h,viewerDID:o,eventBus:this.#r,accessChecker:p,viewerReadAccess:a,contextExtensions:Object.keys(d).length>0?d:void 0}).store,viewerDID:i.viewerDID,events:i.eventBus,accessChecker:i.accessChecker,viewerReadAccess:i.viewerReadAccess}),..._},null!=i.contextExtensions?{...n,...i.contextExtensions}:n}#x(e){return{viewerDID:e}}async #I(e){return null==this.#a[e]&&(this.#a[e]=this.#D().then(async t=>{let i=await t.getGraph(e);if(null==i)throw this.#l.warn("graph {id} not found",{id:e}),delete this.#a[e],Error(`Graph not found: ${e}`);return this.#l.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.#m[e]&&(this.#m[e]=this.#I(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.#c.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=u({...t,extensionResolvers:i});return this.#l.debug("cached schema for graph {id}",{id:e}),s}).catch(t=>{throw delete this.#m[e],t})),await this.#m[e]}async #M(e,t){let i=t??k(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.#b(e),variableValues:e.variables}}#A(e){let t=e.definitions[0];return null!=t&&t.kind===G.OPERATION_DEFINITION?t.operation:"query"}async #C(e,t){let i=await F(this.#u,e,{viewerDID:t});return"issues"in i&&null!=i.issues&&i.issues.length>0?{errors:i.issues.map(e=>new C(e.message,{extensions:null!=e.path?{path:e.path}:void 0}))}:null}async listGraphs(){let e=await this.#D();return{graphs:(await e.listGraphs()).map(e=>({id:e.id,name:e.name}))}}async loadGraph(e){let t=await this.#I(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,v(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.#c.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.#D(),n=await a.createGraph({id:e.id??this.#p.getRandomID(),name:e.name,record:r.record,extensionSDL:t,pluginConfig:i}),o=r.toJSON();return this.#a[n]=Promise.resolve({...o,extensionSDL:t,pluginConfig:i}),delete this.#m[n],this.#l.info("deployed graph {id}",{id:n}),{id:n,...o}}async #G(e,t){let i="local"===t?"engine:mutation:authored":"engine:mutation:received",s=e.filter(e=>!e.dropped),r=new Map,a=[];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);a.push(t);let i=e.document.id,s=r.get(i);null==s&&(s={docID:i,hasCreate:!1,hasUpdate:!1,hasRemove:!1,lastUpdateResult:null,lastRemoveResult:null,lastResult:e},r.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 r.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.#r.emit("engine:document:removed",{documentID:i.id,viewerDID:t.authorDID});continue}if(e.hasCreate&&await this.#r.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.#r.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.#r.emit("document:saved",i)}for(let e=0;e<s.length;e++){let t=s[e],r=t.document;await this.#r.emit(i,{type:a[e],documentID:r.id,viewerDID:t.authorDID,mutationJWT:t.token,version:t.mutation.hlc,modelID:r.model})}}#k(e,t,i){return buildWriteAccessChecker({store:e,defaultAccessLevel:this.#s,extraTokensByIssuer:t,revocationChecker:i})}async #w(t,s){let r,a,{token:o,accessGate:l}=t,u=t.origin??"local",c=i(j,(await n(o)).payload),h=null!=s?await b(s):await this.#D(),m=s??this.#i,g=U(c.hlc)??Math.floor(Date.now()/1e3),v=new Map;try{a=await M(m);let t=await a.getHeldTokens({audience:c.iss,atTime:g});t.length>0&&v.set(c.iss,t.map(e=>e.token)),r=e(I(a))}catch{a=void 0}let w={store:h,validators:this.#g,hlc:this.#n,checkWriteAccess:this.#k(h,v,r),postStateGate:l},y=await h.getDocument(d.fromString(c.sub));if(null!=a&&null!=y&&y.owner!==c.iss){let e=await L({p2pStore:a,graphStore:h,selfDID:this.did,iss:c.iss,mutationHLC:c.hlc,doc:y,arrivalGroupID:t.arrivalGroupID});if(!e.ok){if(this.#l.warn("mutation denied: issuer removed from group",{iss:c.iss,docID:y.id,mutationHLC:c.hlc,removedGroupID:e.removedGroupID,removedAtHLC:e.removedAtHLC,origin:u}),"peer"===u)return{document:null,previousDoc:y,mutation:c,authorDID:c.iss,hash:O(o),token:o,dropped:!0};throw new f(y.id)}}let D=await p(w,c),x=O(o),A=c.iss;if(null==D)return{document:null,previousDoc:y,mutation:c,authorDID:A,hash:x,token:o,dropped:!0};await h.insertMutationLogEntry({mutation_hash:x,model_id:D.model,document_id:D.id,author_did:A,hlc:c.hlc,mutation_jwt:o,status:"applied"});let C={document:D,previousDoc:y,mutation:c,authorDID:A,hash:x,token:o,dropped:!1};return null==s&&await this.#G([C],u),C}async #y(t){let{tokens:s,accessGate:r}=t,a=t.origin??"local",o=await Promise.all(s.map(async e=>{let t=i(j,(await n(e)).payload);return{token:e,mutation:t}})),l=[];return await this.#i.withTransaction(async t=>{let i,s,n=await t.getStore(D),u=new Map,c=new Map,h=new Set,m=Math.floor(Date.now()/1e3);for(let{mutation:e}of o){let t=U(e.hlc);if(null==t){h.add(e.iss);continue}let i=c.get(e.iss);(null==i||t<i)&&c.set(e.iss,t)}for(let r of new Set(o.map(e=>e.mutation.iss))){let a=c.get(r),n=h.has(r)||null==a?Math.min(a??m,m):a;try{let a=await M(t);s=a;let o=await a.getHeldTokens({audience:r,atTime:n});o.length>0&&u.set(r,o.map(e=>e.token)),i??=e(I(a))}catch{}}let g=this.#k(n,u,i);for(let{token:e,mutation:t}of o){let i={store:n,validators:this.#g,hlc:this.#n,checkWriteAccess:g,postStateGate:r},o=await n.getDocument(d.fromString(t.sub));if(null!=s&&null!=o&&o.owner!==t.iss){let e=await L({p2pStore:s,graphStore:n,selfDID:this.did,iss:t.iss,mutationHLC:t.hlc,doc:o,arrivalGroupID:void 0});if(!e.ok)throw this.#l.warn("mutation denied: issuer removed from group",{iss:t.iss,docID:o.id,mutationHLC:t.hlc,removedGroupID:e.removedGroupID,removedAtHLC:e.removedAtHLC,origin:a}),new f(o.id)}let u=await p(i,t),c=O(e);if(null==u){l.push({document:null,previousDoc:o,mutation:t,authorDID:t.iss,hash:c,token:e,dropped:!0});continue}await n.insertMutationLogEntry({mutation_hash:c,model_id:u.model,document_id:u.id,author_did:t.iss,hlc:t.hlc,mutation_jwt:e,status:"applied"}),l.push({document:u,previousDoc:o,mutation:t,authorDID:t.iss,hash:c,token:e,dropped:!1})}}),await this.#G(l,a),{results:l,dropped:l.filter(e=>e.dropped).length}}async #f(e){let t=k(e.text),i=this.#A(t),s=await this.#C(i,e.viewerDID??this.#o.id);if(null!=s)return s;let r=await this.#M(e,t);return await A(r)}async #v(e){let t=await this.#M(e);return await E(t)}async queryGraph(e){let t=k(e.text);if("mutation"===this.#A(t))throw Error("queryGraph() does not accept mutation operations. Use mutateGraph() instead.");return await this.#f({graphID:e.id,text:e.text,variables:e.variables??{},viewerDID:e.viewerDID})}async mutateGraph(e){if(!r(this.#o))throw Error("mutateGraph requires a SigningIdentity");let t=this.#o,i=e.id;await this.getGraphQLSchema(i);let s=[],n=await this.#i.withTransaction(async r=>{let n=async e=>{let i=a(await t.signToken(e)),n=await this.#w({token:i},r);if(s.push(n),null==n.document)throw Error("Unexpected dropped mutation in mutateGraph (no gate configured)");return n.document},o=Math.floor(Date.now()/1e3),l=new Set(e.delegationTokens??[]);try{let e=await M(r);for(let i of(await e.getHeldTokens({audience:t.id,atTime:o})))l.add(i.token)}catch{}let u=l.size>0?Array.from(l):void 0,c=g({issuer:t.id,hlc:this.#n,getRandomValues:this.#p.getRandomValues,cap:u,processSetMutation:e=>n(e),processChangeMutation:e=>n(e)});return await this.#f({graphID:i,text:e.text,variables:e.variables??{},viewerDID:e.viewerDID??t.id,stores:r,contextExtensions:{executeCreateMutation:async e=>await c.createDocument({modelID:e.modelID,data:e.data}),executeSetMutation:async e=>await c.setDocument({modelID:e.modelID,unique:e.unique,data:e.data}),executeUpdateMutation:async e=>await c.updateDocument({docID:e.input.id,patch:m(e.input.patch)}),executeRemoveMutation:async e=>{await c.removeDocument({docID:e.id})}}})});return await this.#G(s,"local"),n}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.#h.getAPI(e)}}
|
package/lib/executor.d.ts
CHANGED
|
@@ -8,6 +8,14 @@ import type { GetAPI } from './registry.js';
|
|
|
8
8
|
*/
|
|
9
9
|
export type EngineGraphParams = ExecuteGraphParams & {
|
|
10
10
|
viewerDID?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Delegation tokens authorizing writes to documents the caller does not own.
|
|
13
|
+
* Carried on each outgoing mutation's `cap` field (part of the signed payload)
|
|
14
|
+
* and verified on apply against the owner→delegate capability chain. Used by
|
|
15
|
+
* `mutateGraph` only — ignored by `queryGraph`/`subscribeToGraph`, which do
|
|
16
|
+
* not produce mutations.
|
|
17
|
+
*/
|
|
18
|
+
delegationTokens?: Array<string>;
|
|
11
19
|
};
|
|
12
20
|
export type Engine = Omit<GraphsProvider, 'queryGraph' | 'mutateGraph' | 'subscribeToGraph'> & {
|
|
13
21
|
queryGraph<Data extends Record<string, unknown> = Record<string, unknown>>(params: EngineGraphParams): Promise<ExecuteGraphResult<Data>>;
|
package/lib/index.d.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
export type { AccessChecker, AccessControlDB, AccessLevel, AccessPermissions, AccessRule, DefaultAccessLevel, } from './access-control.js';
|
|
2
2
|
export { createAccessChecker, parseDocumentAccessPermissions, resolveAccessRule, validateDIDs, validateID, validateIDs, } from './access-control.js';
|
|
3
|
+
export { catalogMatchesDoc } from './catalog-match.js';
|
|
3
4
|
export type { AccessGate, ApplyVerifiedMutationParams, ApplyVerifiedMutationResult, ApplyVerifiedMutationsParams, ApplyVerifiedMutationsResult, ContextFactory, EngineParams, ExecuteParams, ExecutionContext, } from './engine.js';
|
|
4
5
|
export { KubunEngine } from './engine.js';
|
|
5
6
|
export type { EngineEvents } from './engine-events.js';
|
|
6
7
|
export { EngineEventBus } from './events.js';
|
|
7
8
|
export type { Engine, EngineGraphParams, GraphQLOperationParams, GraphQLSource, GraphQLSourceParams, GraphQLSourceProvider, GraphsProvider, } from './executor.js';
|
|
9
|
+
export type { CheckMembershipParams, CheckMembershipResult } from './membership-check.js';
|
|
10
|
+
export { checkMembership } from './membership-check.js';
|
|
8
11
|
export { computeMutationHash } from './mutation-hash.js';
|
|
9
12
|
export type { GraphInternals, KubunPlugin, PluginFactoryParams, SchemaExtension } from './plugin.js';
|
|
10
13
|
export type { AsyncPolicyCheck, PolicyGateMap, PolicyIssue, PolicyPathSegment, PolicyResult, SyncPolicyCheck, } from './policies.js';
|
package/lib/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
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";
|
|
1
|
+
export{createAccessChecker,parseDocumentAccessPermissions,resolveAccessRule,validateDIDs,validateID,validateIDs}from"./access-control.js";export{catalogMatchesDoc}from"./catalog-match.js";export{KubunEngine}from"./engine.js";export{EngineEventBus}from"./events.js";export{checkMembership}from"./membership-check.js";export{computeMutationHash}from"./mutation-hash.js";export{runPolicies}from"./policies.js";export{createRegistry}from"./registry.js";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { DocumentNode } from '@kubun/protocol';
|
|
2
|
+
import type { GraphStoreAPI } from '@kubun/store-graph';
|
|
3
|
+
import type { P2PStoreAPI } from '@kubun/store-p2p';
|
|
4
|
+
export type CheckMembershipParams = {
|
|
5
|
+
p2pStore: P2PStoreAPI;
|
|
6
|
+
graphStore: GraphStoreAPI;
|
|
7
|
+
/** The applying peer's own DID — drives the RPC-path circle walk. */
|
|
8
|
+
selfDID: string;
|
|
9
|
+
/** Mutation issuer DID, the subject of the membership check. */
|
|
10
|
+
iss: string;
|
|
11
|
+
/** Serialized mutation HLC, compared against the recorded removal HLC. */
|
|
12
|
+
mutationHLC: string;
|
|
13
|
+
doc: Pick<DocumentNode, 'model' | 'owner'>;
|
|
14
|
+
/**
|
|
15
|
+
* Envelope group on the sync-receive path. Absent on the RPC apply path,
|
|
16
|
+
* where candidate groups are resolved via the circle walk instead.
|
|
17
|
+
*/
|
|
18
|
+
arrivalGroupID?: string;
|
|
19
|
+
};
|
|
20
|
+
export type CheckMembershipResult = {
|
|
21
|
+
ok: boolean;
|
|
22
|
+
/** Group whose removal record denied the write (set when `ok` is false). */
|
|
23
|
+
removedGroupID?: string;
|
|
24
|
+
/** Removal HLC that denied the write — recorded for forensic logging. */
|
|
25
|
+
removedAtHLC?: string;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Decide whether a mutation's issuer is still permitted to write to the doc's
|
|
29
|
+
* MLS group(s) at the mutation's HLC.
|
|
30
|
+
*
|
|
31
|
+
* A group rejects the issuer iff a membership row exists with a removal HLC
|
|
32
|
+
* that is non-null AND causally at or before the mutation's HLC: the issuer was
|
|
33
|
+
* removed before (or exactly at) the moment the mutation claims. A missing row,
|
|
34
|
+
* an active member (`removed_at_hlc === null`), or a removal recorded strictly
|
|
35
|
+
* after the mutation (a pre-removal mutation) all accept.
|
|
36
|
+
*
|
|
37
|
+
* The serialized HLC form is lexicographically ordered, so a string `<=`
|
|
38
|
+
* compare matches HLC ordering — the same convention the soft-delete LWW uses.
|
|
39
|
+
*
|
|
40
|
+
* On the receive path (`arrivalGroupID` set) only that one group is checked.
|
|
41
|
+
* On the RPC path the candidate groups are the groups behind every circle the
|
|
42
|
+
* applying peer belongs to whose catalog covers the document; an empty walk
|
|
43
|
+
* means the doc lies outside this peer's enforceable perimeter and is accepted.
|
|
44
|
+
*/
|
|
45
|
+
export declare function checkMembership(params: CheckMembershipParams): Promise<CheckMembershipResult>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{catalogMatchesDoc as e}from"./catalog-match.js";export async function checkMembership(t){let{p2pStore:r,graphStore:l,selfDID:o,iss:a,mutationHLC:i,doc:f,arrivalGroupID:n}=t,u=async e=>{let t=await r.getMemberRemoval(e,a);if(null!=t&&null!=t.removed_at_hlc)return t.removed_at_hlc<=i?t.removed_at_hlc:void 0};if(null!=n){let e=await u(n);return null!=e?{ok:!1,removedGroupID:n,removedAtHLC:e}:{ok:!0}}let c=await r.getCirclesForMember(o);if(0===c.length)return{ok:!0};let d=new Set;for(let e of c)for(let t of e.catalog_ids??[])d.add(t);let m=new Map;d.size>0&&(m=await l.getCatalogs(Array.from(d)));let g=new Set;for(let t of c)for(let l of t.catalog_ids??[]){let o=m.get(l);if(null!=o&&await e(o.filter_criteria,f,r)){g.add(t.group_id);break}}if(0===g.size)return{ok:!0};for(let e of g){let t=await u(e);if(null!=t)return{ok:!1,removedGroupID:e,removedAtHLC:t}}return{ok:!0}}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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.16.0",
|
|
19
|
+
"@enkaku/capability": "^0.16.0",
|
|
20
|
+
"@enkaku/event": "^0.16.0",
|
|
21
|
+
"@enkaku/runtime": "^0.16.0",
|
|
22
|
+
"@enkaku/schema": "^0.16.0",
|
|
23
|
+
"@enkaku/token": "^0.16.0",
|
|
24
24
|
"@noble/hashes": "^2.2.0",
|
|
25
25
|
"graphql": "^16.13.2",
|
|
26
|
-
"@kubun/
|
|
27
|
-
"@kubun/
|
|
28
|
-
"@kubun/db-adapter": "^0.
|
|
29
|
-
"@kubun/
|
|
30
|
-
"@kubun/logger": "^0.
|
|
31
|
-
"@kubun/
|
|
32
|
-
"@kubun/
|
|
33
|
-
"@kubun/store-
|
|
26
|
+
"@kubun/graphql": "^0.10.0",
|
|
27
|
+
"@kubun/db": "^0.10.0",
|
|
28
|
+
"@kubun/db-adapter": "^0.10.0",
|
|
29
|
+
"@kubun/mutation": "^0.10.0",
|
|
30
|
+
"@kubun/logger": "^0.10.0",
|
|
31
|
+
"@kubun/protocol": "^0.10.0",
|
|
32
|
+
"@kubun/hlc": "^0.10.0",
|
|
33
|
+
"@kubun/store-p2p": "^0.10.0",
|
|
34
|
+
"@kubun/store-graph": "^0.10.0"
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|
|
36
|
-
"@testcontainers/postgresql": "^
|
|
37
|
-
"@kubun/
|
|
38
|
-
"@kubun/
|
|
39
|
-
"@kubun/
|
|
40
|
-
"@kubun/test-utils": "^0.9.0"
|
|
37
|
+
"@testcontainers/postgresql": "^12.0.1",
|
|
38
|
+
"@kubun/db-postgres": "^0.10.0",
|
|
39
|
+
"@kubun/id": "^0.10.0",
|
|
40
|
+
"@kubun/test-utils": "^0.10.0"
|
|
41
41
|
},
|
|
42
42
|
"scripts": {
|
|
43
43
|
"build:clean": "del lib",
|