Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/typed-role-read-delete.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@enbox/api": patch
---

fix: carry protocol roles through typed point reads and deletes, including lazy record data reads
1 change: 1 addition & 0 deletions packages/api/src/dwn-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions packages/api/src/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ export class Record<T = unknown> 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) */
Expand Down Expand Up @@ -758,7 +758,8 @@ export class Record<T = unknown> 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.
Expand Down
26 changes: 20 additions & 6 deletions packages/api/src/typed-enbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<D> & string,
> = Pick<RecordQuery<D, Path>, 'from'> & {
> = Pick<RecordQuery<D, Path>, 'from' | 'protocolRole'> & {
/**
* Filter to identify the record to read.
*
Expand All @@ -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 = {
Expand All @@ -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.
*
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
});
Expand Down
222 changes: 188 additions & 34 deletions packages/api/tests/dwn-api-remote-write.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -422,7 +423,7 @@ describe('cross-tenant writes (#973)', () => {
});
});

describe('delegated-grant writes with from', () => {
describe('delegated remote access', () => {
let delegateHarness: PlatformAgentTestHarness;

beforeAll(async () => {
Expand All @@ -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,
);
Expand All @@ -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');

Expand All @@ -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
Expand All @@ -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);
});
});
});
Loading
Loading