diff --git a/packages/core/src/crypto/workspace-encryption.ts b/packages/core/src/crypto/workspace-encryption.ts index 4530b5def5..f600d45897 100644 --- a/packages/core/src/crypto/workspace-encryption.ts +++ b/packages/core/src/crypto/workspace-encryption.ts @@ -297,7 +297,7 @@ export function encodeWorkspaceEncryptionKey(bytes: Uint8Array): string { export function decodeWorkspaceEncryptionKey(value: string): Uint8Array { const raw = value.trim(); - const bytes = raw.startsWith('0x') + const bytes = /^0x[0-9a-fA-F]{64}$/.test(raw) ? Buffer.from(raw.slice(2), 'hex') : Buffer.from(padBase64(raw.replace(/-/g, '+').replace(/_/g, '/')), 'base64'); const out = new Uint8Array(bytes); diff --git a/packages/core/test/workspace-encryption.test.ts b/packages/core/test/workspace-encryption.test.ts index 188ac20480..027978530f 100644 --- a/packages/core/test/workspace-encryption.test.ts +++ b/packages/core/test/workspace-encryption.test.ts @@ -8,8 +8,10 @@ import { WORKSPACE_ENCRYPTION_KEY_BYTES, WORKSPACE_RECIPIENT_ENCRYPTION_KEY_PURPOSE, assertSupportedEncryptedWorkspaceEnvelope, + decodeWorkspaceEncryptionKey, decryptWorkspacePayload, encryptWorkspacePayload, + encodeWorkspaceEncryptionKey, generateWorkspaceRecipientEncryptionKey, type EncryptWorkspacePayloadInput, type WorkspaceRecipientEncryptionKey, @@ -159,4 +161,12 @@ describe('workspace encrypted payload helpers', () => { expect(key.publicKeyBytes).toHaveLength(WORKSPACE_ENCRYPTION_KEY_BYTES); expect(key.privateKeyBytes).toHaveLength(WORKSPACE_ENCRYPTION_KEY_BYTES); }); + + it('decodes base64url keys that happen to start with 0x', () => { + const encoded = `0x${'A'.repeat(41)}`; + const bytes = decodeWorkspaceEncryptionKey(encoded); + + expect(bytes).toHaveLength(WORKSPACE_ENCRYPTION_KEY_BYTES); + expect(encodeWorkspaceEncryptionKey(bytes)).toBe(encoded); + }); }); diff --git a/packages/node-ui/src/ui/hooks/useSwmAttributions.ts b/packages/node-ui/src/ui/hooks/useSwmAttributions.ts index 688f294e91..1904b22e3b 100644 --- a/packages/node-ui/src/ui/hooks/useSwmAttributions.ts +++ b/packages/node-ui/src/ui/hooks/useSwmAttributions.ts @@ -120,16 +120,14 @@ export interface SwmAttributionsResult { * sit in the gaps so agent identity reads as its own axis. * * Also picked so no slot lives in the SWM-family amber range. The - * SWM layer default is `#f59e0b` (`LAYER_CONFIG.swm.color`) and any - * agent slot in the same hue is visually indistinguishable from a - * non-attributed node — the "agent palette ≠ SWM amber" invariant. - * Orange `#f97316` previously occupied slot 0 and broke this in - * single-agent dev setups; replaced with indigo `#6366f1`. */ + * SWM graph fallback for unattributed nodes is neutral, but the layer + * identity and conflict states remain amber; agent slots should not + * blur into either one. */ const AGENT_PALETTE = [ '#6366f1', // indigo (replaced orange — was too close to SWM amber) '#14b8a6', // teal '#f43f5e', // rose - '#facc15', // yellow + '#84cc16', // lime '#8b5cf6', // violet '#10b981', // emerald '#0ea5e9', // sky diff --git a/packages/node-ui/src/ui/views/project/helpers.ts b/packages/node-ui/src/ui/views/project/helpers.ts index f3572b0a89..d2c12677f7 100644 --- a/packages/node-ui/src/ui/views/project/helpers.ts +++ b/packages/node-ui/src/ui/views/project/helpers.ts @@ -190,6 +190,11 @@ export const LAYER_CONFIG: Record<'wm' | 'swm' | 'vm', { }, }; +// SWM graph fallback for entities that are visible because a promoted root +// references them, but are not themselves attributed roots. Kept graph-local +// so SWM navigation, badges, and conflict states retain their amber identity. +export const SWM_UNATTRIBUTED_NODE_COLOR = '#475569'; + export function layerNoun( layer: 'wm' | 'swm' | 'vm' | TrustLevel, count: number = 2, @@ -263,6 +268,7 @@ export function buildLayerGraphOptions( nodeColors?: Record, ) { const { color } = LAYER_CONFIG[layer]; + const defaultNodeColor = layer === 'swm' ? SWM_UNATTRIBUTED_NODE_COLOR : color; // VM ("Verifiable Memory") is the DKG hero view — we deliberately juice it: // thicker & brighter edges, bigger hub/leaf spread, higher gradient so every // node reads as a "trust gem". WM/SWM stay quieter so VM clearly wins the @@ -275,12 +281,12 @@ export function buildLayerGraphOptions( style: { classColors: CODE_CLASS_COLORS, predicateColors: CODE_PREDICATE_COLORS, - namespaceColors: neutraliseBuiltinNamespaces(color), + namespaceColors: neutraliseBuiltinNamespaces(defaultNodeColor), // Per-URI node tints (SWM attribution uses this to paint root KAs by // their proposing agent). Omitted for layers that don't use it, so // the style engine keeps falling back to classColors. ...(nodeColors && Object.keys(nodeColors).length > 0 ? { nodeColors } : {}), - defaultNodeColor: color, + defaultNodeColor, defaultEdgeColor: isVM ? '#4ade80' : '#64748b', // VM: vivid green edges edgeWidth: isVM ? 1.8 : 1.2, // Keep VM very slightly bolder than WM/SWM for hierarchy, but well diff --git a/packages/node-ui/test/layer-graph-panel.test.ts b/packages/node-ui/test/layer-graph-panel.test.ts index b74795f876..0d1e1427d3 100644 --- a/packages/node-ui/test/layer-graph-panel.test.ts +++ b/packages/node-ui/test/layer-graph-panel.test.ts @@ -3,6 +3,7 @@ import React, { act } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createRoot, type Root } from 'react-dom/client'; +import { SWM_UNATTRIBUTED_NODE_COLOR } from '../src/ui/views/project/helpers.js'; const graphState = vi.hoisted(() => ({ mounts: [] as string[], @@ -124,8 +125,11 @@ describe('LayerGraphPanel graph lifecycle', () => { }); const graph = container.querySelector('[data-testid="rdf-graph"]') as HTMLElement; + expect(graph.getAttribute('data-default-color')).toBe(SWM_UNATTRIBUTED_NODE_COLOR); const nodeColors = JSON.parse(graph.getAttribute('data-node-colors') ?? '{}'); expect(nodeColors['urn:test:root']).toBeTruthy(); + expect(nodeColors['urn:test:root']).not.toBe(SWM_UNATTRIBUTED_NODE_COLOR); + expect(nodeColors['urn:test:target']).toBeUndefined(); expect(container.textContent).not.toContain('Loading Shared Working Memory attribution...'); }); @@ -159,7 +163,7 @@ describe('LayerGraphPanel graph lifecycle', () => { }); const graph = container.querySelector('[data-testid="rdf-graph"]') as HTMLElement; - expect(graph.getAttribute('data-default-color')).toBe('#f59e0b'); + expect(graph.getAttribute('data-default-color')).toBe(SWM_UNATTRIBUTED_NODE_COLOR); }); it('releases the SWM graph if attribution lookup stalls', async () => { @@ -195,7 +199,7 @@ describe('LayerGraphPanel graph lifecycle', () => { const graph = container.querySelector('[data-testid="rdf-graph"]') as HTMLElement; expect(requestSignal?.aborted).toBe(true); - expect(graph.getAttribute('data-default-color')).toBe('#f59e0b'); + expect(graph.getAttribute('data-default-color')).toBe(SWM_UNATTRIBUTED_NODE_COLOR); expect(JSON.parse(graph.getAttribute('data-node-colors') ?? '{}')).toEqual({}); }); @@ -308,7 +312,7 @@ describe('LayerGraphPanel graph lifecycle', () => { expect(graphState.mounts).toHaveLength(2); }); - expect(graphState.mounts).toEqual(['#64748b', '#f59e0b']); + expect(graphState.mounts).toEqual(['#64748b', SWM_UNATTRIBUTED_NODE_COLOR]); expect(graphState.unmounts).toEqual(['#64748b']); }); diff --git a/packages/node-ui/test/use-swm-attributions.test.ts b/packages/node-ui/test/use-swm-attributions.test.ts index db68ebdfd8..cf22819b45 100644 --- a/packages/node-ui/test/use-swm-attributions.test.ts +++ b/packages/node-ui/test/use-swm-attributions.test.ts @@ -124,4 +124,32 @@ describe('useSwmAttributions — stale-on-switch protection', () => { expect(latest!.events).toHaveLength(1); expect(latest!.events[0].rootUri).toBe('urn:e:cg-B'); }); + + it('folds only returned dkg:rootEntity metadata rows into attribution colors', async () => { + let latest: SwmAttributionsResult | null = null; + function Probe({ id }: { id: string }) { + latest = useSwmAttributions(id); + return null; + } + + await act(async () => { + root.render(React.createElement(Probe, { id: 'cg-A' })); + }); + await flushMicrotasks(); + + pending.get('cg-A')!.resolve([{ + op: '"urn:op:cg-A"', + root: '"urn:e:root-subject"', + agent: '"did:dkg:agent:alice"', + publishedAt: '"2026-05-22T10:00:00Z"', + g: '"did:dkg:context-graph:cg-A/_shared_memory_meta"', + }]); + await flushMicrotasks(); + + expect(latest!.attributions.has('urn:e:root-subject')).toBe(true); + expect(latest!.nodeColors['urn:e:root-subject']).toBeTruthy(); + expect([...latest!.attributions.keys()]).toEqual(['urn:e:root-subject']); + expect(Object.keys(latest!.nodeColors)).toEqual(['urn:e:root-subject']); + expect(latest!.events.map(event => event.rootUri)).toEqual(['urn:e:root-subject']); + }); }); diff --git a/packages/publisher/test/async-lift-subtraction.test.ts b/packages/publisher/test/async-lift-subtraction.test.ts index 07601b5005..3da031b0b9 100644 --- a/packages/publisher/test/async-lift-subtraction.test.ts +++ b/packages/publisher/test/async-lift-subtraction.test.ts @@ -4,7 +4,12 @@ import { GraphManager } from '@origintrail-official/dkg-storage'; import { EVMChainAdapter } from '@origintrail-official/dkg-chain'; import { TypedEventBus, generateEd25519Keypair } from '@origintrail-official/dkg-core'; import { ethers } from 'ethers'; -import { DKGPublisher, generateSubGraphRegistration } from '../src/index.js'; +import { + DKGPublisher, + generateSubGraphRegistration, + getConfirmedStatusQuad, + getTentativeStatusQuad, +} from '../src/index.js'; import { validateLiftPublishPayload } from '../src/async-lift-validation.js'; import { subtractFinalizedExactQuads } from '../src/async-lift-subtraction.js'; import type { LiftValidationInput } from '../src/async-lift-validation.js'; @@ -93,6 +98,12 @@ describe('subtractFinalizedExactQuads', () => { }; } + async function markTentativePublishConfirmed(result: { readonly ual: string; readonly status: string }): Promise { + expect(result.status).toBe('tentative'); + await store.delete([getTentativeStatusQuad(result.ual, CONTEXT_GRAPH)]); + await store.insert([getConfirmedStatusQuad(result.ual, CONTEXT_GRAPH)]); + } + it('removes only the exact finalized public quads and keeps the remainder', async () => { const validated = validateLiftPublishPayload(baseInput()); const [publishedNameQuad, genreQuad] = validated.resolved.quads; @@ -186,12 +197,13 @@ describe('subtractFinalizedExactQuads', () => { }; const validated = validateLiftPublishPayload(input); - await publisher.publish({ + const publishResult = await publisher.publish({ contextGraphId: CONTEXT_GRAPH, quads: validated.resolved.quads, privateQuads: validated.resolved.privateQuads, publisherPeerId: 'peer-1', }); + await markTentativePublishConfirmed(publishResult); const result = await subtractFinalizedExactQuads({ store, @@ -222,12 +234,13 @@ describe('subtractFinalizedExactQuads', () => { }; const validated = validateLiftPublishPayload(input); - await publisher.publish({ + const publishResult = await publisher.publish({ contextGraphId: CONTEXT_GRAPH, quads: validated.resolved.quads, privateQuads: validated.resolved.privateQuads, publisherPeerId: 'peer-1', }); + await markTentativePublishConfirmed(publishResult); const result = await subtractFinalizedExactQuads({ store,