diff --git a/packages/daemon/src/daemon.js b/packages/daemon/src/daemon.js index eeee8f8326..9fa514c6c9 100644 --- a/packages/daemon/src/daemon.js +++ b/packages/daemon/src/daemon.js @@ -516,7 +516,12 @@ const makeDaemonCore = async ( ['bundle', formula.bundle], ]; case 'peer': - return [['networks', formula.networks]]; + return /** @type {Array<[string, FormulaIdentifier]>} */ ([ + ['networks', formula.networks], + ...(formula.syncedStore === undefined + ? [] + : [['syncedStore', formula.syncedStore]]), + ]); case 'handle': return [['agent', formula.agent]]; case 'mail-hub': @@ -2951,6 +2956,28 @@ const makeDaemonCore = async ( return /** @type {FormulaIdentifier} */ (peerId); }; + /** @type {DaemonCore['setPeerSyncedStore']} */ + const setPeerSyncedStore = async (peerId, syncedStoreId) => { + const peerFormula = await getFormulaForId(peerId); + if (peerFormula.type !== 'peer') { + throw makeError(X`Formula ${q(peerId)} is not a peer`); + } + if (peerFormula.syncedStore === syncedStoreId) { + return; + } + /** @type {PeerFormula} */ + const updatedFormula = harden({ + ...peerFormula, + syncedStore: syncedStoreId, + }); + const { number: peerNumber } = parseId(peerId); + await persistencePowers.writeFormula(peerNumber, updatedFormula); + await withFormulaGraphLock(async () => { + formulaForId.set(peerId, updatedFormula); + formulaGraph.onFormulaAdded(peerId, updatedFormula); + }); + }; + /** @type {DaemonCore['cancelValue']} */ const cancelValue = async (id, reason) => { // Wait for any in-flight graph operation (formulation, collection) @@ -4483,11 +4510,15 @@ const makeDaemonCore = async ( /** @type {FormulaNumber} */ ('0'.repeat(64)), peerId, // store dependency (peer keeps alive) ); + await setPeerSyncedStore(peerId, syncedStoreId); - // Write the guest handle locator into the synced store. + // Write the guest handle locator into a hidden synced-store entry. + // Hidden entries use special-name keys and are filtered from ordinary + // user-facing listing operations. + const peerHandleEntry = /** @type {PetName} */ ('@peer-handle'); const guestHandleLocatorStr = formatLocator(guestHandleId, 'remote'); await E(syncedStoreValue).storeLocator( - /** @type {PetName} */ (guestName), + peerHandleEntry, guestHandleLocatorStr, ); @@ -4505,16 +4536,14 @@ const makeDaemonCore = async ( hostHandleExternalId, 'handle', ); - await E(syncedStoreValue).storeLocator( - /** @type {PetName} */ (hostNameFromGuest), - hostHandleLocatorStr, - ); + const selfHandleEntry = /** @type {PetName} */ ('@self-handle'); + await E(syncedStoreValue).storeLocator(selfHandleEntry, hostHandleLocatorStr); } // Create a local guest backed by the synced store. /** @type {DeferredTasks} */ const guestTasks = makeDeferredTasks(); - const { id: localGuestId } = await formulateGuest( + await formulateGuest( hostAgentId, hostHandleId, guestTasks, @@ -4522,16 +4551,6 @@ const makeDaemonCore = async ( syncedStoreId, ); - // Name the local guest handle inside @pins so that incarnating - // it transitively incarnates the guest and its synced pet store. - const localGuestFormula = /** @type {GuestFormula} */ ( - await getFormulaForId(localGuestId) - ); - await E(hostAgent).storeIdentifier( - /** @type {NamePath} */ (['@pins', `guest-${guestName}`]), - localGuestFormula.handle, - ); - // Store the remote guest handle under guestName for mail delivery. // Use storeLocator so the directory properly internalizes the // remote formula identifier for peer resolution. @@ -4690,6 +4709,7 @@ const makeDaemonCore = async ( formulateScratchMount, formulateInvitation, formulateSyncedPetStore, + setPeerSyncedStore, formulateDirectoryForStore, getPeerIdForNodeIdentifier, getAllNetworkAddresses, @@ -4813,6 +4833,9 @@ const makeDaemonCore = async ( harden({ NODE: formula.node, ADDRESSES: formula.addresses, + ...(formula.syncedStore === undefined + ? {} + : { SYNCED_STORE: formula.syncedStore }), }), ); } diff --git a/packages/daemon/src/host.js b/packages/daemon/src/host.js index 56379d30ac..965bb30aef 100644 --- a/packages/daemon/src/host.js +++ b/packages/daemon/src/host.js @@ -13,6 +13,7 @@ import { assertPetNamePath, assertName, assertNamePath, + isPetName, namePathFrom, } from './pet-name.js'; import { @@ -21,7 +22,7 @@ import { parseId, formatId, } from './formula-identifier.js'; -import { addressesFromLocator, formatLocator } from './locator.js'; +import { addressesFromLocator, formatLocator, LOCAL_NODE } from './locator.js'; import { toHex, fromHex } from './hex.js'; import { makePetSitter } from './pet-sitter.js'; @@ -71,6 +72,7 @@ const normalizeHostOrGuestOptions = opts => { * @param {DaemonCore['formulateScratchMount']} args.formulateScratchMount * @param {DaemonCore['formulateInvitation']} args.formulateInvitation * @param {DaemonCore['formulateSyncedPetStore']} args.formulateSyncedPetStore + * @param {DaemonCore['setPeerSyncedStore']} args.setPeerSyncedStore * @param {DaemonCore['formulateDirectoryForStore']} args.formulateDirectoryForStore * @param {DaemonCore['getPeerIdForNodeIdentifier']} args.getPeerIdForNodeIdentifier * @param {DaemonCore['formulateChannel']} args.formulateChannel @@ -105,6 +107,7 @@ export const makeHostMaker = ({ formulateScratchMount, formulateInvitation, formulateSyncedPetStore, + setPeerSyncedStore, formulateDirectoryForStore, getPeerIdForNodeIdentifier, formulateChannel, @@ -198,6 +201,68 @@ export const makeHostMaker = ({ isLocalKey, getNetworkAddresses, ); + /** + * Derive an objective, deterministic hidden export-table key. + * Use the externalized identifier (node:number) so the key is + * stable across process restarts and independent of local formula + * identifier normalization. + * + * @param {FormulaIdentifier} externalizedId + * @returns {Name} + */ + const makeExportRetentionNameForId = externalizedId => { + const { node, number } = parseId(externalizedId); + return /** @type {Name} */ ( + `@export-${node.slice(0, 16)}-${number.slice(0, 16)}` + ); + }; + + /** + * Track formulas sent to a remote peer in that peer's hidden synced table. + * These entries retain formulas even if the exporter never names them + * in their own local pet store. + * + * @param {FormulaIdentifier} recipientId + * @param {FormulaIdentifier[]} ids + */ + const trackSentIdentifiers = async (recipientId, ids) => { + const { node: recipientNode } = parseId(recipientId); + if (recipientNode === LOCAL_NODE || isLocalKey(recipientNode)) { + return; + } + + const peerNames = specialStore + .reverseIdentify(recipientId) + .filter(name => isPetName(name)); + + for (const peerName of peerNames) { + let syncedStore; + try { + syncedStore = await getSyncedStore(peerName); + } catch { + continue; + } + for (const id of ids) { + const { node } = parseId(id); + if (node !== LOCAL_NODE && !isLocalKey(node)) { + continue; + } + const formulaType = await getTypeForId(id); + const { number } = parseId(id); + const externalizedId = formatId({ + number, + node: agentNodeNumber, + }); + const locator = formatLocator(externalizedId, formulaType); + await E(syncedStore).storeLocator( + makeExportRetentionNameForId(id), + locator, + ); + } + return; + } + }; + const mailbox = await makeMailbox({ petStore: specialStore, agentNodeNumber, @@ -205,6 +270,7 @@ export const makeHostMaker = ({ directory, selfId: handleId, context, + trackSentIdentifiers, }); const { petStore, handle } = mailbox; const getEndoBootstrap = async () => provide(endoId, 'endo'); @@ -881,11 +947,12 @@ export const makeHostMaker = ({ /** @type {import('./types.js').FormulaNumber} */ (syncedStoreNumber), peerId, // store dependency ); + await setPeerSyncedStore(peerId, syncedStoreId); // Create a local guest backed by the synced store. /** @type {import('./types.js').DeferredTasks} */ const guestTasks = makeDeferredTasks(); - const { id: localGuestId } = await formulateGuest( + await formulateGuest( hostId, handleId, guestTasks, @@ -893,18 +960,6 @@ export const makeHostMaker = ({ syncedStoreId, ); - // Look up the local guest's handle from its formula so we can - // name it. Incarnating the handle transitively incarnates the - // guest and its synced pet store, starting synchronisation. - const localGuestFormula = - /** @type {import('./types.js').GuestFormula} */ ( - await getFormulaForId(localGuestId) - ); - await E(directory).storeIdentifier( - ['@pins', `guest-${guestName}`], - localGuestFormula.handle, - ); - // Store the remote handle under guestName for mail delivery. const remoteHandleId = formatId({ number: /** @type {import('./types.js').FormulaNumber} */ ( @@ -918,31 +973,30 @@ export const makeHostMaker = ({ /** @type {EndoHost['registerSyncedStore']} */ const registerSyncedStore = async (_petName, _syncedStoreId) => { - // No-op: the synced store is now discovered via the formula - // graph (guest handle → guest → petStore). Retained for - // interface compatibility. + // No-op: synced stores are attached to peer formulas. + // Retained for interface compatibility. }; /** @type {EndoHost['getSyncedStore']} */ const getSyncedStore = async petName => { - // Traverse the formula graph: - // @pins/guest- → local guest handle - // → handle formula.agent → guest formula - // → guest formula.petStore → synced store - const localHandleId = await E(directory).identify( - '@pins', - `guest-${petName}`, - ); - if (localHandleId === undefined) { + const remoteHandleId = await E(directory).identify(petName); + if (remoteHandleId === undefined) { throw new Error(`No synced store for ${q(petName)}`); } - const handleFormula = /** @type {import('./types.js').HandleFormula} */ ( - await getFormulaForId(/** @type {FormulaIdentifier} */ (localHandleId)) + const { node: remoteNode } = parseId( + /** @type {FormulaIdentifier} */ (remoteHandleId), ); - const guestFormula = /** @type {import('./types.js').GuestFormula} */ ( - await getFormulaForId(handleFormula.agent) + if (remoteNode === LOCAL_NODE || isLocalKey(remoteNode)) { + throw new Error(`No synced store for non-peer ${q(petName)}`); + } + const peerId = await getPeerIdForNodeIdentifier( + /** @type {NodeNumber} */ (remoteNode), ); - return provide(guestFormula.petStore, 'synced-pet-store'); + const peerFormula = await getFormulaForId(peerId); + if (peerFormula.type !== 'peer' || peerFormula.syncedStore === undefined) { + throw new Error(`No synced store for ${q(petName)}`); + } + return provide(peerFormula.syncedStore, 'synced-pet-store'); }; /** @type {EndoHost['cancel']} */ diff --git a/packages/daemon/src/mail.js b/packages/daemon/src/mail.js index 235a29c2d4..1a992c5a9c 100644 --- a/packages/daemon/src/mail.js +++ b/packages/daemon/src/mail.js @@ -148,6 +148,7 @@ export const makeMailboxMaker = ({ mailboxStore, directory, context, + trackSentIdentifiers = async (_recipientId, _ids) => {}, }) => { const { number: selfNumber } = parseId(localSelfId); const selfId = formatId({ @@ -862,6 +863,7 @@ export const makeMailboxMaker = ({ return /** @type {FormulaIdentifier} */ (id); }), ); + await trackSentIdentifiers(/** @type {FormulaIdentifier} */ (toId), ids); /** @type {import('./types.js').FormulaNumber | undefined} */ let replyTo; @@ -940,6 +942,10 @@ export const makeMailboxMaker = ({ return /** @type {FormulaIdentifier} */ (id); }), ); + await trackSentIdentifiers( + /** @type {FormulaIdentifier} */ (otherId), + ids, + ); const message = harden({ type: /** @type {const} */ ('package'), @@ -1314,6 +1320,10 @@ export const makeMailboxMaker = ({ throw new Error(`Unknown pet name ${q(petNameOrPath)}`); } assertValidId(valueId); + await trackSentIdentifiers( + /** @type {FormulaIdentifier} */ (otherId), + [/** @type {FormulaIdentifier} */ (valueId)], + ); const messageId = /** @type {import('./types.js').FormulaNumber} */ ( await randomHex256() diff --git a/packages/daemon/src/store-controller.js b/packages/daemon/src/store-controller.js index b4fb61246f..88d74ac3d8 100644 --- a/packages/daemon/src/store-controller.js +++ b/packages/daemon/src/store-controller.js @@ -4,6 +4,7 @@ import harden from '@endo/harden'; import { formatId, parseId } from './formula-identifier.js'; import { LOCAL_NODE } from './locator.js'; +import { isPetName } from './pet-name.js'; /** @import { FormulaIdentifier, GcHooks, Name, PetName, PetStore, StoreController, StoreConverters, SyncedPetStore } from './types.js' */ @@ -198,6 +199,9 @@ export const makeSyncedStoreController = ( const names = []; const state = syncedStore.getState(); for (const [key, entry] of Object.entries(state)) { + if (!isPetName(key)) { + continue; + } if (entry.locator !== null) { try { const { id: entryId } = converters.internalizeLocator( @@ -318,6 +322,9 @@ export const makeSyncedStoreController = ( /** @type {StoreController['followNameChanges']} */ const followNameChanges = async function* syncedFollowNameChanges() { for await (const { key, entry } of syncedStore.followChanges()) { + if (!isPetName(key)) { + continue; + } if (entry.locator !== null) { const entryId = safeIdFromLocator(entry.locator); if (entryId !== undefined) { @@ -349,6 +356,9 @@ export const makeSyncedStoreController = ( }); // Then deltas. for await (const { key, entry } of syncedStore.followChanges()) { + if (!isPetName(key)) { + continue; + } const entryId = entry.locator !== null ? safeIdFromLocator(entry.locator) : undefined; let normalizedEntryId; @@ -376,16 +386,16 @@ export const makeSyncedStoreController = ( /** @type {StoreController['seedGcEdges']} */ const seedGcEdges = async () => { await null; - const names = syncedStore.list(); /** @type {FormulaIdentifier[]} */ const ids = []; - for (const name of names) { - const locator = syncedStore.lookup(name); - if (locator !== undefined) { - const id = safeIdFromLocator(locator); - if (id !== undefined) { - ids.push(id); - } + const state = syncedStore.getState(); + for (const entry of Object.values(state)) { + if (entry.locator === null) { + continue; + } + const id = safeIdFromLocator(entry.locator); + if (id !== undefined) { + ids.push(id); } } if (ids.length > 0) { diff --git a/packages/daemon/src/synced-pet-store.js b/packages/daemon/src/synced-pet-store.js index 5e48ff3711..a5219e864a 100644 --- a/packages/daemon/src/synced-pet-store.js +++ b/packages/daemon/src/synced-pet-store.js @@ -11,7 +11,7 @@ import { M } from '@endo/patterns'; import { q } from '@endo/errors'; import { makeChangeTopic } from './pubsub.js'; -import { assertPetName } from './pet-name.js'; +import { assertName, isPetName } from './pet-name.js'; import { makeSerialJobs } from './serial-jobs.js'; import { makeIteratorRef } from './reader-ref.js'; @@ -231,8 +231,9 @@ export const makeSyncedPetStore = async ({ /** @type {SyncedPetStore['storeLocator']} */ const storeLocator = async (petName, locator) => { - assertPetName(petName); - if (role === 'grantee') { + assertName(petName); + const isInternalEntry = !isPetName(petName); + if (role === 'grantee' && !isInternalEntry) { throw new Error('Grantee cannot write new entries'); } meta.localClock += 1; @@ -250,7 +251,7 @@ export const makeSyncedPetStore = async ({ /** @type {SyncedPetStore['remove']} */ const remove = async petName => { - assertPetName(petName); + assertName(petName); if (!state.has(petName)) { throw new Error(`No entry for pet name ${q(petName)}`); } @@ -292,7 +293,7 @@ export const makeSyncedPetStore = async ({ /** @type {PetName[]} */ const names = []; for (const [key, entry] of state) { - if (entry.locator !== null) { + if (entry.locator !== null && isPetName(key)) { names.push(/** @type {PetName} */ (key)); } } diff --git a/packages/daemon/src/types.d.ts b/packages/daemon/src/types.d.ts index 333bb26fc8..7417afe4bd 100644 --- a/packages/daemon/src/types.d.ts +++ b/packages/daemon/src/types.d.ts @@ -282,6 +282,7 @@ type PeerFormula = { networks: FormulaIdentifier; node: NodeNumber; addresses: Array; + syncedStore?: FormulaIdentifier; }; type HandleFormula = { @@ -699,15 +700,18 @@ export type SyncedPetStoreChange = { }; export interface SyncedPetStore { - /** Store a name->locator entry (grantor only). */ - storeLocator(petName: PetName, locator: string): Promise; + /** + * Store a name->locator entry. + * Grantees may only write special-name internal entries. + */ + storeLocator(petName: Name, locator: string): Promise; /** Delete a name (tombstone). Either party can delete. */ - remove(petName: PetName): Promise; + remove(petName: Name): Promise; /** Check if a non-tombstoned entry exists for the name. */ has(petName: string): boolean; /** Look up the locator for a name, or undefined if absent/tombstoned. */ lookup(petName: string): string | undefined; - /** List all non-tombstoned pet names, sorted. */ + /** List all non-tombstoned regular pet names, sorted. */ list(): PetName[]; /** Return a serializable snapshot of the full CRDT state (including tombstones). */ getState(): Record; @@ -908,6 +912,13 @@ export type MakeMailbox = (args: { mailboxStore: StoreController; directory: EndoDirectory; context: Context; + /** + * Optional hook for host mailboxes to retain formulas sent to remote peers. + */ + trackSentIdentifiers?: ( + recipientId: FormulaIdentifier, + ids: FormulaIdentifier[], + ) => Promise; }) => Promise; export type RequestFn = ( @@ -1558,6 +1569,11 @@ export interface DaemonCore { nodeNumber: NodeNumber, ) => Promise; + setPeerSyncedStore: ( + peerId: FormulaIdentifier, + syncedStoreId: FormulaIdentifier, + ) => Promise; + formulateEndo: ( specifiedFormulaNumber?: FormulaNumber, ) => FormulateResult>; diff --git a/packages/daemon/test/synced-pet-store-integration.test.js b/packages/daemon/test/synced-pet-store-integration.test.js index c5ad9dca12..294c2e73f1 100644 --- a/packages/daemon/test/synced-pet-store-integration.test.js +++ b/packages/daemon/test/synced-pet-store-integration.test.js @@ -154,18 +154,14 @@ test.serial( // The synced store should have a 'list' method. const aliceNames = await E(aliceSyncedStore).list(); t.true(Array.isArray(aliceNames), 'Alice synced store should be listable'); - // Alice's grantor store wrote the guest handle under "bob" pet name - // during acceptance. - t.true( - aliceNames.length !== 0, - 'Alice store should have at least one entry', - ); + // Peer bootstrap entries are hidden from the user-facing list. + t.deepEqual(aliceNames, []); // Bob should have a synced-pet-store under 'alice'. const bobSyncedStore = await E(hostB).getSyncedStore('alice'); t.truthy(bobSyncedStore, 'Bob should have a synced store under "alice"'); const bobNames = await E(bobSyncedStore).list(); - t.true(Array.isArray(bobNames), 'Bob synced store should be listable'); + t.deepEqual(bobNames, []); }, ); @@ -209,6 +205,7 @@ test.serial( }, ); + test.serial('synced stores converge via manual sync', async t => { const { host: hostA } = await prepareHostWithTestNetwork(t); const { host: hostB } = await prepareHostWithTestNetwork(t); @@ -431,7 +428,7 @@ test.serial('synced stores converge after offline changes', async t => { await E(hostA2).move(['test-network-2'], ['@nets', 'tcp']); // After restart, the synced store should still be accessible via - // getSyncedStore because the mapping is persisted in the @pins directory. + // getSyncedStore because the peer formula stores the synced-store ID. const aliceStore2 = await E(hostA2).getSyncedStore('bob'); t.truthy(aliceStore2, 'Alice synced store should survive restart'); @@ -460,3 +457,61 @@ test.serial('synced stores converge after offline changes', async t => { 'Bob should have post-restart entry after sync with restarted daemon', ); }); + +test.serial( + 'sending to peer records hidden export retention entries', + async t => { + const { host: hostA } = await prepareHostWithTestNetwork(t); + const { host: hostB } = await prepareHostWithTestNetwork(t); + + const invitation = await E(hostA).invite('bob'); + const invitationLocator = await E(invitation).locate(); + await E(hostB).accept(invitationLocator, 'alice'); + + const aliceStore = await E(hostA).getSyncedStore('bob'); + const bobStore = await E(hostB).getSyncedStore('alice'); + + await E(hostA).storeValue('tracked-export-value', 'tracked-export-value'); + await E(hostA).send( + 'bob', + ['tracked @value'], + ['value'], + ['tracked-export-value'], + ); + + const deadline = Date.now() + 15_000; + /** @type {[string, import('../src/types.js').SyncedEntry] | undefined} */ + let retained; + while (Date.now() < deadline) { + const state = await E(aliceStore).getState(); + retained = Object.entries(state).find( + ([name, entry]) => name.startsWith('@export-') && entry.locator !== null, + ); + if (retained !== undefined) { + break; + } + // eslint-disable-next-line no-await-in-loop + await new Promise(resolve => setTimeout(resolve, 500)); + } + + t.truthy(retained, 'Alice should track exports in hidden synced-store rows'); + const [hiddenName, hiddenEntry] = /** @type {[string, any]} */ (retained); + const listedNames = await E(aliceStore).list(); + t.false( + listedNames.includes(hiddenName), + 'Hidden export entries must not appear in list()', + ); + + let mirrored = false; + while (Date.now() < deadline) { + const state = await E(bobStore).getState(); + if (state[hiddenName]?.locator === hiddenEntry.locator) { + mirrored = true; + break; + } + // eslint-disable-next-line no-await-in-loop + await new Promise(resolve => setTimeout(resolve, 500)); + } + t.true(mirrored, 'Hidden export entries should synchronize to the peer'); + }, +); diff --git a/packages/daemon/test/synced-pet-store.test.js b/packages/daemon/test/synced-pet-store.test.js index 23a2f221c4..dc47c03ffb 100644 --- a/packages/daemon/test/synced-pet-store.test.js +++ b/packages/daemon/test/synced-pet-store.test.js @@ -189,6 +189,24 @@ test('synced store: grantee cannot write', async t => { } }); +test('synced store: grantee can write hidden internal entries', async t => { + const dir = await makeTmpDir('grantee-internal-write'); + try { + const store = await makeSyncedPetStore({ + storePath: dir, + filePowers, + localNodeId: 'node-bob', + role: 'grantee', + }); + await store.storeLocator('@export-1', 'endo://node-alice/handle:xyz'); + t.is(store.lookup('@export-1'), 'endo://node-alice/handle:xyz'); + // Hidden entries do not appear in user-facing list(). + t.deepEqual(store.list(), []); + } finally { + await removeTmpDir(dir); + } +}); + test('synced store: remove creates tombstone', async t => { const dir = await makeTmpDir('remove'); try { @@ -533,3 +551,21 @@ test('synced store: overwrite updates entry', async t => { await removeTmpDir(dir); } }); + +test('synced store: internal entries are hidden from list', async t => { + const dir = await makeTmpDir('hidden-list'); + try { + const store = await makeSyncedPetStore({ + storePath: dir, + filePowers, + localNodeId: 'node-alice', + role: 'grantor', + }); + await store.storeLocator('@export-1', 'loc-hidden'); + await store.storeLocator('visible', 'loc-visible'); + t.deepEqual(store.list(), ['visible']); + t.is(store.lookup('@export-1'), 'loc-hidden'); + } finally { + await removeTmpDir(dir); + } +});