diff --git a/.changeset/typed-role-read-delete.md b/.changeset/typed-role-read-delete.md new file mode 100644 index 00000000..e33da448 --- /dev/null +++ b/.changeset/typed-role-read-delete.md @@ -0,0 +1,5 @@ +--- +"@enbox/api": patch +--- + +fix: carry protocol roles through typed point reads and deletes, including lazy record data reads diff --git a/packages/api/src/dwn-api.ts b/packages/api/src/dwn-api.ts index 6054b27e..4419b5f6 100644 --- a/packages/api/src/dwn-api.ts +++ b/packages/api/src/dwn-api.ts @@ -950,6 +950,7 @@ export class DwnApi { connectedDid : this.connectedDid, delegateDid : this.delegateDid, dataAccess, + protocolRole : agentRequest.messageParams.protocolRole, storedData : entry.data, initialWrite : entry.initialWrite, ...entry.recordsWrite, diff --git a/packages/api/src/record.ts b/packages/api/src/record.ts index 857a4b70..41bb8a15 100644 --- a/packages/api/src/record.ts +++ b/packages/api/src/record.ts @@ -267,7 +267,7 @@ export class Record implements RecordModel { /** Record's attestation signature. */ get attestation(): DwnMessage[DwnInterface.RecordsWrite]['attestation'] | undefined { return this._attestation; } - /** Role under which the author is writing the record */ + /** Protocol role carried by the request that produced this instance and reused by follow-up operations unless overridden. */ get protocolRole(): string | undefined { return this._protocolRole; } /** Record's deleted state (true/false) */ @@ -758,7 +758,8 @@ export class Record implements RecordModel { } /** - * Delete the current record on the DWN. + * Delete the current record from the connected tenant's local DWN. + * To delete from another tenant, use `TypedEnbox.records.delete(path, { from, ... })`. * * @param params - Parameters to delete the record. * @returns A promise that resolves after this record reflects the accepted tombstone. diff --git a/packages/api/src/typed-enbox.ts b/packages/api/src/typed-enbox.ts index 790fce2b..9f1bfabd 100644 --- a/packages/api/src/typed-enbox.ts +++ b/packages/api/src/typed-enbox.ts @@ -402,17 +402,19 @@ export type TypedSetRequest< * filter: { recordId: notebookId }, * }); * - * // Read from a remote DWN - * const remote = await proto.records.read('notebook', { + * // Read a nested record from a remote DWN using a protocol role + * const remote = await proto.records.read('notebook/entry', { * from: 'did:example:alice', - * filter: { recordId: notebookId }, + * protocolRole: 'notebook/participant', + * within: notebook.contextId, + * filter: { recordId: entryId }, * }); * ``` */ export type TypedReadRequest< D extends ProtocolDefinition, Path extends ProtocolPaths & string, -> = Pick, 'from'> & { +> = Pick, 'from' | 'protocolRole'> & { /** * Filter to identify the record to read. * @@ -433,6 +435,13 @@ export type TypedReadRequest< * await proto.records.delete('notebook', { * recordId: notebook.id, * }); + * + * await proto.records.delete('notebook/entry', { + * from: 'did:example:alice', + * protocolRole: 'notebook/participant', + * recordId: entry.id, + * within: notebook.contextId, + * }); * ``` */ export type TypedDeleteRequest = { @@ -449,6 +458,9 @@ export type TypedDeleteRequest = { */ from?: string; + /** Protocol role invoked to authorize the delete. */ + protocolRole?: string; + /** * The unique `recordId` of the record to delete. * @@ -1546,8 +1558,9 @@ export class TypedEnbox< request.within, ); const result = await this._dwn.records.read({ - from : request.from, - filter : readFilter, + from : request.from, + filter : readFilter, + protocolRole : request.protocolRole, }); if (result.status.code === 404) { @@ -1648,6 +1661,7 @@ export class TypedEnbox< from : request.from, protocol : this._definition.protocol, protocolPath : normalizedPath, + protocolRole : request.protocolRole, recordId : request.recordId, prune : request.prune, }); diff --git a/packages/api/tests/dwn-api-remote-write.spec.ts b/packages/api/tests/dwn-api-remote-write.spec.ts index fb1de7c3..754c6a09 100644 --- a/packages/api/tests/dwn-api-remote-write.spec.ts +++ b/packages/api/tests/dwn-api-remote-write.spec.ts @@ -16,6 +16,7 @@ import photosProtocolDefinition from './fixtures/protocol-definitions/photos.jso import { defineProtocol } from '../src/define-protocol.js'; import { DwnApi } from '../src/dwn-api.js'; +import { DwnResponseError } from '../src/dwn-response-error.js'; import { Enbox } from '../src/enbox.js'; import { recordCodecs } from '../src/record-codec.js'; import { TestDataGenerator } from './utils/test-data-generator.js'; @@ -422,7 +423,7 @@ describe('cross-tenant writes (#973)', () => { }); }); - describe('delegated-grant writes with from', () => { + describe('delegated remote access', () => { let delegateHarness: PlatformAgentTestHarness; beforeAll(async () => { @@ -441,34 +442,13 @@ describe('cross-tenant writes (#973)', () => { await delegateHarness.closeStorage(); }); - it('should dispatch a delegated-grant write to the owner\'s remote DWN', async () => { - // Alice installs a simple notes protocol locally and on her remote DWN. - const notesProtocol: DwnProtocolDefinition = { - published : true, - protocol : `http://notes-protocol.xyz/protocol/${TestDataGenerator.randomString(15)}`, - types : { - note: { - schema : 'https://notes-protocol.xyz/schema/note', - dataFormats : ['text/plain'], - }, - }, - structure: { - note: {}, - }, - }; - const { status: configStatus, protocol } = await dwnAlice.protocols.configure({ definition: notesProtocol }); - expect(configStatus.code).toBe(202); - const { status: protocolSendStatus } = await protocol!.send(aliceDid.uri); - expect(protocolSendStatus.code).toBe(202); - - // Alice grants a device did:jwk delegated write/read for the protocol; - // the DELEGATE agent (separate harness, no Alice keys) imports it. + async function createDelegatedEnbox( + definition: DwnProtocolDefinition, + permissions: Array<'delete' | 'read' | 'write'>, + ): Promise<{ delegateDid: string; enbox: Enbox }> { const delegatedBearerDid = await testHarness.agent.did.create({ store: false, method: 'jwk' }); const delegatePortableDid = await delegatedBearerDid.export(); - const grantRequest = WalletConnect.createPermissionRequestForProtocol({ - definition : notesProtocol, - permissions : ['write', 'read'], - }); + const grantRequest = WalletConnect.createPermissionRequestForProtocol({ definition, permissions }); const grants = await createPermissionGrants( aliceDid.uri, delegatedBearerDid.uri, testHarness.agent, grantRequest.permissionScopes, ); @@ -489,11 +469,39 @@ describe('cross-tenant writes (#973)', () => { agent : delegateHarness.agent as EnboxUserAgent, }); - const enbox = new Enbox({ - agent : delegateHarness.agent, - connectedDid : aliceDid.uri, - delegateDid : delegatePortableDid.uri, - }); + return { + delegateDid : delegatePortableDid.uri, + enbox : new Enbox({ + agent : delegateHarness.agent, + connectedDid : aliceDid.uri, + delegateDid : delegatePortableDid.uri, + }), + }; + } + + it('should dispatch a delegated-grant write to the owner\'s remote DWN', async () => { + // Alice installs a simple notes protocol locally and on her remote DWN. + const notesProtocol: DwnProtocolDefinition = { + published : true, + protocol : `http://notes-protocol.xyz/protocol/${TestDataGenerator.randomString(15)}`, + types : { + note: { + schema : 'https://notes-protocol.xyz/schema/note', + dataFormats : ['text/plain'], + }, + }, + structure: { + note: {}, + }, + }; + const { status: configStatus, protocol } = await dwnAlice.protocols.configure({ definition: notesProtocol }); + expect(configStatus.code).toBe(202); + const { status: protocolSendStatus } = await protocol!.send(aliceDid.uri); + expect(protocolSendStatus.code).toBe(202); + + // Alice grants a device did:jwk delegated write/read for the protocol; + // the DELEGATE agent (separate harness, no Alice keys) imports it. + const { delegateDid, enbox } = await createDelegatedEnbox(notesProtocol, ['write', 'read']); const sendSpy = sinon.spy(delegateHarness.agent, 'sendDwnRequest'); @@ -516,7 +524,7 @@ describe('cross-tenant writes (#973)', () => { expect(sendSpy.callCount).toBe(1); const sentRequest = sendSpy.firstCall.args[0]; expect(sentRequest.target).toBe(aliceDid.uri); - expect(sentRequest.granteeDid).toBe(delegatePortableDid.uri); + expect(sentRequest.granteeDid).toBe(delegateDid); expect((sentRequest.messageParams as { delegatedGrant?: unknown }).delegatedGrant).toBeDefined(); // Verify on Alice's remote DWN: the record exists, the logical author @@ -530,7 +538,153 @@ describe('cross-tenant writes (#973)', () => { const rawMessage = readResult.record!.rawMessage as DwnMessage[DwnInterface.RecordsWrite]; expect(getRecordAuthor(rawMessage)).toBe(aliceDid.uri); - expect(Jws.getSignerDid(rawMessage.authorization.signature.signatures[0])).toBe(delegatePortableDid.uri); + expect(Jws.getSignerDid(rawMessage.authorization.signature.signatures[0])).toBe(delegateDid); + }); + + it('should enforce and retain a nested role for typed remote reads and deletes', async () => { + const roleProtocol: DwnProtocolDefinition = { + published : true, + protocol : `http://role-access.xyz/protocol/${TestDataGenerator.randomString(15)}`, + types : { + thread: { + schema : 'https://role-access.xyz/schema/thread', + dataFormats : ['text/plain'], + }, + participant: { + schema : 'https://role-access.xyz/schema/participant', + dataFormats : ['text/plain'], + }, + auditor: { + schema : 'https://role-access.xyz/schema/auditor', + dataFormats : ['text/plain'], + }, + session: { + schema : 'https://role-access.xyz/schema/session', + dataFormats : ['text/plain'], + }, + }, + structure: { + thread: { + participant : { $role: true }, + auditor : { $role: true }, + session : { + $actions: [ + { role: 'thread/participant', can: ['read', 'co-delete'] }, + { role: 'thread/auditor', can: ['read', 'co-delete'] }, + ], + }, + }, + }, + }; + + for (const ownerDwn of [dwnAlice, dwnBob]) { + const { status, protocol } = await ownerDwn.protocols.configure({ definition: roleProtocol }); + expect(status.code).toBe(202); + const { status: sendStatus } = await protocol!.send(ownerDwn.connectedDid); + expect(sendStatus.code).toBe(202); + } + + const { status: threadStatus, record: thread } = await dwnBob.records.write({ + data : 'thread', + protocol : roleProtocol.protocol, + protocolPath : 'thread', + schema : roleProtocol.types.thread.schema, + dataFormat : 'text/plain', + }); + expect(threadStatus.code).toBe(202); + await thread!.send(bobDid.uri); + + const { status: participantStatus, record: participant } = await dwnBob.records.write({ + data : 'participant', + parentContextId : thread!.contextId, + recipient : aliceDid.uri, + protocol : roleProtocol.protocol, + protocolPath : 'thread/participant', + schema : roleProtocol.types.participant.schema, + dataFormat : 'text/plain', + }); + expect(participantStatus.code).toBe(202); + await participant!.send(bobDid.uri); + + const sessionData = TestDataGenerator.randomString(DwnConstant.maxDataSizeAllowedToBeEncoded + 1); + const { status: sessionStatus, record: session } = await dwnBob.records.write({ + data : sessionData, + parentContextId : thread!.contextId, + protocol : roleProtocol.protocol, + protocolPath : 'thread/session', + schema : roleProtocol.types.session.schema, + dataFormat : 'text/plain', + }); + expect(sessionStatus.code).toBe(202); + await session!.send(bobDid.uri); + + const { enbox } = await createDelegatedEnbox(roleProtocol, ['read', 'delete']); + const typed = new TypedEnbox( + enbox.dwn, + defineProtocol(roleProtocol, { + thread : recordCodecs.text(), + participant : recordCodecs.text(), + auditor : recordCodecs.text(), + session : recordCodecs.text(), + }), + ); + const readRequest = { + from : bobDid.uri, + filter : { recordId: session!.id }, + within : thread!.contextId, + }; + + await expect(typed.records.read('thread/session', readRequest)).rejects.toBeInstanceOf(DwnResponseError); + await expect(typed.records.read('thread/session', { + ...readRequest, + protocolRole: 'thread/auditor', + })).rejects.toBeInstanceOf(DwnResponseError); + + const sendSpy = sinon.spy(delegateHarness.agent, 'sendDwnRequest'); + const remoteRecord = await typed.records.read('thread/session', { + ...readRequest, + protocolRole: 'thread/participant', + }); + expect(remoteRecord?.protocolRole).toBe('thread/participant'); + expect(await remoteRecord!.value()).toBe(sessionData); + expect(await remoteRecord!.value()).toBe(sessionData); + + const roleReads = sendSpy.getCalls().filter((call) => + call.args[0].messageType === DwnInterface.RecordsRead + && call.args[0].target === bobDid.uri + ); + expect(roleReads).toHaveLength(2); + expect(roleReads.every((call) => + call.args[0].messageParams.protocolRole === 'thread/participant' + )).toBe(true); + + const deleteRequest = { + from : bobDid.uri, + recordId : session!.id, + within : thread!.contextId, + }; + await expect(typed.records.delete('thread/session', deleteRequest)).rejects.toBeInstanceOf(DwnResponseError); + await expect(typed.records.delete('thread/session', { + ...deleteRequest, + protocolRole: 'thread/auditor', + })).rejects.toBeInstanceOf(DwnResponseError); + + const retained = await dwnBob.records.read({ + from : bobDid.uri, + filter : { recordId: session!.id }, + }); + expect(retained.status.code).toBe(200); + + await typed.records.delete('thread/session', { + ...deleteRequest, + protocolRole: 'thread/participant', + }); + + const deleted = await dwnBob.records.read({ + from : bobDid.uri, + filter : { recordId: session!.id }, + }); + expect(deleted.status.code).toBe(404); }); }); }); diff --git a/packages/api/tests/typed-protocol.spec.ts b/packages/api/tests/typed-protocol.spec.ts index 9a73b124..e3b61391 100644 --- a/packages/api/tests/typed-protocol.spec.ts +++ b/packages/api/tests/typed-protocol.spec.ts @@ -901,15 +901,18 @@ describe('TypedProtocol API', () => { const scopedTyped = new TypedEnbox(dwn, TodoProtocol); await scopedTyped.records.delete('/list/' as any, { - recordId : 'record-id', - within : 'listcontext', + from : 'did:example:remote', + protocolRole : 'list/editor', + recordId : 'record-id', + within : 'listcontext', }); expect(deleteRecord.calledOnceWithExactly({ contextId : 'listcontext', - from : undefined, + from : 'did:example:remote', protocol : TodoProtocolDefinition.protocol, protocolPath : 'list', + protocolRole : 'list/editor', recordId : 'record-id', prune : undefined, })).toBe(true); @@ -930,14 +933,17 @@ describe('TypedProtocol API', () => { const scopedTyped = new TypedEnbox(dwn, TodoProtocol); const result = await scopedTyped.records.read('/list/' as any, { - filter : { recordId: 'record-id' }, - within : 'listcontext', + filter : { recordId: 'record-id' }, + from : 'did:example:remote', + protocolRole : 'list/viewer', + within : 'listcontext', }); expect(result).toBeUndefined(); expect(readRecord.calledOnceWithExactly({ - from : undefined, - filter : { + from : 'did:example:remote', + protocolRole : 'list/viewer', + filter : { contextId : 'listcontext', protocol : TodoProtocolDefinition.protocol, protocolPath : 'list', diff --git a/packages/api/type-tests/record-query.ts b/packages/api/type-tests/record-query.ts index 76b41fcc..74098123 100644 --- a/packages/api/type-tests/record-query.ts +++ b/packages/api/type-tests/record-query.ts @@ -146,12 +146,16 @@ void typed.records.query('attachment', { filter: { dataFormat: 'image/jpeg' } }) void typed.records.query('note/comment', { within: 'notecontext' }); void typed.records.read('note/comment', { - filter : { recordId: 'comment-id' }, - within : 'notecontext', + filter : { recordId: 'comment-id' }, + from : 'did:example:remote', + protocolRole : 'note/participant', + within : 'notecontext', }); void typed.records.delete('note/comment', { - recordId : 'comment-id', - within : 'notecontext', + from : 'did:example:remote', + protocolRole : 'note/participant', + recordId : 'comment-id', + within : 'notecontext', }); // @ts-expect-error contextId is a raw DWN filter; typed queries use within.