@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.
@@ -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 = {
@@ -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
+ }
@@ -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 { VerifyTokenHook } from '@kokuin/capability';
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
- * Explicit at-rest cipher override. When omitted the engine derives one from
81
- * an `OwnIdentity` (full identity carrying a `privateKey`), and leaves it
82
- * undefined for read-only identities. The app/CLI wiring layer is responsible
83
- * for building an env-key-based cipher (e.g. from `KUBUN_AT_REST_KEY`) and
84
- * passing it here — the engine itself never reads `process.env`.
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
- cipher?: Cipher;
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 { isOwnIdentity, isSigningIdentity, stringifyToken, verifyToken } from '@kokuin/token';
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 { createDefaultCipher, deriveAtRestKey } from './cipher.js';
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
- #cipher;
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: params.identity.id
291
+ nodeID
232
292
  });
233
- this.#maxDriftMS = params.maxDriftMS ?? 3_600_000;
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
- cipher: this.#cipher
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
- const parsed = HLC.parse(max);
422
- const ceiling = Date.now() + this.#maxDriftMS;
423
- const flooredWallTime = Math.min(parsed.wallTime, ceiling);
424
- this.#hlc.receive({
425
- ...parsed,
426
- wallTime: flooredWallTime
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
- */ #createExecutionContext(viewerDID) {
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 before opening the
925
- // transaction, mirroring the batch path which verifies upfront.
926
- const verified = await verifyToken(params.token);
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
- const revocationChecker = createDelegationRevocationChecker(delegationStore);
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
- this.#hlc.receive(HLC.parse(mutation.hlc));
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
- const v = await verifyToken(token);
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
- const revocationChecker = createDelegationRevocationChecker(delegationStore);
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
- this.#hlc.receive(HLC.parse(mutation.hlc));
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 writes to documents the caller does not own.
20
- * Carried on each outgoing mutation's `cap` field (part of the signed payload)
21
- * and verified on apply against the owner→delegate capability chain. Used by
22
- * `mutateGraph` only ignored by `queryGraph`/`subscribeToGraph`, which do
23
- * not produce mutations.
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 { Cipher } from './cipher.js';
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 { createDefaultCipher, deriveAtRestKey } from './cipher.js';
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
- /** Shared at-rest cipher; undefined when no key is available. */
69
- cipher: Cipher | undefined;
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.12.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.2.1",
19
- "@kokuin/token": "^0.3.0",
20
- "@noble/ciphers": "^2.2.0",
21
- "@noble/hashes": "^2.2.0",
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.12.0",
29
- "@kubun/hlc": "^0.12.0",
30
- "@kubun/db-adapter": "^0.12.0",
31
- "@kubun/logger": "^0.12.0",
32
- "@kubun/graphql": "^0.12.0",
33
- "@kubun/store-delegation": "^0.12.0",
34
- "@kubun/mutation": "^0.12.0",
35
- "@kubun/protocol": "^0.12.0",
36
- "@kubun/store-graph": "^0.12.0",
37
- "@kubun/store-p2p": "^0.12.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/id": "^0.12.0",
42
- "@kubun/db-postgres": "^0.12.0",
43
- "@kubun/test-utils": "^0.12.0"
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
@@ -1,6 +0,0 @@
1
- export type Cipher = {
2
- encrypt(plaintext: string): string;
3
- decrypt(ciphertext: string): string;
4
- };
5
- export declare function deriveAtRestKey(ikm: Uint8Array): Uint8Array;
6
- export declare function createDefaultCipher(key: Uint8Array): Cipher;
package/lib/cipher.js 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
- }