@kubun/engine 0.12.0 → 0.13.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 +11 -0
- package/lib/access-control.js +8 -2
- package/lib/controller-authority.d.ts +27 -0
- package/lib/controller-authority.js +83 -0
- package/lib/engine-events.d.ts +17 -0
- package/lib/engine.d.ts +23 -9
- package/lib/engine.js +302 -44
- package/lib/executor.d.ts +7 -5
- package/lib/index.d.ts +1 -2
- package/lib/index.js +1 -1
- package/lib/plugin.d.ts +16 -5
- package/package.json +26 -18
- package/lib/cipher.d.ts +0 -6
- package/lib/cipher.js +0 -40
package/lib/access-control.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type VerifyTokenHook } from '@kokuin/capability';
|
|
2
|
+
import type { DIDMethodResolver } from '@kokuin/token';
|
|
2
3
|
import type { DocumentNode } from '@kubun/protocol';
|
|
3
4
|
import type { AccessLevel, StoredAccessRule } from '@kubun/store-graph';
|
|
4
5
|
/**
|
|
@@ -25,6 +26,16 @@ export type AccessControlDB = {
|
|
|
25
26
|
* without a P2P store (e.g. light clients) where no revocation state exists.
|
|
26
27
|
*/
|
|
27
28
|
revocationChecker?: VerifyTokenHook;
|
|
29
|
+
/**
|
|
30
|
+
* Optional `did:kokuin:` method resolver passed to `checkCapability` as its
|
|
31
|
+
* `methods`. When a capability chain is issued/subjected by a controller DID,
|
|
32
|
+
* this resolves that DID to its current signing keys and deny set — so a
|
|
33
|
+
* capability signed by a device key the controller has revoked is refused,
|
|
34
|
+
* and a controller `iss`/`sub` resolves at all. Left undefined on deployments
|
|
35
|
+
* without controller support, where a `did:kokuin:` issuer is unresolvable
|
|
36
|
+
* and `checkCapability` fails closed.
|
|
37
|
+
*/
|
|
38
|
+
controllerResolver?: DIDMethodResolver;
|
|
28
39
|
};
|
|
29
40
|
export type { AccessLevel };
|
|
30
41
|
export type AccessRule = {
|
package/lib/access-control.js
CHANGED
|
@@ -151,7 +151,10 @@ atTime) {
|
|
|
151
151
|
cap: delegationTokens
|
|
152
152
|
}, {
|
|
153
153
|
atTime,
|
|
154
|
-
verifyToken: db.revocationChecker
|
|
154
|
+
verifyToken: db.revocationChecker,
|
|
155
|
+
methods: db.controllerResolver ? [
|
|
156
|
+
db.controllerResolver
|
|
157
|
+
] : undefined
|
|
155
158
|
});
|
|
156
159
|
return true;
|
|
157
160
|
} catch {
|
|
@@ -171,7 +174,10 @@ atTime) {
|
|
|
171
174
|
cap: token
|
|
172
175
|
}, {
|
|
173
176
|
atTime,
|
|
174
|
-
verifyToken: db.revocationChecker
|
|
177
|
+
verifyToken: db.revocationChecker,
|
|
178
|
+
methods: db.controllerResolver ? [
|
|
179
|
+
db.controllerResolver
|
|
180
|
+
] : undefined
|
|
175
181
|
});
|
|
176
182
|
return true;
|
|
177
183
|
} catch {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type VerifyTokenHook } from '@kokuin/capability';
|
|
2
|
+
import { type DIDMethodResolver } from '@kokuin/token';
|
|
3
|
+
import { type CredentialAuthority } from '@kubun/credential';
|
|
4
|
+
export type ControllerAuthorityParams = {
|
|
5
|
+
/** The device making the request; the capability holder and the read/self-owner subject. */
|
|
6
|
+
viewerDID: string;
|
|
7
|
+
/**
|
|
8
|
+
* Intended owner for a NEW key. Under a real delegation this is the controller
|
|
9
|
+
* the device acts for, supplied by the caller because it is neither the client's
|
|
10
|
+
* input nor the signer but what the chain resolves to; defaults to a self-owned key.
|
|
11
|
+
*/
|
|
12
|
+
ownerDID?: string;
|
|
13
|
+
delegationTokens?: Array<string>;
|
|
14
|
+
/** Injected so a `did:kokuin:` owner's capability and deny set resolve without a dependency cycle. */
|
|
15
|
+
controllerResolver?: DIDMethodResolver;
|
|
16
|
+
/** The delegation-revocation hook, consulted for every capability in the chain. */
|
|
17
|
+
revocationChecker?: VerifyTokenHook;
|
|
18
|
+
/** Evaluate capability expiry at this time (epoch seconds) rather than now(). */
|
|
19
|
+
atTime?: number;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* A superset of {@link createDeviceAuthority}: same owner-equality and wrapping-recipient
|
|
23
|
+
* read, plus a capability chain that lets a delegate administer a controller-owned key on
|
|
24
|
+
* its owner's behalf. `administer` alone carries the chain — a recipient already holds a
|
|
25
|
+
* wrapping that decrypts, so `read` needs no delegation.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createControllerAuthority(params: ControllerAuthorityParams): CredentialAuthority;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { checkCapability } from '@kokuin/capability';
|
|
2
|
+
import { normalizeDID } from '@kokuin/token';
|
|
3
|
+
import { CREDENTIAL_ADMINISTER_ACTION, credentialUserResource } from '@kubun/credential';
|
|
4
|
+
/**
|
|
5
|
+
* A superset of {@link createDeviceAuthority}: same owner-equality and wrapping-recipient
|
|
6
|
+
* read, plus a capability chain that lets a delegate administer a controller-owned key on
|
|
7
|
+
* its owner's behalf. `administer` alone carries the chain — a recipient already holds a
|
|
8
|
+
* wrapping that decrypts, so `read` needs no delegation.
|
|
9
|
+
*/ export function createControllerAuthority(params) {
|
|
10
|
+
const viewerDID = normalizeDID(params.viewerDID);
|
|
11
|
+
const { delegationTokens, controllerResolver, revocationChecker, atTime } = params;
|
|
12
|
+
return {
|
|
13
|
+
async ownerForNewKey () {
|
|
14
|
+
return normalizeDID(params.ownerDID ?? params.viewerDID);
|
|
15
|
+
},
|
|
16
|
+
async authorize (authorization, { key, store }) {
|
|
17
|
+
const ownerDID = normalizeDID(key.owner_did);
|
|
18
|
+
if (ownerDID === viewerDID) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
if (authorization === 'administer') {
|
|
22
|
+
if (!delegationTokens || delegationTokens.length === 0) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
// The user-wide resource covers all of an owner's credentials; `*` covers any.
|
|
26
|
+
const resources = [
|
|
27
|
+
credentialUserResource(key.owner_did),
|
|
28
|
+
'*'
|
|
29
|
+
];
|
|
30
|
+
const methods = controllerResolver ? [
|
|
31
|
+
controllerResolver
|
|
32
|
+
] : undefined;
|
|
33
|
+
// First, try the tokens as one delegation chain (an A→B→C grant).
|
|
34
|
+
for (const res of resources){
|
|
35
|
+
try {
|
|
36
|
+
await checkCapability({
|
|
37
|
+
act: CREDENTIAL_ADMINISTER_ACTION,
|
|
38
|
+
res
|
|
39
|
+
}, {
|
|
40
|
+
iss: viewerDID,
|
|
41
|
+
sub: ownerDID,
|
|
42
|
+
cap: delegationTokens
|
|
43
|
+
}, {
|
|
44
|
+
atTime,
|
|
45
|
+
verifyToken: revocationChecker,
|
|
46
|
+
methods
|
|
47
|
+
});
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
// This resource does not match the chain — continue.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Then each token independently (several unrelated direct grants).
|
|
54
|
+
for (const token of delegationTokens){
|
|
55
|
+
for (const res of resources){
|
|
56
|
+
try {
|
|
57
|
+
await checkCapability({
|
|
58
|
+
act: CREDENTIAL_ADMINISTER_ACTION,
|
|
59
|
+
res
|
|
60
|
+
}, {
|
|
61
|
+
iss: viewerDID,
|
|
62
|
+
sub: ownerDID,
|
|
63
|
+
cap: token
|
|
64
|
+
}, {
|
|
65
|
+
atTime,
|
|
66
|
+
verifyToken: revocationChecker,
|
|
67
|
+
methods
|
|
68
|
+
});
|
|
69
|
+
return true;
|
|
70
|
+
} catch {
|
|
71
|
+
// This token does not match this resource — continue.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
// Current version only: a recipient left behind by a rotation or revocation
|
|
78
|
+
// must read as "not a recipient", not as history.
|
|
79
|
+
const wrappings = await store.listWrappings(key.key_id, key.version);
|
|
80
|
+
return wrappings.some((wrapping)=>wrapping.recipient_did != null && normalizeDID(wrapping.recipient_did) === viewerDID);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
package/lib/engine-events.d.ts
CHANGED
|
@@ -75,4 +75,21 @@ export type EngineEvents = {
|
|
|
75
75
|
/** LWW anchor stamped by this peer. */
|
|
76
76
|
hlc: string;
|
|
77
77
|
};
|
|
78
|
+
/**
|
|
79
|
+
* Emitted when a controller DID's log is seen to fork: a peer served a
|
|
80
|
+
* branch that neither supersedes nor is superseded by the one already
|
|
81
|
+
* stored. The stored authoritative branch is kept; the verification that
|
|
82
|
+
* hit the fork fails closed. A signal for the app to surface a possible
|
|
83
|
+
* key-compromise/duplicity, not a recoverable error.
|
|
84
|
+
*
|
|
85
|
+
* Unlike the document/mutation events, this one fires from inside the verify
|
|
86
|
+
* path — while a peer-ingest apply transaction may still be open (and about to
|
|
87
|
+
* roll back). A listener MUST NOT synchronously read the engine DB in response:
|
|
88
|
+
* on single-connection SQLite that can deadlock against the open write
|
|
89
|
+
* transaction. Do notification-only work here (surface a warning, enqueue an
|
|
90
|
+
* out-of-band task); defer any DB access to a later tick.
|
|
91
|
+
*/
|
|
92
|
+
'engine:controller:forked': {
|
|
93
|
+
did: string;
|
|
94
|
+
};
|
|
78
95
|
};
|
package/lib/engine.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import type { Identity } from '@kokuin/token';
|
|
1
|
+
import { type VerifyTokenHook } from '@kokuin/capability';
|
|
2
|
+
import type { DIDMethodResolver, Identity } from '@kokuin/token';
|
|
3
|
+
import type { CredentialAuthority } from '@kubun/credential';
|
|
3
4
|
import { KubunDB, type StoreProvider } from '@kubun/db';
|
|
4
5
|
import type { Adapter } from '@kubun/db-adapter';
|
|
5
6
|
import { type PatchOperation } from '@kubun/graphql';
|
|
@@ -11,7 +12,6 @@ import { type P2PStoreAPI } from '@kubun/store-p2p';
|
|
|
11
12
|
import { type Runtime } from '@sozai/runtime';
|
|
12
13
|
import type { GraphQLSchema } from 'graphql';
|
|
13
14
|
import { type DefaultAccessLevel } from './access-control.js';
|
|
14
|
-
import { type Cipher } from './cipher.js';
|
|
15
15
|
import type { EngineEvents } from './engine-events.js';
|
|
16
16
|
import { EngineEventBus } from './events.js';
|
|
17
17
|
import type { Engine, EngineGraphParams, GraphQLSource, GraphQLSourceParams } from './executor.js';
|
|
@@ -32,6 +32,7 @@ export declare function buildWriteAccessChecker(params: {
|
|
|
32
32
|
defaultAccessLevel: DefaultAccessLevel;
|
|
33
33
|
extraTokensByIssuer?: Map<string, Array<string>>;
|
|
34
34
|
revocationChecker?: VerifyTokenHook;
|
|
35
|
+
controllerResolver?: DIDMethodResolver;
|
|
35
36
|
}): (doc: DocumentNode, mutation: DocumentMutation) => Promise<boolean>;
|
|
36
37
|
export type { Engine };
|
|
37
38
|
/**
|
|
@@ -41,6 +42,14 @@ export type { Engine };
|
|
|
41
42
|
*/
|
|
42
43
|
export type ExecutionContext = {
|
|
43
44
|
viewerDID: string;
|
|
45
|
+
/** Delegation tokens the request carried, so a plugin can act under the same chain. */
|
|
46
|
+
delegationTokens?: Array<string>;
|
|
47
|
+
/**
|
|
48
|
+
* Credential read/administer authority for this request, assembled by the engine
|
|
49
|
+
* from the viewer and the request's delegation tokens. Required: every request
|
|
50
|
+
* gets one (self-owned when no tokens are present), so plugins never fabricate it.
|
|
51
|
+
*/
|
|
52
|
+
credentialAuthority: CredentialAuthority;
|
|
44
53
|
};
|
|
45
54
|
/**
|
|
46
55
|
* A function that produces per-request context fields.
|
|
@@ -77,13 +86,12 @@ export type EngineParams = {
|
|
|
77
86
|
*/
|
|
78
87
|
maxSubscriptionQueueSize?: number;
|
|
79
88
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* passing it here — the engine itself never reads `process.env`.
|
|
89
|
+
* How long (in milliseconds) a cached `did:kokuin:` controller log is trusted
|
|
90
|
+
* before a re-pull from a group peer is attempted. Bounds how stale a
|
|
91
|
+
* controller's device-key revocation may be locally. Defaults to
|
|
92
|
+
* {@link DEFAULT_CONTROLLER_LOG_TTL_MS} (5 minutes).
|
|
85
93
|
*/
|
|
86
|
-
|
|
94
|
+
controllerLogTTLMs?: number;
|
|
87
95
|
};
|
|
88
96
|
/**
|
|
89
97
|
* Optional pre-persist gate. Receives the synthesized post-apply
|
|
@@ -218,6 +226,12 @@ export type ExecuteParams = {
|
|
|
218
226
|
contextExtensions?: Record<string, unknown>;
|
|
219
227
|
/** Override the store provider for this execution (e.g. transactional provider). */
|
|
220
228
|
stores?: StoreProvider;
|
|
229
|
+
/**
|
|
230
|
+
* Delegation tokens the request carries, surfaced on the ExecutionContext so a
|
|
231
|
+
* plugin's per-request credential authority can authorize a delegated chain.
|
|
232
|
+
* Independent of the mutation `cap` auto-attach, which happens upstream.
|
|
233
|
+
*/
|
|
234
|
+
delegationTokens?: Array<string>;
|
|
221
235
|
};
|
|
222
236
|
export declare class KubunEngine implements Engine {
|
|
223
237
|
#private;
|
package/lib/engine.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createControllerCapabilityVerifier } from '@kokuin/capability';
|
|
2
|
+
import { createControllerResolver, LOG_FORKED } from '@kokuin/controller';
|
|
3
|
+
import { isSigningIdentity, stringifyToken, verifyToken } from '@kokuin/token';
|
|
2
4
|
import { KubunDB } from '@kubun/db';
|
|
3
5
|
import { createReadContext, createSchema } from '@kubun/graphql';
|
|
4
6
|
import { HLC } from '@kubun/hlc';
|
|
5
7
|
import { DocumentID } from '@kubun/id';
|
|
8
|
+
import { controllerNodeID } from '@kubun/identity';
|
|
6
9
|
import { getKubunLogger } from '@kubun/logger';
|
|
7
|
-
import { applyMutation, convertPatchInput, createMutationOperations, WriteAccessDeniedError } from '@kubun/mutation';
|
|
10
|
+
import { applyMutation, convertPatchInput, createMutationOperations, DEFAULT_MAX_DRIFT_MS, WriteAccessDeniedError } from '@kubun/mutation';
|
|
8
11
|
import { clusterToRecord, documentMutation, GraphModel } from '@kubun/protocol';
|
|
12
|
+
import { controllerStoreDefinition, getControllerStore } from '@kubun/store-controller';
|
|
9
13
|
import { createDelegationRevocationChecker, delegationStoreDefinition, getDelegationStore } from '@kubun/store-delegation';
|
|
10
14
|
import { GRAPH_STORE, getGraphStore, graphStoreDefinition } from '@kubun/store-graph';
|
|
11
15
|
import { getP2PStore, P2P_STORE } from '@kubun/store-p2p';
|
|
@@ -13,7 +17,7 @@ import { createRuntime } from '@sozai/runtime';
|
|
|
13
17
|
import { asType, createValidator } from '@sozai/schema';
|
|
14
18
|
import { execute, GraphQLError, Kind, parse, subscribe, validate } from 'graphql';
|
|
15
19
|
import { createAccessChecker } from './access-control.js';
|
|
16
|
-
import {
|
|
20
|
+
import { createControllerAuthority } from './controller-authority.js';
|
|
17
21
|
import { isTransactionFatal, MutateGraphWriteRollback } from './errors.js';
|
|
18
22
|
import { EngineEventBus } from './events.js';
|
|
19
23
|
import { checkMembership } from './membership-check.js';
|
|
@@ -49,7 +53,7 @@ const validateMutation = createValidator(documentMutation);
|
|
|
49
53
|
* hand-built map and exercise the union math directly. The Engine method
|
|
50
54
|
* `#buildWriteAccessChecker` delegates to this helper.
|
|
51
55
|
*/ export function buildWriteAccessChecker(params) {
|
|
52
|
-
const { store, defaultAccessLevel, extraTokensByIssuer, revocationChecker } = params;
|
|
56
|
+
const { store, defaultAccessLevel, extraTokensByIssuer, revocationChecker, controllerResolver } = params;
|
|
53
57
|
// Per-closure cache: a single write-apply transaction enters this helper
|
|
54
58
|
// once and may resolve the same model's interface list multiple times
|
|
55
59
|
// (e.g. during delegation resource enumeration). Memoizing per call keeps
|
|
@@ -67,7 +71,8 @@ const validateMutation = createValidator(documentMutation);
|
|
|
67
71
|
interfaceCache.set(modelID, fetched);
|
|
68
72
|
return fetched;
|
|
69
73
|
},
|
|
70
|
-
revocationChecker
|
|
74
|
+
revocationChecker,
|
|
75
|
+
controllerResolver
|
|
71
76
|
};
|
|
72
77
|
return (doc, mutation)=>{
|
|
73
78
|
const inline = normalizeCap(mutation.cap) ?? [];
|
|
@@ -179,6 +184,45 @@ function createContext(params) {
|
|
|
179
184
|
}
|
|
180
185
|
return coreContext;
|
|
181
186
|
}
|
|
187
|
+
// The honest-case revocation-visibility window: how long a cached controller log
|
|
188
|
+
// is trusted before a re-pull is attempted. A controller `rev` (device-key
|
|
189
|
+
// denial) issued elsewhere becomes locally enforceable at most one TTL late.
|
|
190
|
+
const DEFAULT_CONTROLLER_LOG_TTL_MS = 300_000;
|
|
191
|
+
// Cap the per-DID controller soft-state maps so a long-lived peer (a hub, an
|
|
192
|
+
// always-on relay) syncing documents from many controller owners cannot grow
|
|
193
|
+
// them without bound. Eviction is least-recently-touched; an evicted freshness
|
|
194
|
+
// entry just triggers one extra re-pull, and an evicted fork mark may re-emit —
|
|
195
|
+
// both cheap and self-correcting.
|
|
196
|
+
const MAX_CONTROLLER_DIDS_TRACKED = 4096;
|
|
197
|
+
// Decorate a controller resolver so any resolve method that throws `LOG_FORKED`
|
|
198
|
+
// (a peer served a branch that neither supersedes nor is superseded by the one
|
|
199
|
+
// already seen) fires `onFork(did)` before re-throwing. The throw originates in
|
|
200
|
+
// the resolver's own `authoritativeStates`, before `verifyToken` wraps it, so
|
|
201
|
+
// catching it here is independent of every verify call site. A fork is
|
|
202
|
+
// unverifiable — the error is re-thrown unchanged (fail-closed); only a
|
|
203
|
+
// notification is added. `LOG_NOT_AUTHORITATIVE` (a behind prefix) is not a fork
|
|
204
|
+
// and passes through untouched.
|
|
205
|
+
function withForkDetection(base, onFork) {
|
|
206
|
+
function wrap(method) {
|
|
207
|
+
return async (did, ...args)=>{
|
|
208
|
+
try {
|
|
209
|
+
return await method(did, ...args);
|
|
210
|
+
} catch (err) {
|
|
211
|
+
if (err instanceof Error && err.message.includes(LOG_FORKED)) {
|
|
212
|
+
await onFork(did);
|
|
213
|
+
}
|
|
214
|
+
throw err;
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
method: base.method,
|
|
220
|
+
resolve: wrap(base.resolve.bind(base)),
|
|
221
|
+
resolveHistoric: base.resolveHistoric != null ? wrap(base.resolveHistoric.bind(base)) : undefined,
|
|
222
|
+
resolveAgreementKey: base.resolveAgreementKey != null ? wrap(base.resolveAgreementKey.bind(base)) : undefined,
|
|
223
|
+
resolveDenySet: base.resolveDenySet != null ? wrap(base.resolveDenySet.bind(base)) : undefined
|
|
224
|
+
};
|
|
225
|
+
}
|
|
182
226
|
export class KubunEngine {
|
|
183
227
|
/**
|
|
184
228
|
* Plugin-scoped context factories. Each factory's return value is placed
|
|
@@ -190,7 +234,13 @@ export class KubunEngine {
|
|
|
190
234
|
* onto ctx. Escape hatch for test fixtures — not subject to the duplicate
|
|
191
235
|
* plugin namespace check.
|
|
192
236
|
*/ #imperativeFactories = [];
|
|
193
|
-
#
|
|
237
|
+
#controllerResolver;
|
|
238
|
+
// did → ms of its last successful controller-log pull. Engine-local soft state,
|
|
239
|
+
// not a store column: a pull-failure fallback still writes the cached log to the
|
|
240
|
+
// store (bumping its `updated_at`), which would mask staleness. A restart clears
|
|
241
|
+
// this, re-pulling once per DID — accepted.
|
|
242
|
+
#controllerLogFreshAt = new Map();
|
|
243
|
+
#controllerLogTTLMs;
|
|
194
244
|
#db;
|
|
195
245
|
#defaultAccessLevel;
|
|
196
246
|
#eventBus;
|
|
@@ -221,16 +271,27 @@ export class KubunEngine {
|
|
|
221
271
|
});
|
|
222
272
|
this.#db.register(graphStoreDefinition);
|
|
223
273
|
this.#db.register(delegationStoreDefinition);
|
|
274
|
+
this.#db.register(controllerStoreDefinition);
|
|
275
|
+
// Resolves a `did:kokuin:` issuer to its head signing key from the
|
|
276
|
+
// controller store, over the main connection. Used only by the two
|
|
277
|
+
// pre-transaction `verifyToken` sites, which run before any write tx opens,
|
|
278
|
+
// so the lazy store read cannot deadlock. The in-apply capability path uses
|
|
279
|
+
// `#controllerResolverFor(provider)` instead — see that helper.
|
|
280
|
+
this.#controllerResolver = this.#controllerResolverFor(this.#db);
|
|
224
281
|
this.#identity = params.identity;
|
|
225
|
-
this.#cipher = params.cipher ?? (isOwnIdentity(params.identity) ? createDefaultCipher(deriveAtRestKey(params.identity.privateKey)) : undefined);
|
|
226
282
|
this.#defaultAccessLevel = params.defaultAccessLevel ?? {
|
|
227
283
|
read: 'anyone',
|
|
228
284
|
write: 'only_owner'
|
|
229
285
|
};
|
|
286
|
+
// A controller DID is one string across every device, so the HLC node must
|
|
287
|
+
// key off the per-device signing key or concurrent devices collapse to one
|
|
288
|
+
// node. Read-only identities carry no `publicKey`; they keep their id.
|
|
289
|
+
const nodeID = isSigningIdentity(params.identity) ? controllerNodeID(params.identity) : params.identity.id;
|
|
230
290
|
this.#hlc = new HLC({
|
|
231
|
-
nodeID
|
|
291
|
+
nodeID
|
|
232
292
|
});
|
|
233
|
-
this.#maxDriftMS = params.maxDriftMS ??
|
|
293
|
+
this.#maxDriftMS = params.maxDriftMS ?? DEFAULT_MAX_DRIFT_MS;
|
|
294
|
+
this.#controllerLogTTLMs = params.controllerLogTTLMs ?? DEFAULT_CONTROLLER_LOG_TTL_MS;
|
|
234
295
|
this.#maxSubscriptionQueueSize = params.maxSubscriptionQueueSize;
|
|
235
296
|
this.#logger = params.logger ?? getKubunLogger('engine');
|
|
236
297
|
this.#eventBus = params.eventBus ?? new EngineEventBus({
|
|
@@ -255,8 +316,9 @@ export class KubunEngine {
|
|
|
255
316
|
identity: this.#identity,
|
|
256
317
|
eventBus: this.#eventBus,
|
|
257
318
|
hlc: this.#hlc,
|
|
319
|
+
maxDriftMS: this.#maxDriftMS,
|
|
258
320
|
getLogger: (name)=>this.#logger.getChild(name),
|
|
259
|
-
|
|
321
|
+
controllerResolverFor: (stores)=>this.#controllerResolverFor(stores)
|
|
260
322
|
};
|
|
261
323
|
// Call all plugin factories
|
|
262
324
|
for (const factory of pluginFactories){
|
|
@@ -418,13 +480,24 @@ export class KubunEngine {
|
|
|
418
480
|
// floored wall-time at the bound so local writes always pass; a remote
|
|
419
481
|
// HLC beyond the bound is already rejected at ingest, so the floor is the
|
|
420
482
|
// only source that needs clamping.
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
483
|
+
//
|
|
484
|
+
// Parsed defensively: the log stores a peer's stamp verbatim even when
|
|
485
|
+
// ingest refused to advance the clock to it, and a malformed one sorts
|
|
486
|
+
// above every ISO-8601 stamp — so the maximum is exactly where such a row
|
|
487
|
+
// shows up, on every start, for as long as it is stored.
|
|
488
|
+
const parsed = HLC.tryParse(max);
|
|
489
|
+
if (parsed == null) {
|
|
490
|
+
this.#logger.warn('stored mutation maximum is an unparseable HLC; clock not floored', {
|
|
491
|
+
maxHLC: max
|
|
492
|
+
});
|
|
493
|
+
} else {
|
|
494
|
+
const ceiling = Date.now() + this.#maxDriftMS;
|
|
495
|
+
const flooredWallTime = Math.min(parsed.wallTime, ceiling);
|
|
496
|
+
this.#hlc.receive({
|
|
497
|
+
...parsed,
|
|
498
|
+
wallTime: flooredWallTime
|
|
499
|
+
});
|
|
500
|
+
}
|
|
428
501
|
}
|
|
429
502
|
})();
|
|
430
503
|
return await this.#hlcFloorPromise;
|
|
@@ -434,8 +507,34 @@ export class KubunEngine {
|
|
|
434
507
|
* with all registered plugin context factories and any inline extensions.
|
|
435
508
|
*/ async #buildRequestContext(params) {
|
|
436
509
|
const viewerDID = params.viewerDID ?? this.#identity.id;
|
|
437
|
-
const executionContext = this.#createExecutionContext(viewerDID);
|
|
438
510
|
const provider = params.stores ?? this.#db;
|
|
511
|
+
// Delegation storage is core-registered and always present, so the
|
|
512
|
+
// delegated-token revocation check is unconditional: a light client with no
|
|
513
|
+
// p2p store still holds delegation/revocation rows and must honor them when
|
|
514
|
+
// validating a delegated chain (read ACL below and credential admin here).
|
|
515
|
+
const delegationStore = await getDelegationStore(provider);
|
|
516
|
+
// Provider-scoped controller resolver so a controller-issued (`did:kokuin:`)
|
|
517
|
+
// revocation record verifies. During a mutation `provider` is the open write
|
|
518
|
+
// tx; building the resolver on it is deadlock-safe because it only executes
|
|
519
|
+
// (folds the controller log) when a delegated check actually runs, and that
|
|
520
|
+
// fold does not re-enter this transaction.
|
|
521
|
+
const controllerResolver = this.#controllerResolverFor(provider);
|
|
522
|
+
const revocationChecker = createDelegationRevocationChecker(delegationStore, [
|
|
523
|
+
controllerResolver
|
|
524
|
+
]);
|
|
525
|
+
// Per-request credential authority: assembled from the request's viewer and
|
|
526
|
+
// delegation tokens (async because of the delegation-store read above), so a
|
|
527
|
+
// plugin authorizes credential read/administer without capturing request state
|
|
528
|
+
// in its provider-cached manager. `atTime` is omitted — credential admin is
|
|
529
|
+
// evaluated at request time (checkCapability's default), unlike a document
|
|
530
|
+
// mutation whose cap is honored at its HLC signing time.
|
|
531
|
+
const credentialAuthority = createControllerAuthority({
|
|
532
|
+
viewerDID,
|
|
533
|
+
delegationTokens: params.delegationTokens,
|
|
534
|
+
controllerResolver,
|
|
535
|
+
revocationChecker
|
|
536
|
+
});
|
|
537
|
+
const executionContext = this.#createExecutionContext(viewerDID, params.delegationTokens, credentialAuthority);
|
|
439
538
|
// Plugin-scoped factories: output goes under ctx[pluginName]
|
|
440
539
|
let extensions = {};
|
|
441
540
|
for (const { name, factory } of this.#pluginContextFactories){
|
|
@@ -470,12 +569,6 @@ export class KubunEngine {
|
|
|
470
569
|
} catch {
|
|
471
570
|
p2pStore = undefined;
|
|
472
571
|
}
|
|
473
|
-
// Delegation storage is core-registered and always present, so the read
|
|
474
|
-
// path's delegated-token revocation check is unconditional: a light client
|
|
475
|
-
// with no p2p store still holds delegation/revocation rows and must honor
|
|
476
|
-
// them when validating a delegated read chain.
|
|
477
|
-
const delegationStore = await getDelegationStore(provider);
|
|
478
|
-
const revocationChecker = createDelegationRevocationChecker(delegationStore);
|
|
479
572
|
let accessControlDB;
|
|
480
573
|
let viewerReadAccess;
|
|
481
574
|
if (p2pStore != null) {
|
|
@@ -485,7 +578,8 @@ export class KubunEngine {
|
|
|
485
578
|
isMemberOfAnyGroup: (did, groupIDs)=>memberStore.isMemberOfAnyGroup(did, groupIDs),
|
|
486
579
|
isMemberOfAnyCircle: (did, circleIDs)=>memberStore.isMemberOfAnyCircle(did, circleIDs),
|
|
487
580
|
getModelInterfaces: (modelID)=>store.getModelInterfaces(modelID),
|
|
488
|
-
revocationChecker
|
|
581
|
+
revocationChecker,
|
|
582
|
+
controllerResolver
|
|
489
583
|
};
|
|
490
584
|
const [groups, circles] = await Promise.all([
|
|
491
585
|
memberStore.getGroupsForMember(viewerDID),
|
|
@@ -506,7 +600,8 @@ export class KubunEngine {
|
|
|
506
600
|
isMemberOfAnyGroup: async ()=>false,
|
|
507
601
|
isMemberOfAnyCircle: async ()=>false,
|
|
508
602
|
getModelInterfaces: (modelID)=>store.getModelInterfaces(modelID),
|
|
509
|
-
revocationChecker
|
|
603
|
+
revocationChecker,
|
|
604
|
+
controllerResolver
|
|
510
605
|
};
|
|
511
606
|
viewerReadAccess = {
|
|
512
607
|
viewerDID,
|
|
@@ -531,10 +626,14 @@ export class KubunEngine {
|
|
|
531
626
|
});
|
|
532
627
|
}
|
|
533
628
|
/**
|
|
534
|
-
* Create an ExecutionContext for a single request.
|
|
535
|
-
|
|
629
|
+
* Create an ExecutionContext for a single request. The credential authority is
|
|
630
|
+
* built by the caller (`#buildRequestContext`) because assembling it needs an
|
|
631
|
+
* async delegation-store read against the request's provider.
|
|
632
|
+
*/ #createExecutionContext(viewerDID, delegationTokens, credentialAuthority) {
|
|
536
633
|
return {
|
|
537
|
-
viewerDID
|
|
634
|
+
viewerDID,
|
|
635
|
+
delegationTokens,
|
|
636
|
+
credentialAuthority
|
|
538
637
|
};
|
|
539
638
|
}
|
|
540
639
|
async #getGraphModel(id) {
|
|
@@ -894,15 +993,114 @@ export class KubunEngine {
|
|
|
894
993
|
* checker unions them with the mutation's inline `cap` so server-side
|
|
895
994
|
* application can authorize a write whose held credentials live in the
|
|
896
995
|
* receiving peer's p2p store rather than on the wire.
|
|
897
|
-
*/ #buildWriteAccessChecker(store, extraTokensByIssuer, revocationChecker) {
|
|
996
|
+
*/ #buildWriteAccessChecker(store, extraTokensByIssuer, revocationChecker, controllerResolver) {
|
|
898
997
|
return buildWriteAccessChecker({
|
|
899
998
|
store,
|
|
900
999
|
defaultAccessLevel: this.#defaultAccessLevel,
|
|
901
1000
|
extraTokensByIssuer,
|
|
902
|
-
revocationChecker
|
|
1001
|
+
revocationChecker,
|
|
1002
|
+
controllerResolver
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Pull a `did:kokuin:` controller's log from a connected group peer, when our
|
|
1007
|
+
* store lacks it. Reaches plugin-p2p by string at call time (no static dep —
|
|
1008
|
+
* plugin-p2p imports engine). A peer with no p2p plugin (read-only clients,
|
|
1009
|
+
* most unit tests) has no fetcher: getAPI throws `API not registered`, which
|
|
1010
|
+
* we treat as "no fetcher available" and return undefined.
|
|
1011
|
+
*/ async #fetchControllerLog(did) {
|
|
1012
|
+
let p2p;
|
|
1013
|
+
try {
|
|
1014
|
+
p2p = await this.getAPI('p2p');
|
|
1015
|
+
} catch {
|
|
1016
|
+
return undefined;
|
|
1017
|
+
}
|
|
1018
|
+
return p2p.fetchControllerLog?.(did);
|
|
1019
|
+
}
|
|
1020
|
+
#isControllerLogFresh(did) {
|
|
1021
|
+
const at = this.#controllerLogFreshAt.get(did);
|
|
1022
|
+
return at != null && Date.now() - at < this.#controllerLogTTLMs;
|
|
1023
|
+
}
|
|
1024
|
+
// Record a successful pull's freshness, bounding the map (least-recently-
|
|
1025
|
+
// refreshed evicted first — delete-then-set moves an existing DID to the end).
|
|
1026
|
+
#markControllerLogFresh(did) {
|
|
1027
|
+
this.#controllerLogFreshAt.delete(did);
|
|
1028
|
+
this.#controllerLogFreshAt.set(did, Date.now());
|
|
1029
|
+
if (this.#controllerLogFreshAt.size > MAX_CONTROLLER_DIDS_TRACKED) {
|
|
1030
|
+
const oldest = this.#controllerLogFreshAt.keys().next().value;
|
|
1031
|
+
if (oldest !== undefined) this.#controllerLogFreshAt.delete(oldest);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
// A controller fork is a persistent condition, so emit once per DID — repeated
|
|
1035
|
+
// resolves against the same fork must not spam listeners. Bounded; an evicted
|
|
1036
|
+
// mark may re-emit, acceptable at this scale.
|
|
1037
|
+
#controllerForkEmitted = new Set();
|
|
1038
|
+
async #emitControllerFork(did) {
|
|
1039
|
+
if (this.#controllerForkEmitted.has(did)) return;
|
|
1040
|
+
this.#controllerForkEmitted.add(did);
|
|
1041
|
+
if (this.#controllerForkEmitted.size > MAX_CONTROLLER_DIDS_TRACKED) {
|
|
1042
|
+
const oldest = this.#controllerForkEmitted.keys().next().value;
|
|
1043
|
+
if (oldest !== undefined) this.#controllerForkEmitted.delete(oldest);
|
|
1044
|
+
}
|
|
1045
|
+
// Immediate emit, not emitBuffered: on the peer-ingest path the verify that
|
|
1046
|
+
// hit the fork runs inside an apply transaction that then rolls back, which
|
|
1047
|
+
// would discard a buffered event — but the fork must surface regardless of
|
|
1048
|
+
// whether the mutation applied. Because it fires while that transaction may
|
|
1049
|
+
// still be open, a listener for `engine:controller:forked` MUST NOT
|
|
1050
|
+
// synchronously read the engine DB (single-connection deadlock risk) — see
|
|
1051
|
+
// the event's declaration in engine-events.ts. `await` so it isn't a floating
|
|
1052
|
+
// promise.
|
|
1053
|
+
await this.#eventBus.emit('engine:controller:forked', {
|
|
1054
|
+
did
|
|
903
1055
|
});
|
|
904
1056
|
}
|
|
905
1057
|
/**
|
|
1058
|
+
* A `did:kokuin:` controller resolver scoped to a specific store provider.
|
|
1059
|
+
*
|
|
1060
|
+
* The capability path (`checkCapability` inside `checkDelegation`) runs inside
|
|
1061
|
+
* the apply write transaction, so its controller-store reads MUST route
|
|
1062
|
+
* through that same transaction provider: reading `this.#db` on a second
|
|
1063
|
+
* connection while the write tx is open deadlocks single-connection SQLite
|
|
1064
|
+
* (and reads a stale snapshot on Postgres). The read path passes its query
|
|
1065
|
+
* `provider` for the same consistency, deadlock-free since it holds no write tx.
|
|
1066
|
+
*
|
|
1067
|
+
* `verifyCapability` IS installed below: a capability-authored revoke otherwise
|
|
1068
|
+
* fails the fold and the DID is unresolvable. It does not re-enter this resolver
|
|
1069
|
+
* for the DID being folded — kokuin folds the verifier's own resolutions against
|
|
1070
|
+
* `subjectAtPosition` (states strictly before the event under verification), so
|
|
1071
|
+
* the DID mid-fold is never re-resolved through here.
|
|
1072
|
+
*/ #controllerResolverFor(provider) {
|
|
1073
|
+
return withForkDetection(createControllerResolver({
|
|
1074
|
+
// A revoke authored by a capability-holding device (not the profile's own
|
|
1075
|
+
// key) only folds when the verifier is present; without it the whole log
|
|
1076
|
+
// fails closed and the DID is unresolvable. Zero-arg: no `verifyToken`/`methods`
|
|
1077
|
+
// hook, so nothing re-enters this single-flight resolver for the folded DID.
|
|
1078
|
+
verifyCapability: createControllerCapabilityVerifier(),
|
|
1079
|
+
// A cached controller log is trusted for a bounded TTL; on a miss OR past
|
|
1080
|
+
// the TTL we attempt one re-pull before authorizing, and on pull failure
|
|
1081
|
+
// fall back to the cached log. The resolver's own authoritativeStates keeps
|
|
1082
|
+
// the fold no lower than `history`, so a behind/failed pull can never move
|
|
1083
|
+
// the store back — TTL only decides whether to attempt the re-pull.
|
|
1084
|
+
loadLog: async (did)=>{
|
|
1085
|
+
const cached = await getControllerStore(provider).then((s)=>s.get(did));
|
|
1086
|
+
if (cached != null && this.#isControllerLogFresh(did)) return cached;
|
|
1087
|
+
const pulled = await this.#fetchControllerLog(did);
|
|
1088
|
+
// Freshness is marked on any non-null pull, before the resolver folds it
|
|
1089
|
+
// against `cached`. A pull that then loses to the cache (a behind branch)
|
|
1090
|
+
// still resets the clock — safe (the store never moves back), and it is
|
|
1091
|
+
// what lets the next in-TTL verify recover from the intact cached head.
|
|
1092
|
+
// The user-visible cost: a lagging or hostile group peer can force at
|
|
1093
|
+
// most one fail-closed verification per DID per TTL, which self-heals.
|
|
1094
|
+
if (pulled != null) this.#markControllerLogFresh(did);
|
|
1095
|
+
return pulled ?? cached;
|
|
1096
|
+
},
|
|
1097
|
+
history: {
|
|
1098
|
+
get: (did)=>getControllerStore(provider).then((s)=>s.get(did)),
|
|
1099
|
+
set: (did, log)=>getControllerStore(provider).then((s)=>s.set(did, log))
|
|
1100
|
+
}
|
|
1101
|
+
}), (did)=>this.#emitControllerFork(did));
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
906
1104
|
* Verify a signed mutation JWT, apply it to the database, and capture it
|
|
907
1105
|
* in the mutation log.
|
|
908
1106
|
*
|
|
@@ -921,9 +1119,19 @@ export class KubunEngine {
|
|
|
921
1119
|
// single-connection SQLite). Harmless before an ingest receive() (receive
|
|
922
1120
|
// is monotonic); load-bearing for any local mint that reaches here.
|
|
923
1121
|
await this.#ensureHLCFloored(provider);
|
|
924
|
-
// Verify the JWT signature and validate the payload
|
|
925
|
-
//
|
|
926
|
-
|
|
1122
|
+
// Verify the JWT signature and validate the payload up front. `methods`
|
|
1123
|
+
// resolves a `did:kokuin:` issuer from the controller store. With a
|
|
1124
|
+
// caller-supplied provider (an already-open write tx — e.g. mutateGraph's
|
|
1125
|
+
// signAndApply) the store read MUST route through that provider: reading
|
|
1126
|
+
// the main connection while the caller's tx holds it deadlocks
|
|
1127
|
+
// single-connection SQLite. The no-provider path verifies before opening
|
|
1128
|
+
// its own transaction below, so the main-connection resolver is safe there.
|
|
1129
|
+
const controllerResolver = provider != null ? this.#controllerResolverFor(provider) : this.#controllerResolver;
|
|
1130
|
+
const verified = await verifyToken(params.token, {
|
|
1131
|
+
methods: [
|
|
1132
|
+
controllerResolver
|
|
1133
|
+
]
|
|
1134
|
+
});
|
|
927
1135
|
const mutation = asType(validateMutation, verified.payload);
|
|
928
1136
|
// With a caller-supplied provider the caller owns both the transaction and
|
|
929
1137
|
// the emit — apply directly and hand back the result.
|
|
@@ -977,7 +1185,12 @@ export class KubunEngine {
|
|
|
977
1185
|
if (held.length > 0) {
|
|
978
1186
|
extraTokensByIssuer.set(mutation.iss, held.map((row)=>row.token));
|
|
979
1187
|
}
|
|
980
|
-
|
|
1188
|
+
// Provider-scoped so a controller-issued revocation record resolves; the
|
|
1189
|
+
// capability check runs inside this apply's write tx, so route through
|
|
1190
|
+
// `provider` (not `this.#db`) to stay deadlock-safe.
|
|
1191
|
+
const revocationChecker = createDelegationRevocationChecker(delegationStore, [
|
|
1192
|
+
this.#controllerResolverFor(provider)
|
|
1193
|
+
]);
|
|
981
1194
|
// The MLS/membership gate is the only piece that needs the p2p store. Gate
|
|
982
1195
|
// on store registration, not on whether the lookup throws. An unregistered
|
|
983
1196
|
// p2p store is a light client with no membership to enforce, so the gate is
|
|
@@ -993,7 +1206,9 @@ export class KubunEngine {
|
|
|
993
1206
|
validators: this.#validators,
|
|
994
1207
|
hlc: this.#hlc,
|
|
995
1208
|
maxDriftMS: this.#maxDriftMS,
|
|
996
|
-
checkWriteAccess: this.#buildWriteAccessChecker(store, extraTokensByIssuer, revocationChecker
|
|
1209
|
+
checkWriteAccess: this.#buildWriteAccessChecker(store, extraTokensByIssuer, revocationChecker, // Provider-scoped: the capability check runs inside this apply's write
|
|
1210
|
+
// transaction, so controller-store reads must route through `provider`.
|
|
1211
|
+
this.#controllerResolverFor(provider)),
|
|
997
1212
|
// Build the gate from this apply's transaction-scoped stores so its
|
|
998
1213
|
// reads run inside the transaction. When no factory is supplied (or it
|
|
999
1214
|
// returns undefined), apply.ts takes its existing no-gate branch.
|
|
@@ -1052,8 +1267,19 @@ export class KubunEngine {
|
|
|
1052
1267
|
// passes drift but loses LWW still advances — it is a real observation. The
|
|
1053
1268
|
// anchor lets a local write issued right after ingesting an ahead-of-clock
|
|
1054
1269
|
// remote mutation win last-writer-wins instead of being silently lost.
|
|
1270
|
+
// Guarded: the drift bound cannot reject a malformed stamp (its NaN loses
|
|
1271
|
+
// every comparison), so this is where one would enter the device clock.
|
|
1055
1272
|
if (origin !== 'local') {
|
|
1056
|
-
|
|
1273
|
+
const observed = HLC.tryParse(mutation.hlc);
|
|
1274
|
+
if (observed == null) {
|
|
1275
|
+
this.#logger.warn('mutation carries an unparseable HLC; clock not advanced', {
|
|
1276
|
+
iss: mutation.iss,
|
|
1277
|
+
docID: mutation.sub,
|
|
1278
|
+
mutationHLC: mutation.hlc
|
|
1279
|
+
});
|
|
1280
|
+
} else {
|
|
1281
|
+
this.#hlc.receive(observed);
|
|
1282
|
+
}
|
|
1057
1283
|
}
|
|
1058
1284
|
const hash = computeMutationHash(token);
|
|
1059
1285
|
const authorDID = mutation.iss;
|
|
@@ -1105,7 +1331,14 @@ export class KubunEngine {
|
|
|
1105
1331
|
await this.#ensureHLCFloored();
|
|
1106
1332
|
// Verify all tokens upfront — fail fast before starting transaction
|
|
1107
1333
|
const verified = await Promise.all(tokens.map(async (token)=>{
|
|
1108
|
-
|
|
1334
|
+
// `methods` resolves a `did:kokuin:` issuer from the controller store.
|
|
1335
|
+
// This runs before the write transaction below opens, so the resolver
|
|
1336
|
+
// reading that store off the main connection cannot deadlock.
|
|
1337
|
+
const v = await verifyToken(token, {
|
|
1338
|
+
methods: [
|
|
1339
|
+
this.#controllerResolver
|
|
1340
|
+
]
|
|
1341
|
+
});
|
|
1109
1342
|
const mutation = asType(validateMutation, v.payload);
|
|
1110
1343
|
return {
|
|
1111
1344
|
token,
|
|
@@ -1170,7 +1403,12 @@ export class KubunEngine {
|
|
|
1170
1403
|
// the transaction provider `tx` so the lookups run inside this batch's
|
|
1171
1404
|
// transaction.
|
|
1172
1405
|
const delegationStore = await getDelegationStore(tx);
|
|
1173
|
-
|
|
1406
|
+
// Provider-scoped so a controller-issued revocation record resolves; the
|
|
1407
|
+
// batch apply's write transaction is `tx`, so route controller-store reads
|
|
1408
|
+
// through it to stay deadlock-safe.
|
|
1409
|
+
const revocationChecker = createDelegationRevocationChecker(delegationStore, [
|
|
1410
|
+
this.#controllerResolverFor(tx)
|
|
1411
|
+
]);
|
|
1174
1412
|
for (const iss of new Set(verified.map((v)=>v.mutation.iss))){
|
|
1175
1413
|
const minSigningTime = issuerAtTimes.get(iss);
|
|
1176
1414
|
const includeFallback = issuerHasMissingHLC.has(iss) || minSigningTime == null;
|
|
@@ -1194,7 +1432,9 @@ export class KubunEngine {
|
|
|
1194
1432
|
if (tx.hasStore(P2P_STORE)) {
|
|
1195
1433
|
p2pStore = await getP2PStore(tx);
|
|
1196
1434
|
}
|
|
1197
|
-
const checkWriteAccess = this.#buildWriteAccessChecker(txStore, extraTokensByIssuer, revocationChecker
|
|
1435
|
+
const checkWriteAccess = this.#buildWriteAccessChecker(txStore, extraTokensByIssuer, revocationChecker, // Provider-scoped: the batch apply's write transaction is `tx`, so the
|
|
1436
|
+
// capability check's controller-store reads must route through it.
|
|
1437
|
+
this.#controllerResolverFor(tx));
|
|
1198
1438
|
// Build the gate once from the transaction-scoped stores so its reads run
|
|
1199
1439
|
// inside this transaction; reused for every entry's independent decision.
|
|
1200
1440
|
const postStateGate = accessGate?.({
|
|
@@ -1245,9 +1485,19 @@ export class KubunEngine {
|
|
|
1245
1485
|
// Mirror the single-apply path: skip 'local' (its HLC was just minted
|
|
1246
1486
|
// by this.#hlc.now()), advance for 'peer'. Placed after
|
|
1247
1487
|
// applyMutation so a future-over-drift mutation never advances the
|
|
1248
|
-
// clock; a drift-ok mutation that loses LWW still advances.
|
|
1488
|
+
// clock; a drift-ok mutation that loses LWW still advances. A malformed
|
|
1489
|
+
// stamp is dropped here, not by the drift bound, which its NaN passes.
|
|
1249
1490
|
if (origin !== 'local') {
|
|
1250
|
-
|
|
1491
|
+
const observed = HLC.tryParse(mutation.hlc);
|
|
1492
|
+
if (observed == null) {
|
|
1493
|
+
this.#logger.warn('mutation carries an unparseable HLC; clock not advanced', {
|
|
1494
|
+
iss: mutation.iss,
|
|
1495
|
+
docID: mutation.sub,
|
|
1496
|
+
mutationHLC: mutation.hlc
|
|
1497
|
+
});
|
|
1498
|
+
} else {
|
|
1499
|
+
this.#hlc.receive(observed);
|
|
1500
|
+
}
|
|
1251
1501
|
}
|
|
1252
1502
|
const hash = computeMutationHash(token);
|
|
1253
1503
|
if (document == null) {
|
|
@@ -1319,7 +1569,11 @@ export class KubunEngine {
|
|
|
1319
1569
|
graphID: params.id,
|
|
1320
1570
|
text: params.text,
|
|
1321
1571
|
variables: params.variables ?? {},
|
|
1322
|
-
viewerDID: params.viewerDID
|
|
1572
|
+
viewerDID: params.viewerDID,
|
|
1573
|
+
// Carried onto the request's credential authority (a delegate reading a
|
|
1574
|
+
// credential presents its chain), not onto any mutation cap — a query
|
|
1575
|
+
// produces none.
|
|
1576
|
+
delegationTokens: params.delegationTokens
|
|
1323
1577
|
});
|
|
1324
1578
|
}
|
|
1325
1579
|
/**
|
|
@@ -1728,6 +1982,7 @@ export class KubunEngine {
|
|
|
1728
1982
|
text: params.text,
|
|
1729
1983
|
variables: params.variables ?? {},
|
|
1730
1984
|
viewerDID: params.viewerDID ?? signingIdentity.id,
|
|
1985
|
+
delegationTokens: params.delegationTokens,
|
|
1731
1986
|
contextExtensions
|
|
1732
1987
|
});
|
|
1733
1988
|
}
|
|
@@ -1772,6 +2027,7 @@ export class KubunEngine {
|
|
|
1772
2027
|
text: params.text,
|
|
1773
2028
|
variables: params.variables ?? {},
|
|
1774
2029
|
viewerDID: params.viewerDID ?? signingIdentity.id,
|
|
2030
|
+
delegationTokens: params.delegationTokens,
|
|
1775
2031
|
stores: tx,
|
|
1776
2032
|
contextExtensions: this.#buildGraphMutationContextExtensions({
|
|
1777
2033
|
ops,
|
|
@@ -1868,7 +2124,9 @@ export class KubunEngine {
|
|
|
1868
2124
|
graphID: params.id,
|
|
1869
2125
|
text: params.text,
|
|
1870
2126
|
variables: params.variables ?? {},
|
|
1871
|
-
viewerDID: params.viewerDID
|
|
2127
|
+
viewerDID: params.viewerDID,
|
|
2128
|
+
// Carried onto the request's credential authority, not onto a mutation cap.
|
|
2129
|
+
delegationTokens: params.delegationTokens
|
|
1872
2130
|
});
|
|
1873
2131
|
}
|
|
1874
2132
|
async getAPI(name) {
|
package/lib/executor.d.ts
CHANGED
|
@@ -16,11 +16,13 @@ export type EngineGraphParams = ExecuteGraphParams & {
|
|
|
16
16
|
*/
|
|
17
17
|
owner?: string;
|
|
18
18
|
/**
|
|
19
|
-
* Delegation tokens authorizing
|
|
20
|
-
*
|
|
21
|
-
* and verified on apply against the owner→delegate
|
|
22
|
-
*
|
|
23
|
-
*
|
|
19
|
+
* Delegation tokens authorizing actions the caller cannot take unaided. On
|
|
20
|
+
* `mutateGraph` they ride each outgoing mutation's `cap` field (part of the
|
|
21
|
+
* signed payload) and are verified on apply against the owner→delegate chain.
|
|
22
|
+
* On every operation they are also surfaced on the request's ExecutionContext
|
|
23
|
+
* so a plugin's credential authority can authorize a delegated read/administer;
|
|
24
|
+
* `queryGraph`/`subscribeToGraph` produce no mutation cap but still carry them
|
|
25
|
+
* this way.
|
|
24
26
|
*/
|
|
25
27
|
delegationTokens?: Array<string>;
|
|
26
28
|
};
|
package/lib/index.d.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
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
3
|
export { catalogMatchesDoc } from './catalog-match.js';
|
|
4
|
-
export type
|
|
5
|
-
export { createDefaultCipher, deriveAtRestKey } from './cipher.js';
|
|
4
|
+
export { type ControllerAuthorityParams, createControllerAuthority, } from './controller-authority.js';
|
|
6
5
|
export type { AccessGate, AccessGateFactory, AccessGateStores, ApplyVerifiedMutationParams, ApplyVerifiedMutationResult, ApplyVerifiedMutationsParams, ApplyVerifiedMutationsResult, ContextFactory, DocumentWrite, EngineParams, ExecuteParams, ExecutionContext, MutateDocumentsParams, } from './engine.js';
|
|
7
6
|
export { KubunEngine } from './engine.js';
|
|
8
7
|
export type { EngineEvents } from './engine-events.js';
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { createAccessChecker, parseDocumentAccessPermissions, resolveAccessRule, validateDIDs, validateID, validateIDs } from './access-control.js';
|
|
2
2
|
export { catalogMatchesDoc } from './catalog-match.js';
|
|
3
|
-
export {
|
|
3
|
+
export { createControllerAuthority } from './controller-authority.js';
|
|
4
4
|
export { KubunEngine } from './engine.js';
|
|
5
5
|
export { isTransactionFatal, TransactionFatalError } from './errors.js';
|
|
6
6
|
export { EngineEventBus } from './events.js';
|
package/lib/plugin.d.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import type { Identity } from '@kokuin/token';
|
|
2
|
-
import type { KubunDB } from '@kubun/db';
|
|
1
|
+
import type { DIDMethodResolver, Identity } from '@kokuin/token';
|
|
2
|
+
import type { KubunDB, StoreProvider } from '@kubun/db';
|
|
3
3
|
import type { ExtensionResolvers } from '@kubun/graphql';
|
|
4
4
|
import type { HLC } from '@kubun/hlc';
|
|
5
5
|
import type { Logger } from '@kubun/logger';
|
|
6
6
|
import type { Runtime } from '@sozai/runtime';
|
|
7
7
|
import type { ExecutionResult } from 'graphql';
|
|
8
|
-
import type { Cipher } from './cipher.js';
|
|
9
8
|
import type { ApplyVerifiedMutationParams, ApplyVerifiedMutationResult, ApplyVerifiedMutationsParams, ApplyVerifiedMutationsResult, ContextFactory, ExecuteParams, MutateDocumentsParams } from './engine.js';
|
|
10
9
|
import type { EngineEvents } from './engine-events.js';
|
|
11
10
|
import type { EngineEventBus } from './events.js';
|
|
@@ -64,9 +63,21 @@ export type PluginFactoryParams = {
|
|
|
64
63
|
identity: Identity;
|
|
65
64
|
eventBus: EngineEventBus<EngineEvents>;
|
|
66
65
|
hlc: HLC;
|
|
66
|
+
/**
|
|
67
|
+
* How far into the future a stamp arriving from a peer may sit, in
|
|
68
|
+
* milliseconds — the engine's own bound, so a plugin ingesting peer data
|
|
69
|
+
* bounds it the same way the graph lane does rather than configuring a second
|
|
70
|
+
* one of its own.
|
|
71
|
+
*/
|
|
72
|
+
maxDriftMS: number;
|
|
67
73
|
getLogger: (name: string) => Logger;
|
|
68
|
-
/**
|
|
69
|
-
|
|
74
|
+
/**
|
|
75
|
+
* The engine's `did:kokuin:` controller resolver, scoped to a store provider.
|
|
76
|
+
* A plugin wrapping credentials to a `did:kokuin:` recipient must resolve that
|
|
77
|
+
* controller through the request's transaction provider — building its own
|
|
78
|
+
* resolver over the base db would read outside the tx.
|
|
79
|
+
*/
|
|
80
|
+
controllerResolverFor: (stores: StoreProvider) => DIDMethodResolver;
|
|
70
81
|
};
|
|
71
82
|
/**
|
|
72
83
|
* Plugin descriptor returned by a plugin factory.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"keywords": [],
|
|
5
5
|
"license": "see LICENSE.md",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -15,32 +15,40 @@
|
|
|
15
15
|
"LICENSE.md"
|
|
16
16
|
],
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@kokuin/capability": "^0.
|
|
19
|
-
"@kokuin/
|
|
20
|
-
"@
|
|
21
|
-
"@noble/
|
|
18
|
+
"@kokuin/capability": "^0.3.0",
|
|
19
|
+
"@kokuin/controller": "^0.1.0",
|
|
20
|
+
"@kokuin/token": "^0.5.0",
|
|
21
|
+
"@noble/ciphers": "^2.3.0",
|
|
22
|
+
"@noble/hashes": "^2.3.0",
|
|
22
23
|
"@sozai/async": "^0.2.1",
|
|
23
24
|
"@sozai/codec": "^0.4.0",
|
|
24
25
|
"@sozai/event": "^0.1.3",
|
|
25
26
|
"@sozai/runtime": "^0.1.0",
|
|
26
27
|
"@sozai/schema": "^0.1.1",
|
|
27
28
|
"graphql": "^16.14.2",
|
|
28
|
-
"@kubun/db": "^0.
|
|
29
|
-
"@kubun/
|
|
30
|
-
"@kubun/
|
|
31
|
-
"@kubun/
|
|
32
|
-
"@kubun/
|
|
33
|
-
"@kubun/
|
|
34
|
-
"@kubun/
|
|
35
|
-
"@kubun/
|
|
36
|
-
"@kubun/store-graph": "^0.
|
|
37
|
-
"@kubun/store-p2p": "^0.
|
|
29
|
+
"@kubun/db": "^0.13.0",
|
|
30
|
+
"@kubun/db-adapter": "^0.13.0",
|
|
31
|
+
"@kubun/graphql": "^0.13.0",
|
|
32
|
+
"@kubun/credential": "^0.13.0",
|
|
33
|
+
"@kubun/identity": "^0.13.0",
|
|
34
|
+
"@kubun/logger": "^0.13.0",
|
|
35
|
+
"@kubun/protocol": "^0.13.0",
|
|
36
|
+
"@kubun/store-delegation": "^0.13.0",
|
|
37
|
+
"@kubun/store-graph": "^0.13.0",
|
|
38
|
+
"@kubun/store-p2p": "^0.13.0",
|
|
39
|
+
"@kubun/store-controller": "^0.13.0",
|
|
40
|
+
"@kubun/mutation": "^0.13.0",
|
|
41
|
+
"@kubun/hlc": "^0.13.0"
|
|
38
42
|
},
|
|
39
43
|
"devDependencies": {
|
|
40
44
|
"@testcontainers/postgresql": "^12.1.0",
|
|
41
|
-
"@kubun/
|
|
42
|
-
"@kubun/
|
|
43
|
-
"@kubun/
|
|
45
|
+
"@kubun/db-postgres": "^0.13.0",
|
|
46
|
+
"@kubun/id": "^0.13.0",
|
|
47
|
+
"@kubun/store-credential": "^0.13.0",
|
|
48
|
+
"@kubun/test-utils": "^0.13.0"
|
|
49
|
+
},
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
44
52
|
},
|
|
45
53
|
"scripts": {
|
|
46
54
|
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|
package/lib/cipher.d.ts
DELETED
package/lib/cipher.js
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
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
|
-
}
|