-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathdkg-publisher.ts
More file actions
6677 lines (6311 loc) · 315 KB
/
Copy pathdkg-publisher.ts
File metadata and controls
6677 lines (6311 loc) · 315 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import type { Quad, TripleStore } from '@origintrail-official/dkg-storage';
import type { ChainAdapter, OnChainPublishResult, AddBatchToContextGraphParams } from '@origintrail-official/dkg-chain';
import { enrichEvmError } from '@origintrail-official/dkg-chain';
import type { EventBus, OperationContext } from '@origintrail-official/dkg-core';
import { DKGEvent, Logger, createOperationContext, sha256, encodeWorkspacePublishRequest, encodeEncryptedWorkspacePayload, encryptWorkspacePayload, contextGraphDataUri, contextGraphDataGraphUri, contextGraphMetaUri, contextGraphAssertionUri, contextGraphLayerUri, MemoryLayer, assertionLifecycleUri, contextGraphSubGraphUri, contextGraphSubGraphMetaUri, SYSTEM_CONTEXT_GRAPHS, validateSubGraphName, isSafeIri, assertSafeIri, assertSafeRdfTerm, assertQuadLiteralsMutf8Safe, DKG_GOSSIP_MAX_MESSAGE_BYTES, SwmGossipPayloadTooLargeError, STORAGE_ACK_MAX_STAGING_BYTES, type Ed25519Keypair, buildAuthorAttestationTypedData, buildUpdateAuthorAttestationTypedData, AUTHOR_SCHEME_VERSION_V1, TrustLevel, TRUST_LEVEL_PREDICATE, assertNoUserAuthoredTrustLevelQuads, buildTrustLevelQuads, isTrustLevelQuad, isSwmMerkleExcludedQuad, WORKSPACE_OWNER_PREDICATE, DKG_ENTITY, DKG_ROOT_ENTITY_LEGACY, ENTITY_PRED_ALT, parseAssertionSealQuads, ASSERTION_SEAL_PREDICATES, sharedMemoryReadBothFilter, DKG_ONTOLOGY, awaitTailWithGrace, resolvePublishTailGraceMs } from '@origintrail-official/dkg-core';
import { GraphManager, PrivateContentStore, loadSelectedSharedMemoryQuads } from '@origintrail-official/dkg-storage';
import { DEFAULT_PUBLISH_EPOCHS, MAX_PUBLISH_EPOCHS, type Publisher, type PublishOptions, type PublishResult, type KAManifestEntry, type PhaseCallback, type V10CoreNodeACK, type V10ACKProviderParams, type V10ACKProviderObject, type LegacyV10ACKProvider } from './publisher.js';
import { skolemizeByEntity } from './auto-partition.js';
import { withKeyedLocks } from './keyed-lock.js';
import { tagPromoteStep } from './promote-step-tag.js';
import { canonicalPublishPayload } from './canonical-publish-payload.js';
import {
assertTrustedCatalogTriplesAreGeneratedFloor,
catalogTripleKey,
splitTrustedGeneratedCatalogRootMap,
trustedCatalogTripleKeySet,
} from './catalog-trust.js';
import { partitionCatalogQuads, catalogCommittedLeaves, computeCatalogRoot, contextGraphCatalogUri, isAgentRegistryContextGraph } from '@origintrail-official/dkg-core';
import { RESERVED_SUBJECT_PREFIXES, findReservedSubjectPrefix, isReservedSubject } from './reserved-subjects.js';
import { skolemize } from './skolemize.js';
import {
computeTripleHashV10 as computeTripleHash,
computePrivateRootV10 as computePrivateRoot,
computeFlatKCRootV10 as computeFlatKCRoot,
computeFlatKCMerkleLeafCountV10,
} from './merkle.js';
import { validatePublishRequest } from './validation.js';
import { isFailClosedInlineEncrypt } from './async-lift-publish-options.js';
import {
assertionOriginalGraph,
assertionScopedGraphUri,
listAssertionScopedGraphUris,
listGraphsByPrefix,
} from './assertion-scoped-graphs.js';
import {
generateConfirmedFullMetadata,
buildDeterministicTokenRows,
compareRootIris,
generateOwnershipQuads,
generateAssertionCreatedMetadata,
generateAssertionPromotedMetadata,
generateAssertionDiscardedMetadata,
generateTentativeMetadata,
WM_CURRENT_ASSERTION_PRED,
SWM_CURRENT_ASSERTION_PRED,
VM_CURRENT_ASSERTION_PRED,
toHex,
buildScopedMinimalMeta,
resolveUalByBatchId,
promoteUpdatedKaToPerCgId,
restateLabelGraphForUpdate,
shouldApplyMaterialization,
withMaterializationLock,
writeMaterializedVersion,
type MaterializedVersion,
type KAMetadata,
} from './metadata.js';
import { storeWorkspaceOperationPublicQuads } from './workspace-resolution.js';
import type { WorkspacePublicSnapshotStore } from './workspace-snapshot-store.js';
import { ethers } from 'ethers';
import type { WorkspaceAgentRecipientResolver } from './workspace-agent-recipients.js';
import {
PublisherWalletRequiredError,
StaleWriteError,
ReservedNamespaceError,
AssertionNotPersistedError,
MultiRootPublishNotAtomicError,
CuratorUnconfirmedError,
CuratorRejectedError,
type CASCondition,
} from './errors.js';
import { isQuorumUnmetError } from './ack-errors.js';
import { PublishLifecycleLogger } from './publish-lifecycle-logger.js';
export { RESERVED_SUBJECT_PREFIXES, findReservedSubjectPrefix, isReservedSubject } from './reserved-subjects.js';
// Typed errors + the CAS condition payload live in ./errors.js now; re-export
// them here so `./dkg-publisher.js` (and the package index, which re-exports
// from this module) stays the stable import path for every consumer.
export {
PublisherWalletRequiredError,
StaleWriteError,
ReservedNamespaceError,
AssertionNotPersistedError,
MultiRootPublishNotAtomicError,
CuratorUnconfirmedError,
CuratorRejectedError,
type CASCondition,
};
// #1116 (review A1) — marker predicate stamped on the lifecycle URN when a KA
// has been FULLY shared to SWM (entities:"all", all roots landed). It gates
// finalize(layer:"swm") so a subset share — which also stamps dkg:rootEntity
// member rows — cannot be sealed-in-SWM and published as a partial asset.
const SWM_SHARE_COMPLETE_PRED = 'http://dkg.io/ontology/swmShareComplete';
const SHARE_OPERATION_ID_PRED = 'http://dkg.io/ontology/shareOperationId';
async function listGraphFamily(store: TripleStore, rootGraph: string): Promise<string[]> {
const graphs = await listGraphsByPrefix(store, `${rootGraph}/`);
if (await store.hasGraph(rootGraph)) {
graphs.unshift(rootGraph);
}
return graphs;
}
/**
* Minimal structural view of the OT-RFC-43 Option-1 KA-number allocator the
* publisher needs to mint deterministic packed ids. The concrete
* `KaNumberAllocator` (packages/agent) satisfies this; typing it structurally
* here avoids an agent→publisher dependency cycle.
*/
export interface KaIdAllocator {
/** Allocate the next packed kaId = (uint160(author)<<96)|number for `author`. */
allocate(author: string): { kaId: bigint; number: bigint };
/** Raise the per-author floor to `observedNumber + 1` (never lower) so the next allocate skips minted numbers.
* `observedNumber` is a `bigint` end-to-end (OT-RFC-43 Option-1, PR #976 F6) — the per-author number can
* exceed 2^53, so `Number` would silently lose precision and let the allocator re-issue a minted id. */
reconcile(author: string, observedNumber: bigint): void;
/** Satisfy the allocator's cold-start guard once reconciliation has run. */
markReconciled(): void;
}
export interface DKGPublisherConfig {
store: TripleStore;
chain: ChainAdapter;
eventBus: EventBus;
keypair: Ed25519Keypair;
publisherNodeIdentityId?: bigint;
publisherAddress?: string;
/** Retryable publisher address resolver for adapter-backed signing. */
publisherAddressResolver?: (contextGraphId?: bigint) => Promise<string | undefined>;
/** EVM private key for signing publish requests (hex string with 0x prefix) */
publisherPrivateKey?: string;
/**
* Additional EVM private keys whose identities can act as receiver
* signers for the `contextGraphSignatures` path of `publishSharedMemory`
* (the post-confirmation context-graph verify step). NOT used for V10
* StorageACK collection — ACKs are always gathered from real connected
* core peers via `ack-collector.ts`.
*/
additionalSignerKeys?: string[];
/** Shared map of SWM-owned rootEntities per context graph: entity → creatorPeerId. Pass from agent so handler and publisher stay in sync. */
sharedMemoryOwnedEntities?: Map<string, Map<string, string>>;
/** Shared batch→context graph binding map. Pass to UpdateHandler so it uses trusted local bindings. */
knownBatchContextGraphs?: Map<string, string>;
/** Shared write lock map. Pass to SharedMemoryHandler so gossip writes serialize against CAS writes. */
writeLocks?: Map<string, Promise<void>>;
/** Resolves DKG-agent public encryption keys for private/agent-gated remote SWM gossip. */
workspaceAgentRecipientResolver?: WorkspaceAgentRecipientResolver;
/** Encrypts private/agent-gated SWM gossip with the node's Sender Key epoch state. */
workspaceSenderKeyEncryptor?: WorkspaceSenderKeyEncryptor;
/** Optional out-of-Oxigraph store for immutable public SWM operation snapshots. */
publicSnapshotStore?: WorkspacePublicSnapshotStore;
/**
* OT-RFC-43 Option 1 — when present, the publisher allocates a deterministic
* packed reservedKaId for each V10 mint (and reconciles the per-author floor
* against the chain on first use). Omit for mock/no-chain or pre-Option-1
* flows; the real EVM adapter then throws on the missing reservedKaId.
*/
kaAllocator?: KaIdAllocator;
/**
* RFC ka-metadata-trim Phase 3 (P3.3) — `metadata.provenanceEvents` config.
* Default `true`. When `false` ("lite mode"), the lifecycle writers skip the
* per-transition PROV event nodes (`dkg:AssertionCreated` /
* `dkg:AssertionPromoted` activities) but keep every state/identity row on
* the lifecycle subject; the history API returns `events: []` gracefully.
*/
provenanceEvents?: boolean;
}
export interface WorkspaceSenderKeyEncryptInput {
contextGraphId: string;
plaintext: Uint8Array;
senderAgentAddress: string;
operationId: string;
shareOperationId: string;
timestampMs: number;
subGraphName?: string;
publisherPeerId: string;
}
export type WorkspaceSenderKeyEncryptor = (
input: WorkspaceSenderKeyEncryptInput,
) => Promise<Uint8Array>;
interface PublisherAddressResolutionOptions {
includeReservingPublisherProbe?: boolean;
includeGenericSignMessageProbe?: boolean;
}
function normalizePublisherAddress(address: string | undefined): string | undefined {
if (address === undefined) return undefined;
if (!ethers.isAddress(address)) {
throw new Error(`Invalid publisherAddress: "${address}" is not a valid EVM address`);
}
const normalized = ethers.getAddress(address);
if (normalized === ethers.ZeroAddress) {
throw new Error('Invalid publisherAddress: zero address is not a valid publisher');
}
return normalized;
}
function resolvePublishEpochsOverride(value: number | undefined): number | undefined {
if (value === undefined) return undefined;
if (!Number.isSafeInteger(value) || value < 1 || value > MAX_PUBLISH_EPOCHS) {
throw new Error(`publishEpochs must be a positive uint32 integer, got ${String(value)}`);
}
return value;
}
function isLegacyV10ACKProvider(
provider: NonNullable<PublishOptions['v10ACKProvider']>,
): provider is LegacyV10ACKProvider {
return provider.length > 1;
}
async function invokeV10ACKProvider(
provider: NonNullable<PublishOptions['v10ACKProvider']>,
params: V10ACKProviderParams,
): Promise<V10CoreNodeACK[]> {
if (!isLegacyV10ACKProvider(provider)) {
return (provider as V10ACKProviderObject)(params);
}
if (params.ackMode.kind === 'folded-private') {
throw new Error(
'Folded-private V10 ACK collection requires object-form v10ACKProvider ' +
'so privateMerkleRoots reach the ACK collector.',
);
}
const catalogCommitment = params.ackMode.kind === 'curated-catalog'
? params.ackMode.catalogCommitment
: undefined;
return provider(
params.merkleRoot,
params.contextGraphId,
params.kaCount,
params.rootEntities,
params.publicByteSize,
params.stagingQuads,
params.epochs,
params.tokenAmount,
params.swmGraphId,
params.subGraphName,
params.merkleLeafCount,
params.ackMode.kind === 'curated-catalog' ? true : undefined,
catalogCommitment,
);
}
function coercePublisherAddress(value: unknown): string | undefined {
if (typeof value !== 'string' || !ethers.isAddress(value)) return undefined;
const normalized = ethers.getAddress(value);
return normalized === ethers.ZeroAddress ? undefined : normalized;
}
function publisherAddressFromUal(ual: string | undefined): string | undefined {
const prefix = 'did:dkg:';
if (!ual?.startsWith(prefix)) return undefined;
const segments = ual.slice(prefix.length).split('/');
return coercePublisherAddress(segments[1]);
}
function formatBytesAsKb(bytes: number): string {
return `${(bytes / 1024).toFixed(0)} KB`;
}
function formatGossipLimit(bytes: number): string {
const mb = 1024 * 1024;
if (bytes % mb === 0) return `${bytes / mb} MB`;
return formatBytesAsKb(bytes);
}
function recoverCompactMessageSigner(
message: Uint8Array,
signature: { r: Uint8Array; vs: Uint8Array },
): string {
const serialized = ethers.Signature.from({
r: ethers.hexlify(signature.r),
yParityAndS: ethers.hexlify(signature.vs),
}).serialized;
return ethers.verifyMessage(message, serialized);
}
export interface ShareOptions {
publisherPeerId: string;
operationCtx?: OperationContext;
subGraphName?: string;
localOnly?: boolean;
senderAgentAddress?: string;
/**
* Strict curator-ack gate (OT-RFC-49 curator-leader). When provided, the
* share path calls this AFTER building the signed wire message but BEFORE any
* destructive store mutation, passing the exact message that would be
* published. If it resolves `applied: false`, the write is ABORTED with NO
* local persistence — `CuratorRejectedError` when `rejected: true`, otherwise
* `CuratorUnconfirmedError`. The agent injects this to require the curator (the
* authoritative replica) to have applied the write before the member commits
* it locally, so a write the curator never received is never silently accepted.
* Omitted (the default) preserves the legacy best-effort, commit-then-fan-out
* behaviour for public CGs, `localOnly` writes, and non-gated callers.
*/
confirmBeforeCommit?: (message: Uint8Array) => Promise<{ applied: boolean; rejected?: boolean }>;
}
/** @deprecated Use ShareOptions */
export type WriteToWorkspaceOptions = ShareOptions;
export interface ShareResult {
shareOperationId: string;
message: Uint8Array;
}
/** @deprecated Use ShareResult */
export type WriteToWorkspaceResult = ShareResult;
export interface ConditionalShareOptions extends ShareOptions {
conditions: CASCondition[];
}
/** @deprecated Use ConditionalShareOptions */
export type ShareConditionalOptions = ConditionalShareOptions;
/** @deprecated Use ConditionalShareOptions */
export type WriteConditionalToWorkspaceOptions = ConditionalShareOptions;
// Round 9 Bug 25: protocol-reserved URN namespaces that MUST NOT appear
// as subjects in user-authored quads. These prefixes are owned by the
// daemon's import-file handler for file descriptors and extraction
// provenance per `19_MARKDOWN_CONTENT_TYPE.md §10.2`. Allowing user
// writes here would (a) collide with daemon bookkeeping across assertions
// and (b) get silently stripped by `assertionPromote`'s safety filter,
// which would be data loss from the user's perspective. Reject at the
// write boundary with a clear error that names the reserved prefix.
//
// The daemon's own import-file handler bypasses `assertion.write` via a
// direct `store.insert` (documented in `daemon.ts`), so the guard here
// only fires on user-facing entry points and never on the daemon's
// internal bookkeeping writes.
//
// Prefix form matches the `assertionPromote` defense-in-depth filter:
// bare `urn:dkg:file:` (not `urn:dkg:file:keccak256:`) so any future
// hash-algorithm variant (e.g., `urn:dkg:file:blake3:...`) is also
// covered without a guard update.
// Round 12 Bug 34: module-private token proving an internal caller
// (specifically `publishFromSharedMemory`) is the origin of a
// `publish()` call so the reserved-namespace guard can be bypassed
// for legitimate internal promote→publish flows WITHOUT exposing a
// public flag that external callers could set to bypass the guard.
//
// Round 9 Bug 25 used `options.fromSharedMemory` as the discriminator,
// but `fromSharedMemory` is a public `PublishOptions` field with its
// own user-facing semantic (signals to the V10 ACK path that data is
// already in peers' SWM). Any external caller could set it `true` and
// trivially bypass the guard, making `urn:dkg:file:*` writes possible
// via the public API — the exact class of bypass Round 9 was supposed
// to prevent. Codex Bug 34 caught this.
//
// The token is a module-scoped `Symbol` with no external references.
// Only code in this file can mint it. Public callers cannot forge it.
// Bypassing the guard therefore requires either being in this file
// (and thus code-reviewed for correctness) or not calling the guarded
// public entry points at all (the daemon's direct `store.insert`
// bypass, which is the other legitimate non-guard path).
const INTERNAL_ORIGIN_TOKEN = Symbol('dkg-publisher:internal-origin');
const TRUSTED_CATALOG_ORIGIN_TOKEN = Symbol('dkg-publisher:trusted-catalog-origin');
const PUBLIC_ACK_STAGING_MODE_TOKEN = Symbol('dkg-publisher:public-ack-staging-mode');
type PublicACKStagingMode = 'inline' | 'strict-swm' | 'inline-small-swm';
type InternalPublishOptions = PublishOptions & {
[INTERNAL_ORIGIN_TOKEN]?: true;
[TRUSTED_CATALOG_ORIGIN_TOKEN]?: true;
[PUBLIC_ACK_STAGING_MODE_TOKEN]?: PublicACKStagingMode;
};
interface PublisherSigner {
address: string;
source: 'publisherPrivateKey' | 'chainAdapter';
signMessage(message: Uint8Array): Promise<string>;
/**
* Sign EIP-712 typed data. Required for RFC-001 author attestations
* which use `\x19\x01` framing rather than the EIP-191 prefix that
* `signMessage` applies. Native on `ethers.Wallet`; chain-adapter
* fallbacks throw because the adapter's `signMessage` / `signMessageAs`
* surface only handles EIP-191 hashes.
*/
signTypedData(
domain: ethers.TypedDataDomain,
types: Record<string, Array<{ name: string; type: string }>>,
value: Record<string, unknown>,
): Promise<string>;
}
function isInternalOrigin(options: PublishOptions): boolean {
return (options as InternalPublishOptions)[INTERNAL_ORIGIN_TOKEN] === true;
}
function isTrustedCatalogInternalOrigin(options: PublishOptions): boolean {
return (options as InternalPublishOptions)[TRUSTED_CATALOG_ORIGIN_TOKEN] === true;
}
function resolvePublicACKStagingMode(options: PublishOptions): PublicACKStagingMode {
return (options as InternalPublishOptions)[PUBLIC_ACK_STAGING_MODE_TOKEN]
?? (options.fromSharedMemory ? 'strict-swm' : 'inline');
}
function selectPublicStagingQuads(
mode: PublicACKStagingMode,
publicNquadsBytes: Uint8Array,
): Uint8Array | undefined {
if (mode === 'strict-swm') return undefined;
if (mode === 'inline-small-swm' && publicNquadsBytes.length > STORAGE_ACK_MAX_STAGING_BYTES) {
return undefined;
}
return publicNquadsBytes;
}
function stripOptionalLiteral(value: string | undefined): string | undefined {
if (!value) return undefined;
if (value.startsWith('"')) {
try {
return JSON.parse(value);
} catch {
const lastQuote = value.lastIndexOf('"');
return value.slice(1, lastQuote > 0 ? lastQuote : undefined);
}
}
return value;
}
function sameBigIntLiteral(left: string | bigint | undefined, right: string | bigint | undefined): boolean {
if (left === undefined || right === undefined) return false;
try {
return BigInt(left) === BigInt(right);
} catch {
return false;
}
}
// Round 14 Bug 41: case-insensitive check against `RESERVED_SUBJECT_PREFIXES`.
// Per RFC 8141 §3.1, the URN scheme (`urn:`) and NID (`dkg`) are
// case-insensitive for equivalence purposes — `URN:dkg:file:abc`,
// `urn:DKG:file:abc`, and `urn:dkg:file:abc` are all the same resource.
// The NSS portion is case-sensitive by default but our reserved
// prefixes (`urn:dkg:file:`, `urn:dkg:extraction:`) are entirely
// within the scheme+NID range, so lowercase-then-startsWith on the
// full subject string is the correct comparison: it accepts all
// case variants of the scheme/NID without over-matching into
// NSS-level content.
//
// Earlier rounds used a byte-level `subject.startsWith(prefix)` check
// at both the Bucket A write-boundary guard (Round 9 Bug 25) AND the
// Round 4 promote-time filter (Round 12 Bug 35 SSOT). Both were
// case-sensitive, so a malicious or accidentally-mixed-case subject
// like `URN:dkg:file:keccak256:<hex>` bypassed both defenses. Codex
// Bug 41 flagged this. The fix replaces both byte-level comparisons
// with the shared case-insensitive helper from `reserved-subjects.ts`,
// preserving the SSOT property established in Round 12.
function rejectReservedSubjectPrefixes(quads: Quad[]): void {
for (const q of quads) {
if (isReservedSubject(q.subject)) {
// Find the specific prefix that matched (for the error message)
// — re-scan with the lowercased subject since the constants are
// lowercase. Byte-level comparison here is fine because by this
// point we've already confirmed a match exists.
throw new ReservedNamespaceError(q.subject, findReservedSubjectPrefix(q.subject)!);
}
}
}
function rejectUserAuthoredProtocolMetadata(quads: Quad[]): void {
rejectReservedSubjectPrefixes(quads);
assertNoUserAuthoredTrustLevelQuads(quads);
}
function normalizeAssertionInputGraph(
contextGraphId: string,
subGraphName: string | undefined,
wmGraphUri: string,
graph: string,
): string {
if (graph === '') return '';
// RDF parsers and older scripts often carry the DKG physical storage graph in
// the quad graph term. That is placement metadata, not user-authored RDF
// named-graph identity, so keep it in the KA default graph. Only normalize
// exact physical graph URIs; other DKG context-graph DIDs remain named graphs.
const physicalGraphs = new Set([
wmGraphUri,
contextGraphDataUri(contextGraphId),
...(subGraphName ? [contextGraphDataUri(contextGraphId, subGraphName)] : []),
]);
if (physicalGraphs.has(graph)) return '';
return graph;
}
function rejectOversizedRdfLiterals(quads: Quad[], label: string): void {
assertQuadLiteralsMutf8Safe(quads, { label });
}
async function stampTrustLevel(
store: TripleStore,
graph: string,
subjects: Iterable<string>,
level: TrustLevel,
): Promise<void> {
const quads = buildTrustLevelQuads(subjects, level, graph) as Quad[];
for (const quad of quads) {
await store.deleteByPattern({
graph: quad.graph,
subject: quad.subject,
predicate: TRUST_LEVEL_PREDICATE,
});
}
if (quads.length > 0) {
await store.insert(quads);
}
}
function collectTrustSubjectsForRoots(
quads: Iterable<Pick<Quad, 'subject'>>,
roots: Iterable<string>,
): string[] {
const rootSet = new Set([...roots].filter(Boolean));
const subjects = new Set(rootSet);
for (const quad of quads) {
for (const root of rootSet) {
if (quad.subject === root || quad.subject.startsWith(`${root}/.well-known/genid/`)) {
subjects.add(quad.subject);
break;
}
}
}
return [...subjects];
}
function isNoDataInSwmFailure(err: unknown): boolean {
const seen = new Set<unknown>();
const stack: unknown[] = [err];
while (stack.length > 0) {
const current = stack.pop();
if (current == null || seen.has(current)) continue;
seen.add(current);
if (current instanceof Error) {
if (`${current.name} ${current.message}`.includes('NO_DATA_IN_SWM')) return true;
stack.push((current as Error & { cause?: unknown }).cause);
} else if (typeof current === 'object') {
const record = current as Record<string, unknown>;
for (const key of ['reason', 'code', 'message', 'legacyMessage', 'declineCode', 'declineMessage']) {
const value = record[key];
if (typeof value === 'string' && value.includes('NO_DATA_IN_SWM')) return true;
}
const peerOutcomes = record.peerOutcomes;
if (Array.isArray(peerOutcomes)) stack.push(...peerOutcomes);
if ('cause' in record) stack.push(record.cause);
} else if (String(current).includes('NO_DATA_IN_SWM')) {
return true;
}
}
return false;
}
export class DKGPublisher implements Publisher {
private readonly store: TripleStore;
private readonly chain: ChainAdapter;
private readonly eventBus: EventBus;
private readonly keypair: Ed25519Keypair;
private readonly graphManager: GraphManager;
private readonly privateStore: PrivateContentStore;
private readonly ownedEntities = new Map<string, Set<string>>();
private readonly sharedMemoryOwnedEntities: Map<string, Map<string, string>>;
readonly knownBatchContextGraphs: Map<string, string>;
private publisherNodeIdentityId: bigint;
private readonly publisherAddress?: string;
private readonly publisherAddressResolver?: (contextGraphId?: bigint) => Promise<string | undefined>;
private readonly publisherWallet?: ethers.Wallet;
private adapterSignMessagePublisherAddress?: string;
private readonly adapterSignMessageProbeCache = new Map<string, boolean>();
private workspaceAgentRecipientResolver?: WorkspaceAgentRecipientResolver;
private workspaceSenderKeyEncryptor?: WorkspaceSenderKeyEncryptor;
/** Additional wallets that can provide receiver signatures. */
private readonly additionalSignerWallets: ethers.Wallet[] = [];
private readonly log = new Logger('DKGPublisher');
private readonly sessionId = Date.now().toString(36);
private tentativeCounter = 0;
readonly writeLocks: Map<string, Promise<void>>;
private readonly publicSnapshotStore?: WorkspacePublicSnapshotStore;
/** OT-RFC-43 Option 1 — deterministic KA-id allocator (optional; see DKGPublisherConfig). */
private readonly kaAllocator?: KaIdAllocator;
/** Authors whose allocator floor has been reconciled against the chain this process. */
private readonly reconciledKaAuthors = new Set<string>();
/** RFC ka-metadata-trim P3.3 — gate for the lifecycle PROV event rows (default true). */
private readonly provenanceEvents: boolean;
constructor(config: DKGPublisherConfig) {
this.store = config.store;
this.chain = config.chain;
this.kaAllocator = config.kaAllocator;
this.provenanceEvents = config.provenanceEvents !== false;
this.eventBus = config.eventBus;
this.keypair = config.keypair;
this.publisherNodeIdentityId = config.publisherNodeIdentityId ?? 0n;
this.publisherAddressResolver = config.publisherAddressResolver;
const configuredPublisherAddress = normalizePublisherAddress(config.publisherAddress);
if (config.publisherPrivateKey) {
this.publisherWallet = new ethers.Wallet(config.publisherPrivateKey);
this.publisherAddress = this.publisherWallet.address;
if (
configuredPublisherAddress &&
configuredPublisherAddress.toLowerCase() !== this.publisherAddress.toLowerCase()
) {
throw new Error(
`publisherAddress (${configuredPublisherAddress}) does not match publisherPrivateKey signer ` +
`(${this.publisherAddress})`,
);
}
} else {
// No private key supplied means no in-process publisher signing
// capability. Keep an optional, validated address only for callers
// that route signing through their ChainAdapter (e.g. adapter-backed
// or hardware-signer deployments). Chain-backed publish still fails
// unless that address is backed by ChainAdapter.signMessageAs() or
// signMessage(); update can let the adapter select its signer from the
// configured signer pool.
//
// The previous behaviour generated an ephemeral `Wallet.createRandom()`
// here whenever chain was enabled, which produced unverifiable
// signatures attributed to a throw-away address. We also must not use
// `0x000...000` as a sentinel: it looks like an on-chain publisher and
// can leak into UALs/metadata. See PR #371 for
// the testnet-blocking incident chain (`ensureProfile` had the same
// anti-pattern, fixed in PR #366).
this.publisherAddress = configuredPublisherAddress;
}
for (const key of config.additionalSignerKeys ?? []) {
this.additionalSignerWallets.push(new ethers.Wallet(key));
}
this.graphManager = new GraphManager(config.store);
this.privateStore = new PrivateContentStore(config.store, this.graphManager);
this.sharedMemoryOwnedEntities = config.sharedMemoryOwnedEntities ?? new Map();
this.knownBatchContextGraphs = config.knownBatchContextGraphs ?? new Map();
this.writeLocks = config.writeLocks ?? new Map();
this.workspaceAgentRecipientResolver = config.workspaceAgentRecipientResolver;
this.workspaceSenderKeyEncryptor = config.workspaceSenderKeyEncryptor;
this.publicSnapshotStore = config.publicSnapshotStore;
}
setWorkspaceAgentRecipientResolver(resolver: WorkspaceAgentRecipientResolver | undefined): void {
this.workspaceAgentRecipientResolver = resolver;
}
setWorkspaceSenderKeyEncryptor(encryptor: WorkspaceSenderKeyEncryptor | undefined): void {
this.workspaceSenderKeyEncryptor = encryptor;
}
private async storedOnChainContextGraphId(contextGraphId: string): Promise<string | undefined> {
const ontologyGraph = contextGraphDataUri('ontology');
const contextGraphUri = contextGraphDataUri(contextGraphId);
const result = await this.store.query(
`SELECT ?id WHERE { GRAPH <${ontologyGraph}> { <${contextGraphUri}> <https://dkg.network/ontology#ContextGraphOnChainId> ?id } } LIMIT 1`,
);
if (result.type !== 'bindings' || result.bindings.length === 0) return undefined;
return stripOptionalLiteral(result.bindings[0]?.['id'])?.trim();
}
private async onChainContextGraphMatchesLocalId(
contextGraphId: string,
onChainContextGraphId: bigint | string | undefined,
): Promise<boolean> {
if (onChainContextGraphId === undefined || onChainContextGraphId === null) return false;
const normalizedOnChainId = String(onChainContextGraphId).trim();
const normalizedContextGraphId = contextGraphId.trim();
if (/^\d+$/.test(normalizedContextGraphId) && normalizedContextGraphId === normalizedOnChainId) return true;
const liveNameHashMatches = async (): Promise<boolean> => {
if (typeof this.chain?.getContextGraphNameHash !== 'function') return false;
try {
const nameHash = await this.chain.getContextGraphNameHash(BigInt(normalizedOnChainId));
return typeof nameHash === 'string' &&
nameHash.toLowerCase() === ethers.keccak256(ethers.toUtf8Bytes(normalizedContextGraphId)).toLowerCase();
} catch {
return false;
}
};
const storedOnChainId = await this.storedOnChainContextGraphId(contextGraphId);
if (sameBigIntLiteral(storedOnChainId, normalizedOnChainId)) {
return typeof this.chain?.getContextGraphNameHash === 'function'
? liveNameHashMatches()
: true;
}
return liveNameHashMatches();
}
private async onChainContextGraphIsPrivate(
contextGraphId: string,
onChainContextGraphId: bigint | string | undefined,
): Promise<boolean> {
if (onChainContextGraphId === undefined || onChainContextGraphId === null) return false;
if (!this.chain || this.chain.chainId === 'none') return false;
if (typeof this.chain.getContextGraphAccessPolicy !== 'function') return false;
if (!await this.onChainContextGraphMatchesLocalId(contextGraphId, onChainContextGraphId)) return false;
try {
return Number(await this.chain.getContextGraphAccessPolicy(BigInt(onChainContextGraphId))) === 1;
} catch {
return false;
}
}
private async localContextGraphHasPrivateAccessSignal(contextGraphId: string): Promise<boolean> {
if ((Object.values(SYSTEM_CONTEXT_GRAPHS) as string[]).includes(contextGraphId)) return false;
const ontologyGraph = contextGraphDataGraphUri(SYSTEM_CONTEXT_GRAPHS.ONTOLOGY);
const agentsGraph = contextGraphDataGraphUri(SYSTEM_CONTEXT_GRAPHS.AGENTS);
const cgMeta = contextGraphMetaUri(contextGraphId);
const cgData = contextGraphDataUri(contextGraphId);
const result = await this.store.query(
`SELECT ?policy ?gate WHERE {
{
GRAPH <${ontologyGraph}> {
<${cgData}> <${DKG_ONTOLOGY.DKG_ACCESS_POLICY}> ?policy
}
} UNION {
GRAPH <${agentsGraph}> {
<${cgData}> <${DKG_ONTOLOGY.DKG_ACCESS_POLICY}> ?policy
}
} UNION {
GRAPH <${cgMeta}> {
<${cgData}> <${DKG_ONTOLOGY.DKG_ACCESS_POLICY}> ?policy
}
} UNION {
GRAPH <${ontologyGraph}> {
<${cgData}> <${DKG_ONTOLOGY.DKG_ALLOWED_AGENT}> ?gate
}
} UNION {
GRAPH <${agentsGraph}> {
<${cgData}> <${DKG_ONTOLOGY.DKG_ALLOWED_AGENT}> ?gate
}
} UNION {
GRAPH <${cgMeta}> {
<${cgData}> <${DKG_ONTOLOGY.DKG_ALLOWED_AGENT}> ?gate
}
} UNION {
GRAPH <${ontologyGraph}> {
<${cgData}> <${DKG_ONTOLOGY.DKG_PARTICIPANT_AGENT}> ?gate
}
} UNION {
GRAPH <${agentsGraph}> {
<${cgData}> <${DKG_ONTOLOGY.DKG_PARTICIPANT_AGENT}> ?gate
}
} UNION {
GRAPH <${cgMeta}> {
<${cgData}> <${DKG_ONTOLOGY.DKG_PARTICIPANT_AGENT}> ?gate
}
} UNION {
GRAPH <${ontologyGraph}> {
<${cgData}> <${DKG_ONTOLOGY.DKG_ALLOWED_PEER}> ?gate
}
} UNION {
GRAPH <${agentsGraph}> {
<${cgData}> <${DKG_ONTOLOGY.DKG_ALLOWED_PEER}> ?gate
}
} UNION {
GRAPH <${cgMeta}> {
<${cgData}> <${DKG_ONTOLOGY.DKG_ALLOWED_PEER}> ?gate
}
}
}`,
);
if (result.type !== 'bindings') return false;
let hasPrivatePolicy = false;
let hasGate = false;
for (const row of result.bindings) {
const policy = stripOptionalLiteral(row['policy'])?.trim().toLowerCase();
if (policy === 'public') return false;
if (policy === 'private') hasPrivatePolicy = true;
if (stripOptionalLiteral(row['gate'])?.trim()) hasGate = true;
}
return hasPrivatePolicy || hasGate;
}
private async assertTrustedCatalogTriplesAllowed(params: {
contextGraphId: string;
trustedNonManifestCatalogTriples: PublishOptions['trustedNonManifestCatalogTriples'];
onChainContextGraphId?: bigint | string;
internalCatalogOrigin?: boolean;
allowLocalPrivateContextGraph?: boolean;
}): Promise<void> {
const {
contextGraphId,
trustedNonManifestCatalogTriples,
onChainContextGraphId,
internalCatalogOrigin = false,
allowLocalPrivateContextGraph = false,
} = params;
assertTrustedCatalogTriplesAreGeneratedFloor(
contextGraphId,
trustedNonManifestCatalogTriples,
);
if (trustedCatalogTripleKeySet(trustedNonManifestCatalogTriples).size === 0) return;
if (internalCatalogOrigin) return;
const storedOnChainContextGraphId = await this.storedOnChainContextGraphId(contextGraphId);
const effectiveOnChainContextGraphId = onChainContextGraphId ?? storedOnChainContextGraphId;
if (
allowLocalPrivateContextGraph &&
effectiveOnChainContextGraphId === undefined &&
await this.localContextGraphHasPrivateAccessSignal(contextGraphId)
) return;
if (await this.onChainContextGraphIsPrivate(contextGraphId, effectiveOnChainContextGraphId)) return;
throw new Error(
'trustedNonManifestCatalogTriples is only allowed for internal private context graph catalog floor handling',
);
}
private async resolvePublisherAddress(
contextGraphId?: bigint,
options: PublisherAddressResolutionOptions = {},
): Promise<string | undefined> {
if (this.publisherAddress) return this.publisherAddress;
if (this.publisherAddressResolver) {
const resolved = normalizePublisherAddress(await this.publisherAddressResolver(contextGraphId));
if (resolved) return resolved;
}
return this.inferAdapterPublisherAddress(contextGraphId, options);
}
/** RFC-001 §9 fallback author when no agent override is supplied. Returns undefined if no signer configured. */
async publisherFallbackAuthorAddress(): Promise<string | undefined> {
return this.resolvePublisherAddress();
}
/** Sign EIP-712 typed data with the publisher's own wallet. Returns KAv10's compact (r, vs). */
async signAuthorAttestationAsPublisher(typedData: {
domain: { name: string; version: string; chainId: bigint; verifyingContract: string };
types: Record<string, Array<{ name: string; type: string }>>;
message: Record<string, unknown>;
}): Promise<{ r: Uint8Array; vs: Uint8Array }> {
const address = await this.resolvePublisherAddress();
if (!address) {
throw new Error(
'signAuthorAttestationAsPublisher: no publisher signer is configured. ' +
'Configure publisherPrivateKey or use a chain adapter that exposes signTypedData.',
);
}
const signer = await this.getPublisherSigner(address);
if (!signer) {
throw new Error(
`signAuthorAttestationAsPublisher: failed to resolve a signer for ${address}.`,
);
}
const sigHex = await signer.signTypedData(
typedData.domain,
typedData.types as { [k: string]: Array<{ name: string; type: string }> },
typedData.message,
);
const sig = ethers.Signature.from(sigHex);
return {
r: ethers.getBytes(sig.r),
vs: ethers.getBytes(sig.yParityAndS),
};
}
private async inferAdapterPublisherAddress(
contextGraphId?: bigint,
options: PublisherAddressResolutionOptions = {},
): Promise<string | undefined> {
if (
options.includeReservingPublisherProbe !== false &&
contextGraphId !== undefined &&
typeof this.chain.getAuthorizedPublisherAddress === 'function'
) {
try {
const address = coercePublisherAddress(await this.chain.getAuthorizedPublisherAddress(contextGraphId));
if (address) return address;
} catch {
// Best-effort inference; the publish path will fail clearly if no signer resolves.
}
}
const signerAddressGetter = (this.chain as unknown as { getSignerAddress?: () => unknown }).getSignerAddress;
if (typeof signerAddressGetter === 'function') {
try {
const address = coercePublisherAddress(
await Promise.resolve(signerAddressGetter.call(this.chain)),
);
if (address) return address;
} catch {
// Fall through to other common adapter surfaces.
}
}
const signerAddressesGetter = (this.chain as unknown as { getSignerAddresses?: () => unknown }).getSignerAddresses;
if (typeof signerAddressesGetter === 'function') {
try {
const advertised = await Promise.resolve(signerAddressesGetter.call(this.chain));
if (Array.isArray(advertised)) {
for (const value of advertised) {
const address = coercePublisherAddress(value);
if (address) return address;
}
}
} catch {
// Fall through to legacy adapter surfaces.
}
}
const signerAddress = coercePublisherAddress(
(this.chain as unknown as { signerAddress?: unknown }).signerAddress,
);
if (signerAddress) return signerAddress;
const operationalWallet = this.getAdapterOperationalWallet();
if (operationalWallet) return operationalWallet.address;
if (this.adapterSignMessagePublisherAddress) return this.adapterSignMessagePublisherAddress;
if (options.includeGenericSignMessageProbe === false) return undefined;
if (this.chain.chainId === 'none' || typeof this.chain.signMessage !== 'function') return undefined;
try {
const challenge = ethers.getBytes(ethers.id('dkg-publisher:publisher-address-probe'));
const compact = await this.chain.signMessage(challenge);
const address = coercePublisherAddress(recoverCompactMessageSigner(challenge, compact));
if (address) {
this.adapterSignMessagePublisherAddress = address;
this.adapterSignMessageProbeCache.set(address.toLowerCase(), true);
}
return address;
} catch {
return undefined;
}
}
private getAdapterOperationalWallet(): ethers.Wallet | undefined {
const operationalKeyGetter = (this.chain as unknown as { getOperationalPrivateKey?: () => unknown })
.getOperationalPrivateKey;
if (typeof operationalKeyGetter !== 'function') return undefined;
try {
const privateKey = operationalKeyGetter.call(this.chain);
return typeof privateKey === 'string' && privateKey.length > 0
? new ethers.Wallet(privateKey)
: undefined;
} catch {
return undefined;
}
}
// Local-only tentative publishes need a stable, non-zero UAL component even
// when no EVM publisher key exists. This is not used for signatures.
private localTentativePublisherAddress(): string {
const digest = ethers.keccak256(this.keypair.publicKey);
const address = ethers.getAddress(ethers.dataSlice(digest, 12));
return address === ethers.ZeroAddress ? '0x0000000000000000000000000000000000000001' : address;
}
private isChainV10Ready(): boolean {
return this.chain.chainId !== 'none' &&
typeof this.chain.isV10Ready === 'function' &&
this.chain.isV10Ready();
}
private async refreshChainV10Readiness(): Promise<boolean> {
if (this.isChainV10Ready()) return true;
if (this.chain.chainId === 'none') return false;
try {
const chainIdGetter = (this.chain as unknown as { getEvmChainId?: () => Promise<bigint> }).getEvmChainId;
const kavAddressGetter = (this.chain as unknown as { getKnowledgeAssetsLifecycleAddress?: () => Promise<string> })
.getKnowledgeAssetsLifecycleAddress;
if (typeof chainIdGetter === 'function') await chainIdGetter.call(this.chain);
if (typeof kavAddressGetter === 'function') await kavAddressGetter.call(this.chain);
} catch {
// V9-only or incompletely configured adapters stay off the V10 path.
}
return this.isChainV10Ready();
}
private async resolveKnownBatchPublisherAddress(
contextGraphId: string,
kaId: bigint,
metaGraphUri = this.graphManager.metaGraphUri(contextGraphId),
): Promise<string | undefined> {
try {
const ual = await resolveUalByBatchId(
this.store,
metaGraphUri,
kaId,
);
return publisherAddressFromUal(ual);
} catch {
return undefined;
}
}
private async adapterSignMessageMatchesAddress(expectedAddress: string): Promise<boolean> {
if (typeof this.chain.signMessage !== 'function') return false;