From 2eaa9c8aef08fa4c49573ace6c03f11a34bd0596 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Thu, 21 Dec 2023 12:34:36 -0500 Subject: [PATCH 01/44] Squashed commit of record and event subscriptions base. Co-authored-by: Liran Cohen Co-authored-by: Andor Kesselman Date: Thu Dec 21 12:34:36 2023 -0500 --- build/compile-validators.js | 6 +- .../interface-methods/events-subscribe.json | 47 ++ .../interface-methods/protocol-rule-set.json | 1 + .../interface-methods/records-subscribe.json | 44 ++ src/core/dwn-error.ts | 3 + src/core/message-reply.ts | 7 +- src/core/protocol-authorization.ts | 52 +- src/core/records-grant-authorization.ts | 33 +- src/dwn.ts | 108 ++- src/enums/dwn-interface-method.ts | 3 +- src/event-log/event-log-level.ts | 2 + src/event-log/event-stream.ts | 115 +++ src/event-log/subscription.ts | 72 ++ src/handlers/events-subscribe.ts | 69 ++ src/handlers/permissions-grant.ts | 9 +- src/handlers/permissions-request.ts | 9 +- src/handlers/permissions-revoke.ts | 11 +- src/handlers/protocols-configure.ts | 12 +- src/handlers/records-delete.ts | 49 +- src/handlers/records-subscribe.ts | 316 ++++++++ src/handlers/records-write.ts | 20 +- src/index.ts | 14 +- src/interfaces/events-query.ts | 16 +- src/interfaces/events-subscribe.ts | 65 ++ src/interfaces/records-delete.ts | 36 + src/interfaces/records-subscribe.ts | 105 +++ src/interfaces/records-write.ts | 6 +- src/store/storage-controller.ts | 2 +- src/types/event-stream.ts | 21 + src/types/event-types.ts | 30 +- src/types/message-types.ts | 8 + src/types/protocols-types.ts | 1 + src/types/records-types.ts | 21 + src/utils/records.ts | 4 +- tests/dwn.spec.ts | 12 +- tests/event-log/event-log-level.spec.ts | 2 +- tests/event-log/event-log.spec.ts | 46 +- tests/event-log/event-stream.spec.ts | 87 +++ tests/handlers/events-get.spec.ts | 8 +- tests/handlers/events-query.spec.ts | 6 +- tests/handlers/events-subscribe.spec.ts | 127 +++ tests/handlers/messages-get.spec.ts | 7 +- tests/handlers/permissions-grant.spec.ts | 9 +- tests/handlers/permissions-request.spec.ts | 9 +- tests/handlers/permissions-revoke.spec.ts | 7 +- tests/handlers/protocols-configure.spec.ts | 8 +- tests/handlers/protocols-query.spec.ts | 7 +- tests/handlers/records-delete.spec.ts | 11 +- tests/handlers/records-query.spec.ts | 23 +- tests/handlers/records-read.spec.ts | 7 +- tests/handlers/records-subscribe.spec.ts | 720 ++++++++++++++++++ tests/handlers/records-write.spec.ts | 30 +- tests/interfaces/events-subscribe.spec.ts | 20 + tests/interfaces/records-subscribe.spec.ts | 79 ++ tests/scenarios/delegated-grant.spec.ts | 7 +- tests/scenarios/end-to-end-tests.spec.ts | 7 +- tests/scenarios/events-query.spec.ts | 7 +- tests/scenarios/subscriptions.spec.ts | 579 ++++++++++++++ tests/store/message-store.spec.ts | 24 +- tests/test-stores.ts | 2 +- tests/test-suite.ts | 6 + tests/utils/test-data-generator.ts | 98 ++- .../protocol-definitions/friend-role.json | 4 + .../protocol-definitions/thread-role.json | 4 + 64 files changed, 3103 insertions(+), 177 deletions(-) create mode 100644 json-schemas/interface-methods/events-subscribe.json create mode 100644 json-schemas/interface-methods/records-subscribe.json create mode 100644 src/event-log/event-stream.ts create mode 100644 src/event-log/subscription.ts create mode 100644 src/handlers/events-subscribe.ts create mode 100644 src/handlers/records-subscribe.ts create mode 100644 src/interfaces/events-subscribe.ts create mode 100644 src/interfaces/records-subscribe.ts create mode 100644 src/types/event-stream.ts create mode 100644 tests/event-log/event-stream.spec.ts create mode 100644 tests/handlers/events-subscribe.spec.ts create mode 100644 tests/handlers/records-subscribe.spec.ts create mode 100644 tests/interfaces/events-subscribe.spec.ts create mode 100644 tests/interfaces/records-subscribe.spec.ts create mode 100644 tests/scenarios/subscriptions.spec.ts diff --git a/build/compile-validators.js b/build/compile-validators.js index 28b674541..b69f572fa 100644 --- a/build/compile-validators.js +++ b/build/compile-validators.js @@ -24,6 +24,7 @@ import Definitions from '../json-schemas/definitions.json' assert { type: 'json' import EventsFilter from '../json-schemas/interface-methods/events-filter.json' assert { type: 'json' }; import EventsGet from '../json-schemas/interface-methods/events-get.json' assert { type: 'json' }; import EventsQuery from '../json-schemas/interface-methods/events-query.json' assert { type: 'json' }; +import EventsSubscribe from '../json-schemas/interface-methods/events-subscribe.json' assert { type: 'json' }; import GeneralJwk from '../json-schemas/jwk/general-jwk.json' assert { type: 'json' }; import GeneralJws from '../json-schemas/general-jws.json' assert { type: 'json' }; import GenericSignaturePayload from '../json-schemas/signature-payloads/generic-signature-payload.json' assert { type: 'json' }; @@ -45,6 +46,7 @@ import RecordsDelete from '../json-schemas/interface-methods/records-delete.json import RecordsFilter from '../json-schemas/interface-methods/records-filter.json' assert { type: 'json' }; import RecordsQuery from '../json-schemas/interface-methods/records-query.json' assert { type: 'json' }; import RecordsRead from '../json-schemas/interface-methods/records-read.json' assert { type: 'json' }; +import RecordsSubscribe from '../json-schemas/interface-methods/records-subscribe.json' assert { type: 'json' }; import RecordsWrite from '../json-schemas/interface-methods/records-write.json' assert { type: 'json' }; import RecordsWriteSignaturePayload from '../json-schemas/signature-payloads/records-write-signature-payload.json' assert { type: 'json' }; import RecordsWriteUnidentified from '../json-schemas/interface-methods/records-write-unidentified.json' assert { type: 'json' }; @@ -55,10 +57,12 @@ const schemas = { AuthorizationOwner, RecordsDelete, RecordsQuery, + RecordsSubscribe, RecordsWrite, RecordsWriteUnidentified, EventsFilter, EventsGet, + EventsSubscribe, EventsQuery, Definitions, GeneralJwk, @@ -94,4 +98,4 @@ const moduleCode = standaloneCode(ajv); const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); await mkdirp(path.join(__dirname, '../generated')); -fs.writeFileSync(path.join(__dirname, '../generated/precompiled-validators.js'), moduleCode); +fs.writeFileSync(path.join(__dirname, '../generated/precompiled-validators.js'), moduleCode); \ No newline at end of file diff --git a/json-schemas/interface-methods/events-subscribe.json b/json-schemas/interface-methods/events-subscribe.json new file mode 100644 index 000000000..4e5b9259f --- /dev/null +++ b/json-schemas/interface-methods/events-subscribe.json @@ -0,0 +1,47 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://identity.foundation/dwn/json-schemas/events-subscribe.json", + "type": "object", + "additionalProperties": false, + "required": [ + "descriptor" + ], + "properties": { + "authorization": { + "$ref": "https://identity.foundation/dwn/json-schemas/authorization.json" + }, + "descriptor": { + "type": "object", + "additionalProperties": false, + "required": [ + "interface", + "method", + "messageTimestamp", + "filters" + ], + "properties": { + "interface": { + "enum": [ + "Events" + ], + "type": "string" + }, + "method": { + "enum": [ + "Subscribe" + ], + "type": "string" + }, + "messageTimestamp": { + "type": "string" + }, + "filters": { + "type": "array", + "items": { + "$ref": "https://identity.foundation/dwn/json-schemas/events-filter.json" + } + } + } + } + } + } diff --git a/json-schemas/interface-methods/protocol-rule-set.json b/json-schemas/interface-methods/protocol-rule-set.json index 72eec5034..db04fd141 100644 --- a/json-schemas/interface-methods/protocol-rule-set.json +++ b/json-schemas/interface-methods/protocol-rule-set.json @@ -66,6 +66,7 @@ "enum": [ "delete", "query", + "subscribe", "read", "update", "write" diff --git a/json-schemas/interface-methods/records-subscribe.json b/json-schemas/interface-methods/records-subscribe.json new file mode 100644 index 000000000..92c7939b0 --- /dev/null +++ b/json-schemas/interface-methods/records-subscribe.json @@ -0,0 +1,44 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://identity.foundation/dwn/json-schemas/records-subscribe.json", + "type": "object", + "additionalProperties": false, + "required": [ + "descriptor" + ], + "properties": { + "authorization": { + "$ref": "https://identity.foundation/dwn/json-schemas/authorization-delegated-grant.json" + }, + "descriptor": { + "type": "object", + "additionalProperties": false, + "required": [ + "interface", + "method", + "messageTimestamp", + "filter" + ], + "properties": { + "interface": { + "enum": [ + "Records" + ], + "type": "string" + }, + "method": { + "enum": [ + "Subscribe" + ], + "type": "string" + }, + "messageTimestamp": { + "$ref": "https://identity.foundation/dwn/json-schemas/defs.json#/definitions/date-time" + }, + "filter": { + "$ref": "https://identity.foundation/dwn/json-schemas/records-filter.json" + } + } + } + } +} \ No newline at end of file diff --git a/src/core/dwn-error.ts b/src/core/dwn-error.ts index 0243ee8f1..8f31464f3 100644 --- a/src/core/dwn-error.ts +++ b/src/core/dwn-error.ts @@ -25,6 +25,7 @@ export enum DwnErrorCode { DidNotValid = 'DidNotValid', DidResolutionFailed = 'DidResolutionFailed', Ed25519InvalidJwk = 'Ed25519InvalidJwk', + EventStreamSubscriptionNotSupported = 'EventStreamSubscriptionNotSupported', GeneralJwsVerifierGetPublicKeyNotFound = 'GeneralJwsVerifierGetPublicKeyNotFound', GeneralJwsVerifierInvalidSignature = 'GeneralJwsVerifierInvalidSignature', GrantAuthorizationGrantExpired = 'GrantAuthorizationGrantExpired', @@ -90,6 +91,7 @@ export enum DwnErrorCode { RecordsGrantAuthorizationDeleteProtocolScopeMismatch = 'RecordsGrantAuthorizationDeleteProtocolScopeMismatch', RecordsGrantAuthorizationQueryProtocolScopeMismatch = 'RecordsGrantAuthorizationQueryProtocolScopeMismatch', RecordsGrantAuthorizationScopeContextIdMismatch = 'RecordsGrantAuthorizationScopeContextIdMismatch', + RecordsGrantAuthorizationSubscribeProtocolScopeMismatch = 'RecordsGrantAuthorizationSubscribeProtocolScopeMismatch', RecordsGrantAuthorizationScopeNotProtocol = 'RecordsGrantAuthorizationScopeNotProtocol', RecordsGrantAuthorizationScopeProtocolMismatch = 'RecordsGrantAuthorizationScopeProtocolMismatch', RecordsGrantAuthorizationScopeProtocolPathMismatch = 'RecordsGrantAuthorizationScopeProtocolPathMismatch', @@ -101,6 +103,7 @@ export enum DwnErrorCode { RecordsQueryFilterMissingRequiredProperties = 'RecordsQueryFilterMissingRequiredProperties', RecordsReadReturnedMultiple = 'RecordsReadReturnedMultiple', RecordsReadAuthorizationFailed = 'RecordsReadAuthorizationFailed', + RecordsSubscribeFilterMissingRequiredProperties = 'RecordsSubscribeFilterMissingRequiredProperties', RecordsSchemasDerivationSchemeMissingSchema = 'RecordsSchemasDerivationSchemeMissingSchema', RecordsValidateIntegrityDelegatedGrantAndIdExistenceMismatch = 'RecordsValidateIntegrityDelegatedGrantAndIdExistenceMismatch', RecordsValidateIntegrityGrantedToAndSignerMismatch = 'RecordsValidateIntegrityGrantedToAndSignerMismatch', diff --git a/src/core/message-reply.ts b/src/core/message-reply.ts index 57b1fcf57..a29e0bf89 100644 --- a/src/core/message-reply.ts +++ b/src/core/message-reply.ts @@ -3,7 +3,7 @@ import type { PaginationCursor } from '../types/query-types.js'; import type { ProtocolsConfigureMessage } from '../types/protocols-types.js'; import type { Readable } from 'readable-stream'; import type { RecordsWriteMessage } from '../types/records-types.js'; -import type { GenericMessageReply, QueryResultEntry } from '../types/message-types.js'; +import type { GenericMessageReply, GenericMessageSubscription, QueryResultEntry } from '../types/message-types.js'; export function messageReplyFromError(e: unknown, code: number): GenericMessageReply { @@ -40,4 +40,9 @@ export type UnionMessageReply = GenericMessageReply & { * Mutually exclusive with `record`. */ cursor?: PaginationCursor; + + /** + * A subscription object if a subscription was requested. + */ + subscription?: GenericMessageSubscription; }; \ No newline at end of file diff --git a/src/core/protocol-authorization.ts b/src/core/protocol-authorization.ts index 5ad04fd92..fb4d50ee0 100644 --- a/src/core/protocol-authorization.ts +++ b/src/core/protocol-authorization.ts @@ -3,6 +3,7 @@ import type { MessageStore } from '../types/message-store.js'; import type { RecordsDelete } from '../interfaces/records-delete.js'; import type { RecordsQuery } from '../interfaces/records-query.js'; import type { RecordsRead } from '../interfaces/records-read.js'; +import type { RecordsSubscribe } from '../interfaces/records-subscribe.js'; import type { RecordsWriteMessage } from '../types/records-types.js'; import type { ProtocolActionRule, ProtocolDefinition, ProtocolRuleSet, ProtocolsConfigureMessage, ProtocolType, ProtocolTypes } from '../types/protocols-types.js'; @@ -152,6 +153,48 @@ export class ProtocolAuthorization { ); } + // maybe combine with query? + public static async authorizeSubscription( + tenant: string, + incomingMessage: RecordsSubscribe, + messageStore: MessageStore, + ): Promise { + // validate that required properties exist in query filter + const { protocol, protocolPath, contextId } = incomingMessage.message.descriptor.filter; + + // fetch the protocol definition + const protocolDefinition = await ProtocolAuthorization.fetchProtocolDefinition( + tenant, + protocol!, // authorizeQuery` is only called if `protocol` is present + messageStore, + ); + + // get the rule set for the inbound message + const inboundMessageRuleSet = ProtocolAuthorization.getRuleSet( + protocolPath!, // presence of `protocolPath` is verified in `parse()` + protocolDefinition, + ); + + // If the incoming message has `protocolRole` in the descriptor, validate the invoked role + await ProtocolAuthorization.verifyInvokedRole( + tenant, + incomingMessage, + protocol!, + contextId, + protocolDefinition, + messageStore, + ); + + // verify method invoked against the allowed actions + await ProtocolAuthorization.verifyAllowedActions( + tenant, + incomingMessage, + inboundMessageRuleSet, + [], // ancestor chain is not relevant to subscribes + messageStore, + ); + } + /** * Performs protocol-based authorization against the incoming RecordsQuery message. * @throws {Error} if authorization fails. @@ -423,7 +466,7 @@ export class ProtocolAuthorization { */ private static async verifyInvokedRole( tenant: string, - incomingMessage: RecordsDelete | RecordsQuery | RecordsRead | RecordsWrite, + incomingMessage: RecordsDelete | RecordsQuery | RecordsRead | RecordsSubscribe | RecordsWrite, protocolUri: string, contextId: string | undefined, protocolDefinition: ProtocolDefinition, @@ -481,7 +524,7 @@ export class ProtocolAuthorization { */ private static async getActionsSeekingARuleMatch( tenant: string, - incomingMessage: RecordsDelete | RecordsQuery | RecordsRead | RecordsWrite, + incomingMessage: RecordsDelete | RecordsQuery | RecordsRead | RecordsSubscribe | RecordsWrite, messageStore: MessageStore, ): Promise { @@ -495,6 +538,9 @@ export class ProtocolAuthorization { case DwnMethodName.Read: return [ProtocolAction.Read]; + case DwnMethodName.Subscribe: + return [ProtocolAction.Subscribe]; + case DwnMethodName.Write: const incomingRecordsWrite = incomingMessage as RecordsWrite; if (await incomingRecordsWrite.isInitialWrite()) { @@ -519,7 +565,7 @@ export class ProtocolAuthorization { */ private static async verifyAllowedActions( tenant: string, - incomingMessage: RecordsDelete | RecordsQuery | RecordsRead | RecordsWrite, + incomingMessage: RecordsDelete | RecordsQuery | RecordsRead | RecordsSubscribe | RecordsWrite, inboundMessageRuleSet: ProtocolRuleSet, ancestorMessageChain: RecordsWriteMessage[], messageStore: MessageStore, diff --git a/src/core/records-grant-authorization.ts b/src/core/records-grant-authorization.ts index 6b3259d55..9f48c6c48 100644 --- a/src/core/records-grant-authorization.ts +++ b/src/core/records-grant-authorization.ts @@ -1,7 +1,7 @@ import type { MessageStore } from '../types/message-store.js'; import type { RecordsPermissionScope } from '../types/permissions-grant-descriptor.js'; import type { PermissionsGrantMessage, RecordsPermissionsGrantMessage } from '../types/permissions-types.js'; -import type { RecordsDeleteMessage, RecordsQueryMessage, RecordsReadMessage, RecordsWriteMessage } from '../types/records-types.js'; +import type { RecordsDeleteMessage, RecordsQueryMessage, RecordsReadMessage, RecordsSubscribeMessage, RecordsWriteMessage } from '../types/records-types.js'; import { GrantAuthorization } from './grant-authorization.js'; import { PermissionsConditionPublication } from '../types/permissions-grant-descriptor.js'; @@ -96,6 +96,37 @@ export class RecordsGrantAuthorization { } } + /** + * Authorizes the scope of a PermissionsGrant for RecordsSubscribe. + * @param messageStore Used to check if the grant has been revoked. + */ + public static async authorizeSubscribe( + recordsSubscribeMessage: RecordsSubscribeMessage, + expectedGrantedToInGrant: string, + expectedGrantedForInGrant: string, + permissionsGrantMessage: PermissionsGrantMessage, + messageStore: MessageStore, + ): Promise { + + await GrantAuthorization.performBaseValidation({ + incomingMessage: recordsSubscribeMessage, + expectedGrantedToInGrant, + expectedGrantedForInGrant, + permissionsGrantMessage, + messageStore + }); + + // If the grant specifies a protocol, the query must specify the same protocol. + const protocolInGrant = (permissionsGrantMessage.descriptor.scope as RecordsPermissionScope).protocol; + const protocolInSubscribe = recordsSubscribeMessage.descriptor.filter.protocol; + if (protocolInGrant !== undefined && protocolInSubscribe !== protocolInGrant) { + throw new DwnError( + DwnErrorCode.RecordsGrantAuthorizationSubscribeProtocolScopeMismatch, + `Grant protocol scope ${protocolInGrant} does not match protocol in subscribe ${protocolInSubscribe}` + ); + } + } + /** * Authorizes the scope of a PermissionsGrant for RecordsDelete. * @param messageStore Used to check if the grant has been revoked. diff --git a/src/dwn.ts b/src/dwn.ts index ad6c3090d..d3d50fce8 100644 --- a/src/dwn.ts +++ b/src/dwn.ts @@ -1,20 +1,23 @@ import type { DataStore } from './types/data-store.js'; import type { EventLog } from './types/event-log.js'; +import type { EventStream } from './types/event-stream.js'; import type { MessageStore } from './types/message-store.js'; import type { MethodHandler } from './types/method-handler.js'; import type { TenantGate } from './core/tenant-gate.js'; import type { UnionMessageReply } from './core/message-reply.js'; -import type { EventsGetMessage, EventsGetReply, EventsQueryMessage, EventsQueryReply } from './types/event-types.js'; +import type { EventsGetMessage, EventsGetReply, EventsQueryMessage, EventsQueryReply, EventsSubscribeMessage, EventsSubscribeReply } from './types/event-types.js'; import type { GenericMessage, GenericMessageReply, MessageOptions } from './types/message-types.js'; import type { MessagesGetMessage, MessagesGetReply } from './types/messages-types.js'; import type { PermissionsGrantMessage, PermissionsRequestMessage, PermissionsRevokeMessage } from './types/permissions-types.js'; import type { ProtocolsConfigureMessage, ProtocolsQueryMessage, ProtocolsQueryReply } from './types/protocols-types.js'; -import type { RecordsDeleteMessage, RecordsQueryMessage, RecordsQueryReply, RecordsReadMessage, RecordsReadReply, RecordsWriteMessage, RecordsWriteMessageOptions } from './types/records-types.js'; +import type { RecordsDeleteMessage, RecordsQueryMessage, RecordsQueryReply, RecordsReadMessage, RecordsReadReply, RecordsSubscribeMessage, RecordsSubscribeReply, RecordsWriteMessage, RecordsWriteMessageOptions } from './types/records-types.js'; import { AllowAllTenantGate } from './core/tenant-gate.js'; import { DidResolver } from './did/did-resolver.js'; import { EventsGetHandler } from './handlers/events-get.js'; import { EventsQueryHandler } from './handlers/events-query.js'; +import { EventsSubscribeHandler } from './handlers/events-subscribe.js'; +import { EventStreamEmitter } from './event-log/event-stream.js'; import { Message } from './core/message.js'; import { messageReplyFromError } from './core/message-reply.js'; import { MessagesGetHandler } from './handlers/messages-get.js'; @@ -26,6 +29,7 @@ import { ProtocolsQueryHandler } from './handlers/protocols-query.js'; import { RecordsDeleteHandler } from './handlers/records-delete.js'; import { RecordsQueryHandler } from './handlers/records-query.js'; import { RecordsReadHandler } from './handlers/records-read.js'; +import { RecordsSubscribeHandler } from './handlers/records-subscribe.js'; import { RecordsWriteHandler } from './handlers/records-write.js'; import { DwnInterfaceName, DwnMethodName } from './enums/dwn-interface-method.js'; @@ -35,33 +39,94 @@ export class Dwn { private messageStore: MessageStore; private dataStore: DataStore; private eventLog: EventLog; + private eventStream: EventStream; private tenantGate: TenantGate; private constructor(config: DwnConfig) { this.didResolver = config.didResolver!; this.tenantGate = config.tenantGate!; + this.eventStream = config.eventStream!; this.messageStore = config.messageStore; this.dataStore = config.dataStore; this.eventLog = config.eventLog; this.methodHandlers = { - [DwnInterfaceName.Events + DwnMethodName.Get] : new EventsGetHandler(this.didResolver, this.eventLog), - [DwnInterfaceName.Events + DwnMethodName.Query] : new EventsQueryHandler(this.didResolver, this.eventLog), - [DwnInterfaceName.Messages + DwnMethodName.Get] : new MessagesGetHandler(this.didResolver, this.messageStore, this.dataStore), - [DwnInterfaceName.Permissions + DwnMethodName.Grant] : new PermissionsGrantHandler( - this.didResolver, this.messageStore, this.eventLog), + [DwnInterfaceName.Events + DwnMethodName.Get]: new EventsGetHandler( + this.didResolver, + this.eventLog, + ), + [DwnInterfaceName.Events + DwnMethodName.Query]: new EventsQueryHandler( + this.didResolver, + this.eventLog, + ), + [DwnInterfaceName.Events+ DwnMethodName.Subscribe]: new EventsSubscribeHandler( + this.didResolver, + this.messageStore, + this.eventStream, + ), + [DwnInterfaceName.Messages + DwnMethodName.Get]: new MessagesGetHandler( + this.didResolver, + this.messageStore, + this.dataStore, + ), + [DwnInterfaceName.Permissions + DwnMethodName.Grant]: new PermissionsGrantHandler( + this.didResolver, + this.messageStore, + this.eventLog, + this.eventStream + ), [DwnInterfaceName.Permissions + DwnMethodName.Request]: new PermissionsRequestHandler( - this.didResolver, this.messageStore, this.eventLog), + this.didResolver, + this.messageStore, + this.eventLog, + this.eventStream + ), [DwnInterfaceName.Permissions + DwnMethodName.Revoke]: new PermissionsRevokeHandler( - this.didResolver, this.messageStore, this.eventLog), + this.didResolver, + this.messageStore, + this.eventLog, + this.eventStream + ), [DwnInterfaceName.Protocols + DwnMethodName.Configure]: new ProtocolsConfigureHandler( - this.didResolver, this.messageStore, this.dataStore, this.eventLog), - [DwnInterfaceName.Protocols + DwnMethodName.Query] : new ProtocolsQueryHandler(this.didResolver, this.messageStore, this.dataStore), - [DwnInterfaceName.Records + DwnMethodName.Delete] : new RecordsDeleteHandler( - this.didResolver, this.messageStore, this.dataStore, this.eventLog), - [DwnInterfaceName.Records + DwnMethodName.Query] : new RecordsQueryHandler(this.didResolver, this.messageStore, this.dataStore), - [DwnInterfaceName.Records + DwnMethodName.Read] : new RecordsReadHandler(this.didResolver, this.messageStore, this.dataStore), - [DwnInterfaceName.Records + DwnMethodName.Write] : new RecordsWriteHandler(this.didResolver, this.messageStore, this.dataStore, this.eventLog), + this.didResolver, + this.messageStore, + this.eventLog, + this.eventStream + ), + [DwnInterfaceName.Protocols + DwnMethodName.Query]: new ProtocolsQueryHandler( + this.didResolver, + this.messageStore, + this.dataStore + ), + [DwnInterfaceName.Records + DwnMethodName.Delete]: new RecordsDeleteHandler( + this.didResolver, + this.messageStore, + this.dataStore, + this.eventLog, + this.eventStream + ), + [DwnInterfaceName.Records + DwnMethodName.Query]: new RecordsQueryHandler( + this.didResolver, + this.messageStore, + this.dataStore + ), + [DwnInterfaceName.Records + DwnMethodName.Read]: new RecordsReadHandler( + this.didResolver, + this.messageStore, + this.dataStore + ), + [DwnInterfaceName.Records + DwnMethodName.Subscribe]: new RecordsSubscribeHandler( + this.didResolver, + this.messageStore, + this.eventStream + ), + [DwnInterfaceName.Records + DwnMethodName.Write]: new RecordsWriteHandler( + this.didResolver, + this.messageStore, + this.dataStore, + this.eventLog, + this.eventStream + ) }; } @@ -71,6 +136,7 @@ export class Dwn { public static async create(config: DwnConfig): Promise { config.didResolver ??= new DidResolver(); config.tenantGate ??= new AllowAllTenantGate(); + config.eventStream ??= new EventStreamEmitter({ messageStore: config.messageStore, didResolver: config.didResolver }); const dwn = new Dwn(config); await dwn.open(); @@ -82,9 +148,11 @@ export class Dwn { await this.messageStore.open(); await this.dataStore.open(); await this.eventLog.open(); + await this.eventStream.open(); } public async close(): Promise { + this.eventStream.close(); this.messageStore.close(); this.dataStore.close(); this.eventLog.close(); @@ -96,6 +164,7 @@ export class Dwn { */ public async processMessage(tenant: string, rawMessage: EventsGetMessage): Promise; public async processMessage(tenant: string, rawMessage: EventsQueryMessage): Promise; + public async processMessage(tenant: string, rawMessage: EventsSubscribeMessage): Promise; public async processMessage(tenant: string, rawMessage: MessagesGetMessage): Promise; public async processMessage(tenant: string, rawMessage: ProtocolsConfigureMessage): Promise; public async processMessage(tenant: string, rawMessage: ProtocolsQueryMessage): Promise; @@ -104,6 +173,7 @@ export class Dwn { public async processMessage(tenant: string, rawMessage: PermissionsRevokeMessage): Promise; public async processMessage(tenant: string, rawMessage: RecordsDeleteMessage): Promise; public async processMessage(tenant: string, rawMessage: RecordsQueryMessage): Promise; + public async processMessage(tenant: string, rawMessage: RecordsSubscribeMessage): Promise; public async processMessage(tenant: string, rawMessage: RecordsReadMessage): Promise; public async processMessage(tenant: string, rawMessage: RecordsWriteMessage, options?: RecordsWriteMessageOptions): Promise; public async processMessage(tenant: string, rawMessage: unknown, options?: MessageOptions): Promise; @@ -154,6 +224,7 @@ export class Dwn { // Verify interface and method const dwnInterface = rawMessage?.descriptor?.interface; const dwnMethod = rawMessage?.descriptor?.method; + if (dwnInterface === undefined || dwnMethod === undefined) { return { status: { code: 400, detail: `Both interface and method must be present, interface: ${dwnInterface}, method: ${dwnMethod}` } @@ -174,10 +245,11 @@ export class Dwn { * DWN configuration. */ export type DwnConfig = { - didResolver?: DidResolver, + didResolver?: DidResolver; + eventStream?: EventStream; tenantGate?: TenantGate; messageStore: MessageStore; dataStore: DataStore; - eventLog: EventLog + eventLog: EventLog; }; diff --git a/src/enums/dwn-interface-method.ts b/src/enums/dwn-interface-method.ts index a1e567aa7..21cd7aaa5 100644 --- a/src/enums/dwn-interface-method.ts +++ b/src/enums/dwn-interface-method.ts @@ -16,5 +16,6 @@ export enum DwnMethodName { Request = 'Request', Revoke = 'Revoke', Write = 'Write', - Delete = 'Delete' + Delete = 'Delete', + Subscribe = 'Subscribe' } diff --git a/src/event-log/event-log-level.ts b/src/event-log/event-log-level.ts index 372ba1f96..d1d548113 100644 --- a/src/event-log/event-log-level.ts +++ b/src/event-log/event-log-level.ts @@ -1,4 +1,5 @@ import type { EventLog } from '../types/event-log.js'; +import type { EventStream } from '../types/event-stream.js'; import type { ULIDFactory } from 'ulidx'; import type { Filter, KeyValues, PaginationCursor } from '../types/query-types.js'; @@ -14,6 +15,7 @@ type EventLogLevelConfig = { */ location?: string, createLevelDatabase?: typeof createLevelDatabase, + eventStream?: EventStream, }; export class EventLogLevel implements EventLog { diff --git a/src/event-log/event-stream.ts b/src/event-log/event-stream.ts new file mode 100644 index 000000000..c34278bab --- /dev/null +++ b/src/event-log/event-stream.ts @@ -0,0 +1,115 @@ +import type { DidResolver } from '../did/did-resolver.js'; +import type { GenericMessage } from '../types/message-types.js'; +import type { MessageStore } from '../types/message-store.js'; +import type { EventsSubscribeMessage, EventSubscription } from '../types/event-types.js'; +import type { EventStream, EventStreamSubscription } from '../types/event-stream.js'; +import type { Filter, KeyValues } from '../types/query-types.js'; +import type { RecordsSubscribeMessage, RecordsSubscription } from '../types/records-types.js'; + +import { EventEmitter } from 'events'; +import { EventsSubscribe } from '../interfaces/events-subscribe.js'; +import { EventsSubscriptionHandler } from '../handlers/events-subscribe.js'; +import { Message } from '../core/message.js'; +import { RecordsSubscribe } from '../interfaces/records-subscribe.js'; +import { RecordsSubscriptionHandler } from '../handlers/records-subscribe.js'; +import { DwnError, DwnErrorCode } from '../core/dwn-error.js'; + +const eventChannel = 'events'; + +type EventStreamConfig = { + emitter?: EventEmitter; + messageStore: MessageStore; + didResolver: DidResolver; + reauthorizationTTL?: number; +}; + +export class EventStreamEmitter implements EventStream { + private eventEmitter: EventEmitter; + private didResolver: DidResolver; + private messageStore: MessageStore; + private reauthorizationTTL: number; + + private isOpen: boolean = false; + private subscriptions: Map = new Map(); + + constructor(config: EventStreamConfig) { + this.didResolver = config.didResolver; + this.messageStore = config.messageStore; + this.reauthorizationTTL = config.reauthorizationTTL ?? 0; // if set to zero it does not reauthorize + + // we capture the rejections and currently just log the errors that are produced + this.eventEmitter = config.emitter || new EventEmitter({ captureRejections: true }); + } + + private get eventChannel(): string { + return `${eventChannel}_bus`; + } + + private eventError = (error: any): void => { + console.error('event emitter error', error); + }; + + async subscribe(tenant: string, message: EventsSubscribeMessage, filters: Filter[]): Promise; + async subscribe(tenant: string, message: RecordsSubscribeMessage, filters: Filter[]): Promise; + async subscribe(tenant: string, message: GenericMessage, filters: Filter[]): Promise { + const messageCid = await Message.getCid(message); + let subscription = this.subscriptions.get(messageCid); + if (subscription !== undefined) { + return subscription; + } + + const unsubscribe = async ():Promise => { await this.unsubscribe(messageCid); }; + + if (RecordsSubscribe.isRecordsSubscribeMessage(message)) { + subscription = await RecordsSubscriptionHandler.create({ + tenant, + message, + filters, + unsubscribe, + eventEmitter : this.eventEmitter, + messageStore : this.messageStore, + reauthorizationTTL : this.reauthorizationTTL, + }); + } else if (EventsSubscribe.isEventsSubscribeMessage(message)) { + subscription = await EventsSubscriptionHandler.create(tenant, message, filters, this.eventEmitter, this.messageStore, unsubscribe); + } else { + throw new DwnError(DwnErrorCode.EventStreamSubscriptionNotSupported, 'not a supported subscription message'); + } + + this.subscriptions.set(messageCid, subscription); + this.eventEmitter.addListener(this.eventChannel, subscription.listener); + + return subscription; + } + + private async unsubscribe(id:string): Promise { + const subscription = this.subscriptions.get(id); + if (subscription !== undefined) { + this.subscriptions.delete(id); + this.eventEmitter.removeListener(this.eventChannel, subscription.listener); + } + } + + async open(): Promise { + this.eventEmitter.on('error', this.eventError); + this.isOpen = true; + } + + async close(): Promise { + this.isOpen = false; + this.eventEmitter.removeAllListeners(); + } + + emit(tenant: string, message: GenericMessage, ...matchIndexes: KeyValues[]): void { + if (!this.isOpen) { + //todo: dwn error + throw new Error('Event stream is not open. Cannot add to the stream.'); + } + try { + this.eventEmitter.emit(this.eventChannel, tenant, message, ...matchIndexes); + } catch (error) { + //todo: dwn catch error; + throw error; // You can choose to handle or propagate the error as needed. + } + } +} \ No newline at end of file diff --git a/src/event-log/subscription.ts b/src/event-log/subscription.ts new file mode 100644 index 000000000..61dd19b53 --- /dev/null +++ b/src/event-log/subscription.ts @@ -0,0 +1,72 @@ +import type { EventEmitter } from 'events'; +import type { EventHandler } from '../types/event-types.js'; +import type { GenericMessage } from '../types/message-types.js'; +import type { MessageStore } from '../types/message-store.js'; +import type { Filter, KeyValues } from '../types/query-types.js'; + +import { FilterUtility } from '../utils/filter.js'; + +export class SubscriptionBase { + protected eventEmitter: EventEmitter; + protected messageStore: MessageStore; + protected filters: Filter[]; + protected tenant: string; + protected message: GenericMessage; + + #unsubscribe: () => Promise; + #id: string; + + protected constructor(options: { + tenant: string, + message: GenericMessage, + id: string, + filters: Filter[], + eventEmitter: EventEmitter, + messageStore: MessageStore, + unsubscribe: () => Promise; + } + ) { + const { tenant, id, filters, eventEmitter, message, messageStore, unsubscribe } = options; + + this.tenant = tenant; + this.#id = id; + this.filters = filters; + this.eventEmitter = eventEmitter; + this.message = message; + this.messageStore = messageStore; + this.#unsubscribe = unsubscribe; + } + + get eventChannel(): string { + return `${this.tenant}_${this.#id}`; + } + + get id(): string { + return this.#id; + } + + protected matchFilter(tenant: string, ...indexes: KeyValues[]):boolean { + return this.tenant === tenant && + indexes.find(index => FilterUtility.matchAnyFilter(index, this.filters)) !== undefined; + } + + public listener = (tenant: string, message: GenericMessage, ...indexes: KeyValues[]):void => { + if (this.matchFilter(tenant, ...indexes)) { + this.eventEmitter.emit(this.eventChannel, message); + } + }; + + on(handler: EventHandler): { off: () => void } { + this.eventEmitter.on(this.eventChannel, handler); + return { + off: (): void => { + this.eventEmitter.off(this.eventChannel, handler); + } + }; + } + + async close(): Promise { + this.eventEmitter.removeAllListeners(this.eventChannel); + await this.#unsubscribe(); + } +} \ No newline at end of file diff --git a/src/handlers/events-subscribe.ts b/src/handlers/events-subscribe.ts new file mode 100644 index 000000000..3c843b988 --- /dev/null +++ b/src/handlers/events-subscribe.ts @@ -0,0 +1,69 @@ +import type { DidResolver } from '../did/did-resolver.js'; +import type EventEmitter from 'events'; +import type { EventStream } from '../types/event-stream.js'; +import type { Filter } from '../types/query-types.js'; +import type { MessageStore } from '../types/message-store.js'; +import type { MethodHandler } from '../types/method-handler.js'; +import type { EventsSubscribeMessage, EventsSubscribeReply } from '../types/event-types.js'; + +import { EventsSubscribe } from '../interfaces/events-subscribe.js'; +import { Message } from '../core/message.js'; +import { messageReplyFromError } from '../core/message-reply.js'; +import { SubscriptionBase } from '../event-log/subscription.js'; +import { authenticate, authorizeOwner } from '../core/auth.js'; + +export class EventsSubscribeHandler implements MethodHandler { + constructor( + private didResolver: DidResolver, + private messageStore: MessageStore, + private eventStream: EventStream + ) {} + + public async handle({ + tenant, + message, + }: { + tenant: string; + message: EventsSubscribeMessage; + }): Promise { + let subscriptionRequest: EventsSubscribe; + try { + subscriptionRequest = await EventsSubscribe.parse(message); + } catch (e) { + return messageReplyFromError(e, 400); + } + + try { + await authenticate(message.authorization, this.didResolver); + await authorizeOwner(tenant, subscriptionRequest); + } catch (error) { + return messageReplyFromError(error, 401); + } + + try { + const subscription = await this.eventStream.subscribe(tenant, message, []); + const messageReply: EventsSubscribeReply = { + status: { code: 200, detail: 'OK' }, + subscription, + }; + return messageReply; + } catch (error) { + return messageReplyFromError(error, 401); + } + } +} + +export class EventsSubscriptionHandler extends SubscriptionBase { + public static async create( + tenant: string, + message: EventsSubscribeMessage, + filters: Filter[], + eventEmitter: EventEmitter, + messageStore: MessageStore, + unsubscribe: () => Promise + ): Promise { + const id = await Message.getCid(message); + return new EventsSubscriptionHandler({ tenant, message, id, filters, eventEmitter, messageStore, unsubscribe }); + } +}; + diff --git a/src/handlers/permissions-grant.ts b/src/handlers/permissions-grant.ts index 04a850076..8ed205d7b 100644 --- a/src/handlers/permissions-grant.ts +++ b/src/handlers/permissions-grant.ts @@ -1,5 +1,6 @@ import type { DidResolver } from '../did/did-resolver.js'; import type { EventLog } from '../types//event-log.js'; +import type { EventStream } from '../types/event-stream.js'; import type { GenericMessageReply } from '../types/message-types.js'; import type { KeyValues } from '../types/query-types.js'; import type { MessageStore } from '../types//message-store.js'; @@ -13,7 +14,12 @@ import { PermissionsGrant } from '../interfaces/permissions-grant.js'; import { removeUndefinedProperties } from '../utils/object.js'; export class PermissionsGrantHandler implements MethodHandler { - constructor(private didResolver: DidResolver, private messageStore: MessageStore, private eventLog: EventLog) { } + constructor( + private didResolver: DidResolver, + private messageStore: MessageStore, + private eventLog: EventLog, + private eventStream: EventStream + ) { } public async handle({ tenant, @@ -41,6 +47,7 @@ export class PermissionsGrantHandler implements MethodHandler { if (existingMessage === undefined) { await this.messageStore.put(tenant, message, indexes); await this.eventLog.append(tenant, messageCid, indexes); + this.eventStream.emit(tenant, message, indexes); } return { diff --git a/src/handlers/permissions-request.ts b/src/handlers/permissions-request.ts index d1a0d762e..1c3ddb013 100644 --- a/src/handlers/permissions-request.ts +++ b/src/handlers/permissions-request.ts @@ -1,5 +1,6 @@ import type { DidResolver } from '../did/did-resolver.js'; import type { EventLog } from '../types//event-log.js'; +import type { EventStream } from '../types/event-stream.js'; import type { GenericMessageReply } from '../types/message-types.js'; import type { MessageStore } from '../types//message-store.js'; import type { MethodHandler } from '../types/method-handler.js'; @@ -12,7 +13,12 @@ import { PermissionsRequest } from '../interfaces/permissions-request.js'; export class PermissionsRequestHandler implements MethodHandler { - constructor(private didResolver: DidResolver, private messageStore: MessageStore, private eventLog: EventLog) { } + constructor( + private didResolver: DidResolver, + private messageStore: MessageStore, + private eventLog: EventLog, + private eventStream: EventStream + ) { } public async handle({ tenant, @@ -45,6 +51,7 @@ export class PermissionsRequestHandler implements MethodHandler { if (existingMessage === undefined) { await this.messageStore.put(tenant, message, indexes); await this.eventLog.append(tenant, messageCid, indexes); + this.eventStream.emit(tenant, message, indexes); } return { diff --git a/src/handlers/permissions-revoke.ts b/src/handlers/permissions-revoke.ts index f7fda04f7..c31d9772e 100644 --- a/src/handlers/permissions-revoke.ts +++ b/src/handlers/permissions-revoke.ts @@ -1,5 +1,6 @@ import type { DidResolver } from '../did/did-resolver.js'; import type { EventLog } from '../types/event-log.js'; +import type { EventStream } from '../types/event-stream.js'; import type { GenericMessageReply } from '../types/message-types.js'; import type { KeyValues } from '../types/query-types.js'; import type { MessageStore } from '../types/message-store.js'; @@ -13,7 +14,12 @@ import { PermissionsRevoke } from '../interfaces/permissions-revoke.js'; import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; export class PermissionsRevokeHandler implements MethodHandler { - constructor(private didResolver: DidResolver, private messageStore: MessageStore, private eventLog: EventLog) { } + constructor( + private didResolver: DidResolver, + private messageStore: MessageStore, + private eventLog: EventLog, + private eventStream: EventStream + ) { } public async handle({ tenant, @@ -89,6 +95,9 @@ export class PermissionsRevokeHandler implements MethodHandler { await this.messageStore.put(tenant, message, indexes); await this.eventLog.append(tenant, await Message.getCid(message), indexes); + // emit revoke and exercise any revocation necessary within the event stream + this.eventStream.emit(tenant, message, indexes); + // Delete existing revokes which are all newer than the incoming message const removedRevokeCids: string[] = []; for (const existingRevoke of existingRevokesForGrant) { diff --git a/src/handlers/protocols-configure.ts b/src/handlers/protocols-configure.ts index 5873fee58..a0eb5b3de 100644 --- a/src/handlers/protocols-configure.ts +++ b/src/handlers/protocols-configure.ts @@ -1,6 +1,6 @@ -import type { DataStore } from '../types/data-store.js'; import type { DidResolver } from '../did/did-resolver.js'; import type { EventLog } from '../types/event-log.js'; +import type { EventStream } from '../types/event-stream.js'; import type { GenericMessageReply } from '../types/message-types.js'; import type { MessageStore } from '../types//message-store.js'; import type { MethodHandler } from '../types/method-handler.js'; @@ -14,7 +14,12 @@ import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.j export class ProtocolsConfigureHandler implements MethodHandler { - constructor(private didResolver: DidResolver, private messageStore: MessageStore, private dataStore: DataStore, private eventLog: EventLog) { } + constructor( + private didResolver: DidResolver, + private messageStore: MessageStore, + private eventLog: EventLog, + private eventStream: EventStream + ) { } public async handle({ tenant, @@ -58,9 +63,10 @@ export class ProtocolsConfigureHandler implements MethodHandler { if (incomingMessageIsNewest) { const indexes = ProtocolsConfigureHandler.constructIndexes(protocolsConfigure); - const messageCid = await Message.getCid(message); await this.messageStore.put(tenant, message, indexes); + const messageCid = await Message.getCid(message); await this.eventLog.append(tenant, messageCid, indexes); + this.eventStream.emit(tenant, message, indexes); messageReply = { status: { code: 202, detail: 'Accepted' } diff --git a/src/handlers/records-delete.ts b/src/handlers/records-delete.ts index 4a9935546..5168cfc81 100644 --- a/src/handlers/records-delete.ts +++ b/src/handlers/records-delete.ts @@ -1,8 +1,8 @@ import type { DataStore } from '../types/data-store.js'; import type { DidResolver } from '../did/did-resolver.js'; import type { EventLog } from '../types/event-log.js'; +import type { EventStream } from '../types/event-stream.js'; import type { GenericMessageReply } from '../types/message-types.js'; -import type { KeyValues } from '../types/query-types.js'; import type { MessageStore } from '../types//message-store.js'; import type { MethodHandler } from '../types/method-handler.js'; import type { RecordsDeleteMessage, RecordsWriteMessage } from '../types/records-types.js'; @@ -13,14 +13,19 @@ import { messageReplyFromError } from '../core/message-reply.js'; import { ProtocolAuthorization } from '../core/protocol-authorization.js'; import { RecordsDelete } from '../interfaces/records-delete.js'; import { RecordsWrite } from '../interfaces/records-write.js'; -import { removeUndefinedProperties } from '../utils/object.js'; import { StorageController } from '../store/storage-controller.js'; import { DwnError, DwnErrorCode } from '../core/dwn-error.js'; import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; export class RecordsDeleteHandler implements MethodHandler { - constructor(private didResolver: DidResolver, private messageStore: MessageStore, private dataStore: DataStore, private eventLog: EventLog) { } + constructor( + private didResolver: DidResolver, + private messageStore: MessageStore, + private dataStore: DataStore, + private eventLog: EventLog, + private eventStream: EventStream + ) { } public async handle({ tenant, @@ -86,12 +91,16 @@ export class RecordsDeleteHandler implements MethodHandler { } const recordsWrite = await RecordsWrite.getInitialWrite(existingMessages); - const indexes = RecordsDeleteHandler.constructIndexes(recordsDelete, recordsWrite); - await this.messageStore.put(tenant, message, indexes); - + const indexes = recordsDelete.constructIndexes(recordsWrite); const messageCid = await Message.getCid(message); + await this.messageStore.put(tenant, message, indexes); await this.eventLog.append(tenant, messageCid, indexes); + const latestRecordsWrite = newestExistingMessage as RecordsWriteMessage; + const mostRecentWrite = await RecordsWrite.parse(latestRecordsWrite); + const mostRecentWriteIndexes = await mostRecentWrite.constructIndexes(false); + this.eventStream.emit(tenant, message, indexes, mostRecentWriteIndexes); + // delete all existing messages that are not newest, except for the initial write await StorageController.deleteAllOlderMessagesButKeepInitialWrite( tenant, existingMessages, newestMessage, this.messageStore, this.dataStore, this.eventLog @@ -125,30 +134,4 @@ export class RecordsDeleteHandler implements MethodHandler { ); } } - - /** - * Indexed properties needed for MessageStore indexing. - */ - static constructIndexes(recordsDelete: RecordsDelete, recordsWrite: RecordsWriteMessage): KeyValues { - const message = recordsDelete.message; - const descriptor = { ...message.descriptor }; - - // we add the immutable properties from the initial RecordsWrite message in order to use them when querying relevant deletes. - const { protocol, protocolPath, recipient, schema, parentId, dataFormat, dateCreated } = recordsWrite.descriptor; - - // NOTE: the "trick" not may not be apparent on how a query is able to omit deleted records: - // we intentionally not add index for `isLatestBaseState` at all, this means that upon a successful delete, - // no messages with the record ID will match any query because queries by design filter by `isLatestBaseState = true`, - // `isLatestBaseState` for the initial delete would have been toggled to `false` - const indexes: { [key:string]: string | undefined } = { - // isLatestBaseState : "true", // intentionally showing that this index is omitted - protocol, protocolPath, recipient, schema, parentId, dataFormat, dateCreated, - contextId : recordsWrite.contextId, - author : recordsDelete.author, - ...descriptor - }; - removeUndefinedProperties(indexes); - - return indexes as KeyValues; - } -}; \ No newline at end of file +}; diff --git a/src/handlers/records-subscribe.ts b/src/handlers/records-subscribe.ts new file mode 100644 index 000000000..fa14e3f50 --- /dev/null +++ b/src/handlers/records-subscribe.ts @@ -0,0 +1,316 @@ +import type { DidResolver } from '../did/did-resolver.js'; +import type EventEmitter from 'events'; +import type { EventStream } from '../types/event-stream.js'; +import type { GenericMessage } from '../types/message-types.js'; +import type { MessageStore } from '../types//message-store.js'; +import type { MethodHandler } from '../types/method-handler.js'; +import type { Filter, KeyValues } from '../types/query-types.js'; +import type { RecordsDeleteMessage, RecordsSubscribeMessage, RecordsSubscribeReply, RecordsWriteMessage } from '../types/records-types.js'; + +import { authenticate } from '../core/auth.js'; +import { Message } from '../core/message.js'; +import { messageReplyFromError } from '../core/message-reply.js'; +import { ProtocolAuthorization } from '../core/protocol-authorization.js'; +import { Records } from '../utils/records.js'; +import { RecordsDelete } from '../interfaces/records-delete.js'; +import { RecordsSubscribe } from '../interfaces/records-subscribe.js'; +import { RecordsWrite } from '../interfaces/records-write.js'; +import { SubscriptionBase } from '../event-log/subscription.js'; +import { Time } from '../utils/time.js'; +import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; + +export class RecordsSubscribeHandler implements MethodHandler { + + constructor(private didResolver: DidResolver, private messageStore: MessageStore, private eventStream: EventStream) { } + + public async handle({ + tenant, + message + }: {tenant: string, message: RecordsSubscribeMessage}): Promise { + let recordsSubscribe: RecordsSubscribe; + try { + recordsSubscribe = await RecordsSubscribe.parse(message); + } catch (e) { + return messageReplyFromError(e, 400); + } + let filters:Filter[] = []; + // if this is an anonymous subscribe and the filter supports published records, subscribe to only published records + if (RecordsSubscribeHandler.filterIncludesPublishedRecords(recordsSubscribe) && recordsSubscribe.author === undefined) { + // return a stream + filters = await RecordsSubscribeHandler.subscribePublishedRecords(recordsSubscribe); + } else { + // authentication and authorization + try { + await authenticate(message.authorization!, this.didResolver); + await RecordsSubscribeHandler.authorizeRecordsSubscribe(tenant, recordsSubscribe, this.messageStore); + } catch (error) { + return messageReplyFromError(error, 401); + } + + if (recordsSubscribe.author === tenant) { + filters = await RecordsSubscribeHandler.subscribeAsOwner(recordsSubscribe); + } else { + filters = await RecordsSubscribeHandler.subscribeAsNonOwner(recordsSubscribe); + } + } + + const subscription = await this.eventStream.subscribe(tenant, message, filters); + return { + status: { code: 200, detail: 'OK' }, + subscription + }; + } + + // 1) owner filters + // 2) public filters + // 3) authorized filters + // a) protocol authorized + // b) grant authorized + + /** + * Fetches the records as the owner of the DWN with no additional filtering. + */ + private static async subscribeAsOwner(RecordsSubscribe: RecordsSubscribe): Promise { + const { filter } = RecordsSubscribe.message.descriptor; + + const subscribeFilter = { + ...Records.convertFilter(filter), + interface : DwnInterfaceName.Records, + method : [ DwnMethodName.Write, DwnMethodName.Delete ], // we fetch both write and delete so that subscriber can update state. + }; + + return [subscribeFilter]; + } + + /** + * Subscribe to records as a non-owner. + * + * Filters can support returning both published and unpublished records, + * as well as explicitly only published or only unpublished records. + * + * A) BOTH published and unpublished: + * 1. published records; and + * 2. unpublished records intended for the subscription author (where `recipient` is the subscription author); and + * 3. unpublished records authorized by a protocol rule. + * + * B) PUBLISHED: + * 1. only published records; + * + * C) UNPUBLISHED: + * 1. unpublished records intended for the subscription author (where `recipient` is the subscription author); and + * 2. unpublished records authorized by a protocol rule. + * + */ + private static async subscribeAsNonOwner( + recordsSubscribe: RecordsSubscribe + ): Promise { + const filters:Filter[] = []; + + if (RecordsSubscribeHandler.filterIncludesPublishedRecords(recordsSubscribe)) { + filters.push(RecordsSubscribeHandler.buildPublishedRecordsFilter(recordsSubscribe)); + } + + if (RecordsSubscribeHandler.filterIncludesUnpublishedRecords(recordsSubscribe)) { + filters.push(RecordsSubscribeHandler.buildUnpublishedRecordsBySubscribeAuthorFilter(recordsSubscribe)); + + const recipientFilter = recordsSubscribe.message.descriptor.filter.recipient; + if (recipientFilter === undefined || recipientFilter === recordsSubscribe.author) { + filters.push(RecordsSubscribeHandler.buildUnpublishedRecordsForSubscribeAuthorFilter(recordsSubscribe)); + } + + if (RecordsSubscribeHandler.shouldProtocolAuthorizeSubscribe(recordsSubscribe)) { + filters.push(RecordsSubscribeHandler.buildUnpublishedProtocolAuthorizedRecordsFilter(recordsSubscribe)); + } + } + return filters; + } + + /** + * Fetches only published records. + */ + private static async subscribePublishedRecords( + recordsSubscribe: RecordsSubscribe + ): Promise { + const filter = RecordsSubscribeHandler.buildPublishedRecordsFilter(recordsSubscribe); + return [filter]; + } + + private static buildPublishedRecordsFilter(recordsSubscribe: RecordsSubscribe): Filter { + // fetch all published records matching the subscribe + return { + ...Records.convertFilter(recordsSubscribe.message.descriptor.filter), + interface : DwnInterfaceName.Records, + method : [ DwnMethodName.Write, DwnMethodName.Delete ], + published : true, + }; + } + + /** + * Creates a filter for unpublished records that are intended for the subscribe author (where `recipient` is the author). + */ + private static buildUnpublishedRecordsForSubscribeAuthorFilter(recordsSubscribe: RecordsSubscribe): Filter { + // include records where recipient is subscribe author + return { + ...Records.convertFilter(recordsSubscribe.message.descriptor.filter), + interface : DwnInterfaceName.Records, + method : [ DwnMethodName.Write, DwnMethodName.Delete ], + recipient : recordsSubscribe.author!, + published : false + }; + } + + /** + * Creates a filter for unpublished records that are within the specified protocol. + * Validation that `protocol` and other required protocol-related fields occurs before this method. + */ + private static buildUnpublishedProtocolAuthorizedRecordsFilter(recordsSubscribe: RecordsSubscribe): Filter { + return { + ...Records.convertFilter(recordsSubscribe.message.descriptor.filter), + interface : DwnInterfaceName.Records, + method : [ DwnMethodName.Write, DwnMethodName.Delete ], + published : false + }; + } + + /** + * Creates a filter for only unpublished records where the author is the same as the subscribe author. + */ + private static buildUnpublishedRecordsBySubscribeAuthorFilter(recordsSubscribe: RecordsSubscribe): Filter { + // include records where author is the same as the subscribe author + return { + ...Records.convertFilter(recordsSubscribe.message.descriptor.filter), + author : recordsSubscribe.author!, + interface : DwnInterfaceName.Records, + method : [ DwnMethodName.Write, DwnMethodName.Delete ], + published : false + }; + } + + /** + * Determines if ProtocolAuthorization.authorizeSubscribe should be run and if the corresponding filter should be used. + */ + private static shouldProtocolAuthorizeSubscribe(recordsSubscribe: RecordsSubscribe): boolean { + return recordsSubscribe.signaturePayload!.protocolRole !== undefined; + } + + /** + * Checks if the recordSubscribe filter supports returning published records. + */ + private static filterIncludesPublishedRecords(recordsSubscribe: RecordsSubscribe): boolean { + const { filter } = recordsSubscribe.message.descriptor; + // When `published` and `datePublished` range are both undefined, published records can be returned. + return filter.datePublished !== undefined || filter.published !== false; + } + + /** + * Checks if the recordSubscribe filter supports returning unpublished records. + */ + private static filterIncludesUnpublishedRecords(recordsSubscribe: RecordsSubscribe): boolean { + const { filter } = recordsSubscribe.message.descriptor; + // When `published` and `datePublished` range are both undefined, unpublished records can be returned. + if (filter.datePublished === undefined && filter.published === undefined) { + return true; + } + return filter.published === false; + } + + /** + * @param messageStore Used to check if the grant has been revoked. + */ + public static async authorizeRecordsSubscribe( + tenant: string, + recordsSubscribe: RecordsSubscribe, + messageStore: MessageStore + ): Promise { + + if (Message.isSignedByDelegate(recordsSubscribe.message)) { + await recordsSubscribe.authorizeDelegate(messageStore); + } + + // Only run protocol authz if message deliberately invokes it + if (RecordsSubscribeHandler.shouldProtocolAuthorizeSubscribe(recordsSubscribe)) { + await ProtocolAuthorization.authorizeSubscription(tenant, recordsSubscribe, messageStore); + } + } +} + +export class RecordsSubscriptionHandler extends SubscriptionBase { + private recordsSubscribe: RecordsSubscribe; + + private reauthorizationTTL: number; + private reauthorizationTime?: string; + + constructor(options: { + id: string, + tenant: string, + recordsSubscribe: RecordsSubscribe, + filters: Filter[], + eventEmitter: EventEmitter, + messageStore: MessageStore, + unsubscribe: () => Promise; + reauthorizationTTL:number, + }) { + const { recordsSubscribe, reauthorizationTTL } = options; + super({ ...options, message: recordsSubscribe.message }); + this.recordsSubscribe = recordsSubscribe; + + // set reauthorization option, if reauthorizationTTL is zero it will never re-authorize. + // if reauthorizationTTL is less than zero it will re-authorize with each matching event. + // otherwise it will re-authorize only after a TTL and reset its timer each time. + this.reauthorizationTTL = reauthorizationTTL; + if (this.reauthorizationTTL > 0) { + this.reauthorizationTime = Time.createOffsetTimestamp({ seconds: this.reauthorizationTTL }); + } + } + + get shouldAuthorize(): boolean { + return this.reauthorizationTTL < 0 || + this.reauthorizationTime !== undefined && Time.getCurrentTimestamp() >= this.reauthorizationTime; + } + + private async reauthorize():Promise { + this.reauthorizationTime = Time.createOffsetTimestamp({ seconds: this.reauthorizationTTL! }); + await RecordsSubscribeHandler.authorizeRecordsSubscribe(this.tenant, this.recordsSubscribe, this.messageStore); + } + + public static async create(options: { + tenant: string, + message: RecordsSubscribeMessage, + filters: Filter[], + eventEmitter: EventEmitter, + messageStore: MessageStore, + unsubscribe: () => Promise; + reauthorizationTTL: number + }): Promise { + const id = await Message.getCid(options.message); + const recordsSubscribe = await RecordsSubscribe.parse(options.message); + return new RecordsSubscriptionHandler({ ...options, id, recordsSubscribe }); + } + + public listener = async (tenant: string, message: GenericMessage, ...indexes: KeyValues[]):Promise => { + if (this.matchFilter(tenant, ...indexes)) { + if (this.shouldAuthorize) { + try { + await this.reauthorize(); + } catch (error) { + //todo: check for known authorization errors + // console.log('reauthorize error', error); + await this.close(); + } + } + + if (RecordsWrite.isRecordsWriteMessage(message) || RecordsDelete.isRecordsDeleteMessage(message)) { + this.eventEmitter.emit(this.eventChannel, message); + } + } + }; + + on(handler:(message: RecordsWriteMessage | RecordsDeleteMessage) => void): { off: () => void } { + this.eventEmitter.on(this.eventChannel, handler); + return { + off: (): void => { + this.eventEmitter.off(this.eventChannel, handler); + } + }; + } +}; diff --git a/src/handlers/records-write.ts b/src/handlers/records-write.ts index 3d356fe8d..899d2c690 100644 --- a/src/handlers/records-write.ts +++ b/src/handlers/records-write.ts @@ -1,6 +1,7 @@ import type { DataStore } from '../types/data-store.js'; import type { DidResolver } from '../did/did-resolver.js'; import type { EventLog } from '../types/event-log.js'; +import type { EventStream } from '../types/event-stream.js'; import type { GenericMessageReply } from '../types/message-types.js'; import type { MessageStore } from '../types//message-store.js'; import type { MethodHandler } from '../types/method-handler.js'; @@ -25,7 +26,13 @@ type HandlerArgs = { tenant: string, message: RecordsWriteMessage, dataStream?: export class RecordsWriteHandler implements MethodHandler { - constructor(private didResolver: DidResolver, private messageStore: MessageStore, private dataStore: DataStore, private eventLog: EventLog) { } + constructor( + private didResolver: DidResolver, + private messageStore: MessageStore, + private dataStore: DataStore, + private eventLog: EventLog, + private eventStream: EventStream + ) { } public async handle({ tenant, @@ -120,9 +127,18 @@ export class RecordsWriteHandler implements MethodHandler { } } - const indexes = await recordsWrite.constructRecordsWriteIndexes(isLatestBaseState); + const indexes = await recordsWrite.constructIndexes(isLatestBaseState); await this.messageStore.put(tenant, messageWithOptionalEncodedData, indexes); await this.eventLog.append(tenant, await Message.getCid(message), indexes); + + // we use the same KeyValues as the store indexes for the event stream match fields + // if it is not the initial write, we also include the indexes from the most recent write + const eventIndexes = [ indexes ]; + if (!newMessageIsInitialWrite && newestExistingMessage?.descriptor.method === DwnMethodName.Write) { + const newistExistingRecordsWrite = await RecordsWrite.parse(newestExistingMessage as RecordsQueryReplyEntry); + eventIndexes.push(await newistExistingRecordsWrite.constructIndexes(false)); + } + this.eventStream.emit(tenant, message, ...eventIndexes); } catch (error) { const e = error as any; if (e.code === DwnErrorCode.RecordsWriteMissingEncodedDataInPrevious || diff --git a/src/index.ts b/src/index.ts index ffbc667ff..15e2a2ac4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,14 +2,15 @@ export type { DwnConfig } from './dwn.js'; export type { DidMethodResolver, DwnServiceEndpoint, ServiceEndpoint, DidDocument, DidResolutionResult, DidResolutionMetadata, DidDocumentMetadata, VerificationMethod } from './types/did-types.js'; export type { EventLog } from './types/event-log.js'; -export type { EventsGetMessage, EventsGetReply, EventsQueryMessage, EventsQueryReply } from './types/event-types.js'; +export type { EventStream } from './types/event-stream.js'; +export type { EventsGetMessage, EventsGetReply, EventsQueryMessage, EventsQueryReply, EventsSubscribeDescriptor, EventsSubscribeMessage, EventsSubscribeReply, EventSubscription } from './types/event-types.js'; export type { GenericMessage, GenericMessageReply, MessageSort, Pagination, QueryResultEntry } from './types/message-types.js'; export type { MessagesGetMessage, MessagesGetReply, MessagesGetReplyEntry } from './types/messages-types.js'; export type { Filter, EqualFilter, OneOfFilter, RangeFilter, RangeCriterion, PaginationCursor, QueryOptions } from './types/query-types.js'; export type { PermissionConditions, PermissionScope, PermissionsGrantDescriptor } from './types/permissions-grant-descriptor.js'; export type { PermissionsGrantMessage, PermissionsRequestDescriptor, PermissionsRequestMessage, PermissionsRevokeDescriptor, PermissionsRevokeMessage } from './types/permissions-types.js'; export type { ProtocolsConfigureDescriptor, ProtocolDefinition, ProtocolTypes, ProtocolRuleSet, ProtocolsQueryFilter, ProtocolsConfigureMessage, ProtocolsQueryMessage, ProtocolsQueryReply } from './types/protocols-types.js'; -export type { EncryptionProperty, RecordsDeleteMessage, RecordsQueryMessage, RecordsQueryReply, RecordsQueryReplyEntry, RecordsReadReply, RecordsWriteDescriptor, RecordsWriteMessage } from './types/records-types.js'; +export type { EncryptionProperty, RecordsDeleteMessage, RecordsQueryMessage, RecordsQueryReply, RecordsQueryReplyEntry, RecordsReadReply, RecordsSubscribeDescriptor, RecordsSubscribeMessage, RecordsSubscription, RecordsWriteDescriptor, RecordsWriteMessage } from './types/records-types.js'; export { authenticate } from './core/auth.js'; export { ActiveTenantCheckResult, AllowAllTenantGate, TenantGate } from './core/tenant-gate.js'; export { Cid } from './utils/cid.js'; @@ -46,15 +47,20 @@ export { ProtocolsQuery, ProtocolsQueryOptions } from './interfaces/protocols-qu export { Records } from './utils/records.js'; export { RecordsDelete, RecordsDeleteOptions } from './interfaces/records-delete.js'; export { RecordsRead, RecordsReadOptions } from './interfaces/records-read.js'; +export { RecordsSubscribe, RecordsSubscribeOptions } from './interfaces/records-subscribe.js'; export { Secp256k1 } from './utils/secp256k1.js'; export { Signer } from './types/signer.js'; export { SortDirection } from './types/query-types.js'; export { Time } from './utils/time.js'; -// store interfaces +// store implementations export { DataStoreLevel } from './store/data-store-level.js'; export { EventLogLevel } from './event-log/event-log-level.js'; export { MessageStoreLevel } from './store/message-store-level.js'; +// eventing implementations +export { EventStreamEmitter } from './event-log/event-stream.js'; +export { EventsSubscribe , EventsSubscribeOptions } from './interfaces/events-subscribe.js'; + // test library exports -export { Persona, TestDataGenerator } from '../tests/utils/test-data-generator.js'; +export { Persona, TestDataGenerator } from '../tests/utils/test-data-generator.js'; \ No newline at end of file diff --git a/src/interfaces/events-query.ts b/src/interfaces/events-query.ts index 7b6e2ad5b..5885dce1b 100644 --- a/src/interfaces/events-query.ts +++ b/src/interfaces/events-query.ts @@ -1,6 +1,6 @@ import type { ProtocolsQueryFilter } from '../types/protocols-types.js'; import type { Signer } from '../types/signer.js'; -import type { EventsMessageFilter, EventsQueryDescriptor, EventsQueryFilter, EventsQueryMessage, EventsRecordsFilter } from '../types/event-types.js'; +import type { EventsFilter, EventsMessageFilter, EventsQueryDescriptor, EventsQueryMessage, EventsRecordsFilter } from '../types/event-types.js'; import type { Filter, PaginationCursor } from '../types/query-types.js'; import { AbstractMessage } from '../core/abstract-message.js'; @@ -14,7 +14,7 @@ import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.j export type EventsQueryOptions = { signer: Signer; - filters: EventsQueryFilter[]; + filters: EventsFilter[]; cursor?: PaginationCursor; messageTimestamp?: string; }; @@ -47,9 +47,9 @@ export class EventsQuery extends AbstractMessage{ return new EventsQuery(message); } - private static normalizeFilters(filters: EventsQueryFilter[]): EventsQueryFilter[] { + private static normalizeFilters(filters: EventsFilter[]): EventsFilter[] { - const eventsQueryFilters: EventsQueryFilter[] = []; + const eventsQueryFilters: EventsFilter[] = []; // normalize each filter individually by the type of filter it is. for (const filter of filters) { @@ -73,7 +73,7 @@ export class EventsQuery extends AbstractMessage{ * @param filters An array of EventsFilter * @returns {Filter[]} an array of generic Filter able to be used when querying. */ - public static convertFilters(filters: EventsQueryFilter[]): Filter[] { + public static convertFilters(filters: EventsFilter[]): Filter[] { const eventsQueryFilters: Filter[] = []; @@ -103,11 +103,11 @@ export class EventsQuery extends AbstractMessage{ return filterCopy as Filter; } - private static isMessagesFilter(filter: EventsQueryFilter): filter is EventsMessageFilter { + private static isMessagesFilter(filter: EventsFilter): filter is EventsMessageFilter { return 'method' in filter || 'interface' in filter || 'dateUpdated' in filter || 'author' in filter; } - private static isRecordsFilter(filter: EventsQueryFilter): filter is EventsRecordsFilter { + private static isRecordsFilter(filter: EventsFilter): filter is EventsRecordsFilter { return 'dateCreated' in filter || 'dataFormat' in filter || 'dataSize' in filter || @@ -118,7 +118,7 @@ export class EventsQuery extends AbstractMessage{ 'recipient' in filter; } - private static isProtocolFilter(filter: EventsQueryFilter): filter is ProtocolsQueryFilter { + private static isProtocolFilter(filter: EventsFilter): filter is ProtocolsQueryFilter { return 'protocol' in filter; } } \ No newline at end of file diff --git a/src/interfaces/events-subscribe.ts b/src/interfaces/events-subscribe.ts new file mode 100644 index 000000000..e392e22b3 --- /dev/null +++ b/src/interfaces/events-subscribe.ts @@ -0,0 +1,65 @@ +import type { GenericMessage } from '../types/message-types.js'; +import type { Signer } from '../types/signer.js'; +import type { EventsFilter, EventsSubscribeDescriptor, EventsSubscribeMessage } from '../types/event-types.js'; + +import { AbstractMessage } from '../core/abstract-message.js'; +import { Message } from '../core/message.js'; +import { removeUndefinedProperties } from '../utils/object.js'; +import { Time } from '../utils/time.js'; +import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; + + +export type EventsSubscribeOptions = { + messageTimestamp?: string; + signer?: Signer; + filters?: EventsFilter[] + permissionsGrantId?: string; +}; + +export class EventsSubscribe extends AbstractMessage { + public static async parse(message: EventsSubscribeMessage): Promise { + if (message.authorization !== undefined) { + await Message.validateSignatureStructure(message.authorization.signature, message.descriptor); + } + Time.validateTimestamp(message.descriptor.messageTimestamp); + return new EventsSubscribe(message); + } + + /** + * Creates a SubscriptionRequest message. + * + * @throws {DwnError} when a combination of required SubscriptionRequestOptions are missing + */ + public static async create( + options: EventsSubscribeOptions + ): Promise { + const { permissionsGrantId } = options; + const currentTime = Time.getCurrentTimestamp(); + + const descriptor: EventsSubscribeDescriptor = { + interface : DwnInterfaceName.Events, + method : DwnMethodName.Subscribe, + filters : options.filters ?? [], + messageTimestamp : options.messageTimestamp ?? currentTime, + }; + + removeUndefinedProperties(descriptor); + + // only generate the `authorization` property if signature input is given + let authorization = undefined; + if (options.signer !== undefined) { + authorization = await Message.createAuthorization({ + descriptor, + permissionsGrantId, + signer: options.signer + }); + } + const message: EventsSubscribeMessage = { descriptor, authorization }; + Message.validateJsonSchema(message); + return new EventsSubscribe(message); + } + + public static isEventsSubscribeMessage(message: GenericMessage): message is EventsSubscribeMessage { + return message.descriptor.interface === DwnInterfaceName.Events && message.descriptor.method === DwnMethodName.Subscribe; + } +} diff --git a/src/interfaces/records-delete.ts b/src/interfaces/records-delete.ts index e45c8b7d5..a632d1f72 100644 --- a/src/interfaces/records-delete.ts +++ b/src/interfaces/records-delete.ts @@ -1,4 +1,6 @@ import type { DelegatedGrantMessage } from '../types/delegated-grant-message.js'; +import type { GenericMessage } from '../types/message-types.js'; +import type { KeyValues } from '../types/query-types.js'; import type { MessageStore } from '../types//message-store.js'; import type { Signer } from '../types/signer.js'; import type { RecordsDeleteDescriptor, RecordsDeleteMessage, RecordsWriteMessage } from '../types/records-types.js'; @@ -7,6 +9,7 @@ import { AbstractMessage } from '../core/abstract-message.js'; import { Message } from '../core/message.js'; import { Records } from '../utils/records.js'; import { RecordsGrantAuthorization } from '../core/records-grant-authorization.js'; +import { removeUndefinedProperties } from '../utils/object.js'; import { Time } from '../utils/time.js'; import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; @@ -69,6 +72,35 @@ export class RecordsDelete extends AbstractMessage { /** * Authorizes the delegate who signed this message. + * Indexed properties needed for MessageStore indexing. + */ + public constructIndexes( + initialWrite: RecordsWriteMessage + ): KeyValues { + const message = this.message; + const descriptor = { ...message.descriptor }; + + // we add the immutable properties from the initial RecordsWrite message in order to use them when querying relevant deletes. + const { protocol, protocolPath, recipient, schema, parentId, dataFormat, dateCreated } = initialWrite.descriptor; + + // NOTE: the "trick" not may not be apparent on how a query is able to omit deleted records: + // we intentionally not add index for `isLatestBaseState` at all, this means that upon a successful delete, + // no messages with the record ID will match any query because queries by design filter by `isLatestBaseState = true`, + // `isLatestBaseState` for the initial delete would have been toggled to `false` + const indexes: { [key:string]: string | undefined } = { + // isLatestBaseState : "true", // intentionally showing that this index is omitted + protocol, protocolPath, recipient, schema, parentId, dataFormat, dateCreated, + contextId : initialWrite.contextId, + author : this.author!, + ...descriptor + }; + removeUndefinedProperties(indexes); + + return indexes as KeyValues; + } + + /* + * Authorizes the delegate who signed the message. * @param messageStore Used to check if the grant has been revoked. */ public async authorizeDelegate(recordsWriteToDelete: RecordsWriteMessage, messageStore: MessageStore): Promise { @@ -82,4 +114,8 @@ export class RecordsDelete extends AbstractMessage { messageStore }); } + + public static isRecordsDeleteMessage(message: GenericMessage): message is RecordsDeleteMessage { + return message.descriptor.interface === DwnInterfaceName.Records && message.descriptor.method === DwnMethodName.Delete; + } } diff --git a/src/interfaces/records-subscribe.ts b/src/interfaces/records-subscribe.ts new file mode 100644 index 000000000..dcb8c5953 --- /dev/null +++ b/src/interfaces/records-subscribe.ts @@ -0,0 +1,105 @@ +import type { DelegatedGrantMessage } from '../types/delegated-grant-message.js'; +import type { GenericMessage } from '../types/message-types.js'; +import type { MessageStore } from '../types/message-store.js'; +import type { Signer } from '../types/signer.js'; +import type { RecordsFilter, RecordsSubscribeDescriptor, RecordsSubscribeMessage } from '../types/records-types.js'; + +import { AbstractMessage } from '../core/abstract-message.js'; +import { Message } from '../core/message.js'; +import { Records } from '../utils/records.js'; +import { RecordsGrantAuthorization } from '../core/records-grant-authorization.js'; +import { removeUndefinedProperties } from '../utils/object.js'; +import { Time } from '../utils/time.js'; +import { DwnError, DwnErrorCode } from '../core/dwn-error.js'; +import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; +import { validateProtocolUrlNormalized, validateSchemaUrlNormalized } from '../utils/url.js'; + +export type RecordsSubscribeOptions = { + messageTimestamp?: string; + filter: RecordsFilter; + signer?: Signer; + protocolRole?: string; + + /** + * The delegated grant to sign on behalf of the logical author, which is the grantor (`grantedBy`) of the delegated grant. + */ + delegatedGrant?: DelegatedGrantMessage; +}; + +/** + * A class representing a RecordsSubscribe DWN message. + */ +export class RecordsSubscribe extends AbstractMessage { + + public static async parse(message: RecordsSubscribeMessage): Promise { + let signaturePayload; + if (message.authorization !== undefined) { + signaturePayload = await Message.validateSignatureStructure(message.authorization.signature, message.descriptor); + } + + Records.validateDelegatedGrantReferentialIntegrity(message, signaturePayload); + + if (signaturePayload?.protocolRole !== undefined) { + if (message.descriptor.filter.protocolPath === undefined) { + throw new DwnError( + DwnErrorCode.RecordsSubscribeFilterMissingRequiredProperties, + 'Role-authorized queries must include `protocolPath` in the filter' + ); + } + } + if (message.descriptor.filter.protocol !== undefined) { + validateProtocolUrlNormalized(message.descriptor.filter.protocol); + } + if (message.descriptor.filter.schema !== undefined) { + validateSchemaUrlNormalized(message.descriptor.filter.schema); + } + Time.validateTimestamp(message.descriptor.messageTimestamp); + + return new RecordsSubscribe(message); + } + + public static async create(options: RecordsSubscribeOptions): Promise { + const descriptor: RecordsSubscribeDescriptor = { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Subscribe, + messageTimestamp : options.messageTimestamp ?? Time.getCurrentTimestamp(), + filter : Records.normalizeFilter(options.filter), + }; + + // delete all descriptor properties that are `undefined` else the code will encounter the following IPLD issue when attempting to generate CID: + // Error: `undefined` is not supported by the IPLD Data Model and cannot be encoded + removeUndefinedProperties(descriptor); + + // only generate the `authorization` property if signature input is given + const signer = options.signer; + let authorization; + if (signer) { + authorization = await Message.createAuthorization({ + descriptor, + signer, + protocolRole : options.protocolRole, + delegatedGrant : options.delegatedGrant + }); + } + const message = { descriptor, authorization }; + + Message.validateJsonSchema(message); + + return new RecordsSubscribe(message); + } + + /** + * Authorizes the delegate who signed the message. + * @param messageStore Used to check if the grant has been revoked. + */ + public async authorizeDelegate(messageStore: MessageStore): Promise { + const grantedTo = this.signer!; + const grantedFor = this.author!; + const delegatedGrant = this.message.authorization!.authorDelegatedGrant!; + await RecordsGrantAuthorization.authorizeSubscribe(this.message, grantedTo, grantedFor, delegatedGrant, messageStore); + } + + public static isRecordsSubscribeMessage(message: GenericMessage): message is RecordsSubscribeMessage { + return message.descriptor.interface === DwnInterfaceName.Records && message.descriptor.method === DwnMethodName.Subscribe; + } +} diff --git a/src/interfaces/records-write.ts b/src/interfaces/records-write.ts index c6a28ac63..0178f9199 100644 --- a/src/interfaces/records-write.ts +++ b/src/interfaces/records-write.ts @@ -689,7 +689,7 @@ export class RecordsWrite implements MessageInterface { } - public async constructRecordsWriteIndexes( + public async constructIndexes( isLatestBaseState: boolean ): Promise { const message = this.message; @@ -932,4 +932,8 @@ export class RecordsWrite implements MessageInterface { const attesters = attestationSignatures.map((signature) => Jws.getSignerDid(signature)); return attesters; } + + public static isRecordsWriteMessage(message: GenericMessage): message is RecordsWriteMessage { + return message.descriptor.interface === DwnInterfaceName.Records && message.descriptor.method === DwnMethodName.Write; + } } diff --git a/src/store/storage-controller.ts b/src/store/storage-controller.ts index 14c9932ad..f9a395fa6 100644 --- a/src/store/storage-controller.ts +++ b/src/store/storage-controller.ts @@ -64,7 +64,7 @@ export class StorageController { if (existingMessageIsInitialWrite) { const existingRecordsWrite = await RecordsWrite.parse(message as RecordsWriteMessage); const isLatestBaseState = false; - const indexes = await existingRecordsWrite.constructRecordsWriteIndexes(isLatestBaseState); + const indexes = await existingRecordsWrite.constructIndexes(isLatestBaseState); const writeMessage = message as RecordsQueryReplyEntry; delete writeMessage.encodedData; await messageStore.put(tenant, writeMessage, indexes); diff --git a/src/types/event-stream.ts b/src/types/event-stream.ts new file mode 100644 index 000000000..e101efb71 --- /dev/null +++ b/src/types/event-stream.ts @@ -0,0 +1,21 @@ +import type { GenericMessage } from './message-types.js'; +import type { EventHandler, EventsSubscribeMessage, EventSubscription } from './event-types.js'; +import type { Filter, KeyValues } from './query-types.js'; +import type { RecordsSubscribeMessage, RecordsSubscription } from './records-types.js'; + + +export interface EventStream { + subscribe(tenant: string, message: EventsSubscribeMessage, filters: Filter[]): Promise; + subscribe(tenant: string, message: RecordsSubscribeMessage, filters: Filter[]): Promise; + subscribe(tenant: string, message: GenericMessage, filters: Filter[]): Promise; + emit(tenant: string, message: GenericMessage, ...matchIndexes: KeyValues[]): void; + open(): Promise; + close(): Promise; +} + +export interface EventStreamSubscription { + id: string; + listener: (tenant: string, message: GenericMessage, ...indexes: KeyValues[]) => void; + on: (handler: EventHandler) => { off: () => void }; + close: () => Promise; +} \ No newline at end of file diff --git a/src/types/event-types.ts b/src/types/event-types.ts index a0fdb9feb..c2d1c1a5b 100644 --- a/src/types/event-types.ts +++ b/src/types/event-types.ts @@ -23,10 +23,10 @@ export type EventsRecordsFilter = { dateCreated?: RangeCriterion; }; -export type EventsQueryFilter = EventsMessageFilter | EventsRecordsFilter | ProtocolsQueryFilter; +export type EventsFilter = EventsMessageFilter | EventsRecordsFilter | ProtocolsQueryFilter; export type EventsGetDescriptor = { - interface : DwnInterfaceName.Events; + interface: DwnInterfaceName.Events; method: DwnMethodName.Get; cursor?: PaginationCursor; messageTimestamp: string; @@ -42,11 +42,35 @@ export type EventsGetReply = GenericMessageReply & { cursor?: PaginationCursor; }; +export type EventsSubscribeMessage = { + authorization?: AuthorizationModel; + descriptor: EventsSubscribeDescriptor; +}; + +export type EventHandler = (message: GenericMessage) => void; + +export type EventSubscription = { + id: string; + on: (handler: EventHandler) => { off: () => void }; + close: () => Promise; +}; + +export type EventsSubscribeReply = GenericMessageReply & { + subscription?: EventSubscription; +}; + +export type EventsSubscribeDescriptor = { + interface: DwnInterfaceName.Events; + method: DwnMethodName.Subscribe; + messageTimestamp: string; + filters: EventsFilter[]; +}; + export type EventsQueryDescriptor = { interface: DwnInterfaceName.Events; method: DwnMethodName.Query; messageTimestamp: string; - filters: EventsQueryFilter[]; + filters: EventsFilter[]; cursor?: PaginationCursor; }; diff --git a/src/types/message-types.ts b/src/types/message-types.ts index c6e0066f3..1b0c18722 100644 --- a/src/types/message-types.ts +++ b/src/types/message-types.ts @@ -75,6 +75,14 @@ export type QueryResultEntry = GenericMessage & { encodedData?: string; }; +export type GenericMessageHandler = (message: GenericMessage) => void; + +export type GenericMessageSubscription = { + id: string; + on: (handler: GenericMessageHandler) => { off: () => void }; + close: () => Promise; +}; + /** * Pagination Options for querying messages. * diff --git a/src/types/protocols-types.ts b/src/types/protocols-types.ts index 7ddfccd25..f3e4eabc4 100644 --- a/src/types/protocols-types.ts +++ b/src/types/protocols-types.ts @@ -40,6 +40,7 @@ export enum ProtocolAction { Delete = 'delete', Query = 'query', Read = 'read', + Subscribe = 'subscribe', Update = 'update', Write = 'write' } diff --git a/src/types/records-types.ts b/src/types/records-types.ts index e47d09d0f..1847c85a4 100644 --- a/src/types/records-types.ts +++ b/src/types/records-types.ts @@ -106,6 +106,13 @@ export type RecordsQueryDescriptor = { pagination?: Pagination; }; +export type RecordsSubscribeDescriptor = { + interface: DwnInterfaceName.Records; + method: DwnMethodName.Subscribe; + messageTimestamp: string; + filter: RecordsFilter; +}; + export type RecordsFilter = { /**the logical author of the record */ author?: string; @@ -146,6 +153,14 @@ export type RecordsQueryReply = GenericMessageReply & { cursor?: PaginationCursor; }; +export type RecordsSubscribeMessage = GenericMessage & { + descriptor: RecordsSubscribeDescriptor; +}; + +export type RecordsSubscribeReply = GenericMessageReply & { + subscription?: RecordsSubscription; +}; + export type RecordsReadMessage = { authorization?: AuthorizationModel; descriptor: RecordsReadDescriptor; @@ -178,4 +193,10 @@ export type RecordsDeleteDescriptor = { method: DwnMethodName.Delete; recordId: string; messageTimestamp: string; +}; + +export type RecordsSubscription = { + id: string; + on: (handler:(message: RecordsWriteMessage | RecordsDeleteMessage) => void) => { off: () => void }; + close: () => Promise; }; \ No newline at end of file diff --git a/src/utils/records.ts b/src/utils/records.ts index f998f0d55..266d8124d 100644 --- a/src/utils/records.ts +++ b/src/utils/records.ts @@ -2,7 +2,7 @@ import type { DerivedPrivateJwk } from './hd-key.js'; import type { Filter } from '../types/query-types.js'; import type { GenericSignaturePayload } from '../types/message-types.js'; import type { Readable } from 'readable-stream'; -import type { RecordsDeleteMessage, RecordsFilter, RecordsQueryMessage, RecordsReadMessage, RecordsWriteDescriptor, RecordsWriteMessage } from '../types/records-types.js'; +import type { RecordsDeleteMessage, RecordsFilter, RecordsQueryMessage, RecordsReadMessage, RecordsSubscribeMessage, RecordsWriteDescriptor, RecordsWriteMessage } from '../types/records-types.js'; import { DateSort } from '../types/records-types.js'; import { Encoder } from './encoder.js'; @@ -289,7 +289,7 @@ export class Records { * Usage of this property is purely for performance optimization so we don't have to decode the signature payload again. */ public static validateDelegatedGrantReferentialIntegrity( - message: RecordsReadMessage | RecordsQueryMessage | RecordsWriteMessage | RecordsDeleteMessage, + message: RecordsReadMessage | RecordsQueryMessage | RecordsWriteMessage | RecordsDeleteMessage | RecordsSubscribeMessage, signaturePayload: GenericSignaturePayload | undefined ): void { // `deletedGrantId` in the payload of the message signature and `authorDelegatedGrant` in `authorization` must both exist or be both undefined diff --git a/tests/dwn.spec.ts b/tests/dwn.spec.ts index 797e67fb6..58d285482 100644 --- a/tests/dwn.spec.ts +++ b/tests/dwn.spec.ts @@ -1,3 +1,4 @@ +import type { EventStream } from '../src/types/event-stream.js'; import type { ActiveTenantCheckResult, EventsGetReply, TenantGate } from '../src/index.js'; import type { DataStore, EventLog, MessageStore } from '../src/index.js'; @@ -11,6 +12,7 @@ import { Message } from '../src/core/message.js'; import { stubInterface } from 'ts-sinon'; import { TestDataGenerator } from './utils/test-data-generator.js'; import { TestStores } from './test-stores.js'; +import { DidResolver, EventStreamEmitter } from '../src/index.js'; chai.use(chaiAsPromised); @@ -19,6 +21,7 @@ export function testDwnClass(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; // important to follow the `before` and `after` pattern to initialize and clean the stores in tests @@ -29,7 +32,10 @@ export function testDwnClass(): void { dataStore = stores.dataStore; eventLog = stores.eventLog; - dwn = await Dwn.create({ messageStore, dataStore, eventLog }); + const didResolver = new DidResolver(); + eventStream = new EventStreamEmitter({ messageStore, didResolver }); + + dwn = await Dwn.create({ messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { @@ -125,12 +131,14 @@ export function testDwnClass(): void { const messageStoreStub = stubInterface(); const dataStoreStub = stubInterface(); const eventLogStub = stubInterface(); + const eventStreamStub = stubInterface(); const dwnWithConfig = await Dwn.create({ tenantGate : blockAllTenantGate, messageStore : messageStoreStub, dataStore : dataStoreStub, - eventLog : eventLogStub + eventLog : eventLogStub, + eventStream : eventStreamStub }); const alice = await DidKeyResolver.generate(); diff --git a/tests/event-log/event-log-level.spec.ts b/tests/event-log/event-log-level.spec.ts index 8051f5025..dcdbcef67 100644 --- a/tests/event-log/event-log-level.spec.ts +++ b/tests/event-log/event-log-level.spec.ts @@ -29,7 +29,7 @@ describe('EventLogLevel Tests', () => { it('deletes all index related data', async () => { const { author, message, recordsWrite } = await TestDataGenerator.generateRecordsWrite(); const messageCid = await Message.getCid(message); - const index = await recordsWrite.constructRecordsWriteIndexes(true); + const index = await recordsWrite.constructIndexes(true); await eventLog.append(author.did, messageCid, index); const indexLevelDeleteSpy = sinon.spy(eventLog.index, 'delete'); diff --git a/tests/event-log/event-log.spec.ts b/tests/event-log/event-log.spec.ts index da759cfeb..dcb28a2fa 100644 --- a/tests/event-log/event-log.spec.ts +++ b/tests/event-log/event-log.spec.ts @@ -30,12 +30,12 @@ export function testEventLog(): void { it('separates events by tenant', async () => { const { author, message, recordsWrite } = await TestDataGenerator.generateRecordsWrite(); - const message1Index = await recordsWrite.constructRecordsWriteIndexes(true); + const message1Index = await recordsWrite.constructIndexes(true); const messageCid = await Message.getCid(message); await eventLog.append(author.did, messageCid, message1Index); const { author: author2, message: message2, recordsWrite: recordsWrite2 } = await TestDataGenerator.generateRecordsWrite(); - const message2Index = await recordsWrite2.constructRecordsWriteIndexes(true); + const message2Index = await recordsWrite2.constructIndexes(true); const messageCid2 = await Message.getCid(message2); await eventLog.append(author2.did, messageCid2, message2Index); @@ -53,7 +53,7 @@ export function testEventLog(): void { const { author, message, recordsWrite } = await TestDataGenerator.generateRecordsWrite(); const messageCid = await Message.getCid(message); - const messageIndex = await recordsWrite.constructRecordsWriteIndexes(true); + const messageIndex = await recordsWrite.constructIndexes(true); await eventLog.append(author.did, messageCid, messageIndex); expectedMessages.push(messageCid); @@ -61,7 +61,7 @@ export function testEventLog(): void { for (let i = 0; i < 9; i += 1) { const { message, recordsWrite } = await TestDataGenerator.generateRecordsWrite({ author }); const messageCid = await Message.getCid(message); - const index = await recordsWrite.constructRecordsWriteIndexes(true); + const index = await recordsWrite.constructIndexes(true); await eventLog.append(author.did, messageCid, index); expectedMessages.push(messageCid); @@ -81,14 +81,14 @@ export function testEventLog(): void { const { author, message, recordsWrite } = await TestDataGenerator.generateRecordsWrite(); const messageCid = await Message.getCid(message); - const messageIndex = await recordsWrite.constructRecordsWriteIndexes(true); + const messageIndex = await recordsWrite.constructIndexes(true); await eventLog.append(author.did, messageCid, messageIndex); expectedMessages.push(messageCid); for (let i = 0; i < 9; i += 1) { const { message, recordsWrite } = await TestDataGenerator.generateRecordsWrite({ author }); const messageCid = await Message.getCid(message); - const index = await recordsWrite.constructRecordsWriteIndexes(true); + const index = await recordsWrite.constructIndexes(true); await eventLog.append(author.did, messageCid, index); expectedMessages.push(messageCid); @@ -108,7 +108,8 @@ export function testEventLog(): void { // create an initial record to and, issue a getEvents and grab the cursor const { message, recordsWrite } = await TestDataGenerator.generateRecordsWrite({ author }); const messageCid = await Message.getCid(message); - const index = await recordsWrite.constructRecordsWriteIndexes(true); + const index = await recordsWrite.constructIndexes(true); + await eventLog.append(author.did, messageCid, index); const { events: cursorEvents, cursor } = await eventLog.getEvents(author.did); expect(cursorEvents.length).to.equal(1); @@ -120,7 +121,8 @@ export function testEventLog(): void { for (let i = 0; i < 5; i += 1) { const { message, recordsWrite } = await TestDataGenerator.generateRecordsWrite({ author }); const messageCid = await Message.getCid(message); - const index = await recordsWrite.constructRecordsWriteIndexes(true); + const index = await recordsWrite.constructIndexes(true); + await eventLog.append(author.did, messageCid, index); expectedMessages.push(messageCid); } @@ -138,7 +140,7 @@ export function testEventLog(): void { it('finds and deletes events that whose values match the cids provided', async () => { const { author, message, recordsWrite } = await TestDataGenerator.generateRecordsWrite(); const messageCid = await Message.getCid(message); - const index = await recordsWrite.constructRecordsWriteIndexes(true); + const index = await recordsWrite.constructIndexes(true); await eventLog.append(author.did, messageCid, index); @@ -146,7 +148,7 @@ export function testEventLog(): void { for (let i = 0; i < 9; i += 1) { const { message, recordsWrite } = await TestDataGenerator.generateRecordsWrite({ author }); const messageCid = await Message.getCid(message); - const index = await recordsWrite.constructRecordsWriteIndexes(true); + const index = await recordsWrite.constructIndexes(true); await eventLog.append(author.did, messageCid, index); if (i % 2 === 0) { @@ -164,7 +166,7 @@ export function testEventLog(): void { const cids: string[] = []; const { author, message, recordsWrite } = await TestDataGenerator.generateRecordsWrite(); const messageCid = await Message.getCid(message); - const index = await recordsWrite.constructRecordsWriteIndexes(true); + const index = await recordsWrite.constructIndexes(true); await eventLog.append(author.did, messageCid, index); cids.push(messageCid); @@ -172,7 +174,7 @@ export function testEventLog(): void { for (let i = 0; i < 3; i += 1) { const { message, recordsWrite } = await TestDataGenerator.generateRecordsWrite({ author }); const messageCid = await Message.getCid(message); - const index = await recordsWrite.constructRecordsWriteIndexes(true); + const index = await recordsWrite.constructIndexes(true); await eventLog.append(author.did, messageCid, index); cids.push(messageCid); @@ -192,7 +194,7 @@ export function testEventLog(): void { const { author, message, recordsWrite } = await TestDataGenerator.generateRecordsWrite({ schema: 'schema1' }); const messageCid = await Message.getCid(message); - const indexes = await recordsWrite.constructRecordsWriteIndexes(true); + const indexes = await recordsWrite.constructIndexes(true); await eventLog.append(author.did, messageCid, indexes); expectedMessages.push(messageCid); @@ -200,7 +202,7 @@ export function testEventLog(): void { for (let i = 0; i < 5; i += 1) { const { message, recordsWrite } = await TestDataGenerator.generateRecordsWrite({ author, schema: 'schema1' }); const messageCid = await Message.getCid(message); - const indexes = await recordsWrite.constructRecordsWriteIndexes(true); + const indexes = await recordsWrite.constructIndexes(true); await eventLog.append(author.did, messageCid, indexes); expectedMessages.push(messageCid); @@ -210,13 +212,13 @@ export function testEventLog(): void { // not inserted into expected events. const { message: message2, recordsWrite: recordsWrite2 } = await TestDataGenerator.generateRecordsWrite({ author }); const message2Cid = await Message.getCid(message2); - const message2Indexes = await recordsWrite2.constructRecordsWriteIndexes(true); + const message2Indexes = await recordsWrite2.constructIndexes(true); await eventLog.append(author.did, message2Cid, message2Indexes); for (let i = 0; i < 5; i += 1) { const { message, recordsWrite } = await TestDataGenerator.generateRecordsWrite({ author, schema: 'schema1' }); const messageCid = await Message.getCid(message); - const indexes = await recordsWrite.constructRecordsWriteIndexes(true); + const indexes = await recordsWrite.constructIndexes(true); await eventLog.append(author.did, messageCid, indexes); expectedMessages.push(messageCid); @@ -236,26 +238,26 @@ export function testEventLog(): void { // message 1 schema1 const { message, recordsWrite } = await TestDataGenerator.generateRecordsWrite({ author, schema: 'schema1' }); const messageCid = await Message.getCid(message); - const indexes = await recordsWrite.constructRecordsWriteIndexes(true); + const indexes = await recordsWrite.constructIndexes(true); await eventLog.append(author.did, messageCid, indexes); // message 2 schema1 const { message: message2, recordsWrite: recordsWrite2 } = await TestDataGenerator.generateRecordsWrite({ author, schema: 'schema1' }); const message2Cid = await Message.getCid(message2); - const message2Indexes = await recordsWrite2.constructRecordsWriteIndexes(true); + const message2Indexes = await recordsWrite2.constructIndexes(true); await eventLog.append(author.did, message2Cid, message2Indexes); // message 3 schema1 const { message: message3, recordsWrite: recordsWrite3 } = await TestDataGenerator.generateRecordsWrite({ author, schema: 'schema1' }); const message3Cid = await Message.getCid(message3); - const message3Indexes = await recordsWrite3.constructRecordsWriteIndexes(true); + const message3Indexes = await recordsWrite3.constructIndexes(true); await eventLog.append(author.did, message3Cid, message3Indexes); // insert a record that will not show up in the filtered query. // not inserted into expected events because it's not a part of the schema. const { message: nonSchemaMessage1, recordsWrite: nonSchemaMessage1Write } = await TestDataGenerator.generateRecordsWrite({ author }); const nonSchemaMessage1Cid = await Message.getCid(nonSchemaMessage1); - const nonSchemaMessage1Indexes = await nonSchemaMessage1Write.constructRecordsWriteIndexes(true); + const nonSchemaMessage1Indexes = await nonSchemaMessage1Write.constructIndexes(true); await eventLog.append(author.did, nonSchemaMessage1Cid, nonSchemaMessage1Indexes); // make initial query @@ -268,13 +270,13 @@ export function testEventLog(): void { // add an additional message to schema 1 const { message: message4, recordsWrite: recordsWrite4 } = await TestDataGenerator.generateRecordsWrite({ author, schema: 'schema1' }); const message4Cid = await Message.getCid(message4); - const message4Indexes = await recordsWrite4.constructRecordsWriteIndexes(true); + const message4Indexes = await recordsWrite4.constructIndexes(true); await eventLog.append(author.did, message4Cid, message4Indexes); // insert another non schema record const { message: nonSchemaMessage2, recordsWrite: nonSchemaMessage2Write } = await TestDataGenerator.generateRecordsWrite({ author }); const nonSchemaMessage2Cid = await Message.getCid(nonSchemaMessage2); - const nonSchemaMessage2Indexes = await nonSchemaMessage2Write.constructRecordsWriteIndexes(true); + const nonSchemaMessage2Indexes = await nonSchemaMessage2Write.constructIndexes(true); await eventLog.append(author.did, nonSchemaMessage2Cid, nonSchemaMessage2Indexes); ({ events } = await eventLog.queryEvents(author.did, [{ schema: normalizeSchemaUrl('schema1') }], cursor)); diff --git a/tests/event-log/event-stream.spec.ts b/tests/event-log/event-stream.spec.ts new file mode 100644 index 000000000..ca7141e6c --- /dev/null +++ b/tests/event-log/event-stream.spec.ts @@ -0,0 +1,87 @@ +import type { GenericMessage, MessageStore } from '../../src/index.js'; + +import EventEmitter from 'events'; +import { EventStreamEmitter } from '../../src/event-log/event-stream.js'; +import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { DidKeyResolver, Message } from '../../src/index.js'; +import { DidResolver, MessageStoreLevel } from '../../src/index.js'; + +import chaiAsPromised from 'chai-as-promised'; +import chai, { expect } from 'chai'; + +chai.use(chaiAsPromised); + +describe('Event Stream Tests', () => { + let eventStream: EventStreamEmitter; + let didResolver: DidResolver; + let messageStore: MessageStore; + + before(() => { + didResolver = new DidResolver(); + messageStore = new MessageStoreLevel({ + blockstoreLocation : 'TEST-MESSAGESTORE', + indexLocation : 'TEST-INDEX' + }); + // Create a new instance of EventStream before each test + eventStream = new EventStreamEmitter({ didResolver, messageStore }); + }); + + beforeEach(async () => { + messageStore.clear(); + }); + + after(async () => { + // Clean up after each test by closing and clearing the event stream + await messageStore.close(); + await eventStream.close(); + }); + + xit('test add callback', async () => { + }); + + xit('test bad message', async () => { + }); + + xit('should throw an error when adding events to a closed stream', async () => { + }); + + xit('should handle concurrent event sending', async () => { + }); + + xit('test emitter chaining', async () => { + }); + + it('should remove listeners when unsubscribe method is used', async () => { + const alice = await DidKeyResolver.generate(); + const emitter = new EventEmitter(); + const eventEmitter = new EventStreamEmitter({ emitter, messageStore, didResolver }); + expect(emitter.listenerCount('events_bus')).to.equal(0); + + const { message } = await TestDataGenerator.generateRecordsSubscribe({ author: alice }); + const sub = await eventEmitter.subscribe(alice.did, message, []); + expect(emitter.listenerCount('events_bus')).to.equal(1); + + await sub.close(); + expect(emitter.listenerCount('events_bus')).to.equal(0); + }); + + it('should remove listeners when off method is used', async () => { + const alice = await DidKeyResolver.generate(); + const emitter = new EventEmitter(); + const eventEmitter = new EventStreamEmitter({ emitter, messageStore, didResolver }); + const { message } = await TestDataGenerator.generateRecordsSubscribe(); + const sub = await eventEmitter.subscribe(alice.did, message, []); + const messageCid = await Message.getCid(message); + expect(emitter.listenerCount(`${alice.did}_${messageCid}`)).to.equal(0); + const handler = (_:GenericMessage):void => {}; + const on1 = sub.on(handler); + const on2 = sub.on(handler); + expect(emitter.listenerCount(`${alice.did}_${messageCid}`)).to.equal(2); + + on1.off(); + expect(emitter.listenerCount(`${alice.did}_${messageCid}`)).to.equal(1); + on2.off(); + expect(emitter.listenerCount(`${alice.did}_${messageCid}`)).to.equal(0); + await sub.close(); + }); +}); diff --git a/tests/handlers/events-get.spec.ts b/tests/handlers/events-get.spec.ts index 57ccb7744..64034c896 100644 --- a/tests/handlers/events-get.spec.ts +++ b/tests/handlers/events-get.spec.ts @@ -1,3 +1,4 @@ +import type { EventStream } from '../../src/types/event-stream.js'; import type { DataStore, EventLog, @@ -11,7 +12,8 @@ import { TestDataGenerator } from '../utils/test-data-generator.js'; import { DidKeyResolver, DidResolver, - Dwn + Dwn, + EventStreamEmitter } from '../../src/index.js'; import { Message } from '../../src/core/message.js'; @@ -23,6 +25,7 @@ export function testEventsGetHandler(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; // important to follow the `before` and `after` pattern to initialize and clean the stores in tests @@ -34,8 +37,9 @@ export function testEventsGetHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; + eventStream = new EventStreamEmitter({ messageStore, didResolver }); - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { diff --git a/tests/handlers/events-query.spec.ts b/tests/handlers/events-query.spec.ts index a8a9d5ff3..85500595a 100644 --- a/tests/handlers/events-query.spec.ts +++ b/tests/handlers/events-query.spec.ts @@ -1,6 +1,7 @@ import type { DataStore, EventLog, + EventStream, MessageStore } from '../../src/index.js'; @@ -12,6 +13,7 @@ import { DidKeyResolver, DidResolver, Dwn, + EventStreamEmitter, } from '../../src/index.js'; @@ -21,6 +23,7 @@ export function testEventsQueryHandler(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; // important to follow the `before` and `after` pattern to initialize and clean the stores in tests @@ -32,8 +35,9 @@ export function testEventsQueryHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; + eventStream = new EventStreamEmitter({ messageStore, didResolver }); - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { diff --git a/tests/handlers/events-subscribe.spec.ts b/tests/handlers/events-subscribe.spec.ts new file mode 100644 index 000000000..72f518e4d --- /dev/null +++ b/tests/handlers/events-subscribe.spec.ts @@ -0,0 +1,127 @@ +import type { EventStream } from '../../src/types/event-stream.js'; +import type { DataStore, EventLog, GenericMessage, MessageStore } from '../../src/index.js'; + +import { DidKeyResolver } from '../../src/did/did-key-resolver.js'; +import { DidResolver } from '../../src/did/did-resolver.js'; +import { Dwn } from '../../src/dwn.js'; +import { EventsSubscribe } from '../../src/interfaces/events-subscribe.js'; +import { EventStreamEmitter } from '../../src/event-log/event-stream.js'; +import { Jws } from '../../src/utils/jws.js'; +import { Message } from '../../src/core/message.js'; +import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestStores } from '../test-stores.js'; + +import sinon from 'sinon'; +import chai, { expect } from 'chai'; + +import chaiAsPromised from 'chai-as-promised'; +chai.use(chaiAsPromised); + +export function testEventsSubscribeHandler(): void { + describe('EventsSubscribe.handle()', () => { + + let didResolver: DidResolver; + let messageStore: MessageStore; + let dataStore: DataStore; + let eventLog: EventLog; + let eventStream: EventStream; + let dwn: Dwn; + + // important to follow the `before` and `after` pattern to initialize and clean the stores in tests + // so that different test suites can reuse the same backend store for testing + before(async () => { + didResolver = new DidResolver([new DidKeyResolver()]); + + const stores = TestStores.get(); + messageStore = stores.messageStore; + dataStore = stores.dataStore; + eventLog = stores.eventLog; + eventStream = new EventStreamEmitter({ messageStore, didResolver }); + + dwn = await Dwn.create({ + didResolver, + messageStore, + dataStore, + eventLog, + eventStream, + }); + + }); + + beforeEach(async () => { + sinon.restore(); // wipe all previous stubs/spies/mocks/fakes + + // clean up before each test rather than after so that a test does not depend on other tests to do the clean up + await messageStore.clear(); + await dataStore.clear(); + await eventLog.clear(); + }); + + after(async () => { + await dwn.close(); + }); + + it('should allow tenant to subscribe their own event stream', async () => { + const alice = await DidKeyResolver.generate(); + + // testing Subscription Request + const subscriptionRequest = await EventsSubscribe.create({ + signer: Jws.createSigner(alice), + }); + + const subscriptionReply = await dwn.processMessage(alice.did, subscriptionRequest.message); + expect(subscriptionReply.status.code).to.equal(200); + expect(subscriptionReply.subscription).to.not.be.undefined; + + // set up a promise to read later that captures the emitted messageCid + const messageSubscriptionPromise: Promise = new Promise((resolve) => { + const process = async (message: GenericMessage):Promise => { + const messageCid = await Message.getCid(message); + resolve(messageCid); + }; + subscriptionReply.subscription!.on(process); + }); + + const messageWrite = await TestDataGenerator.generateRecordsWrite({ author: alice }); + const writeReply = await dwn.processMessage(alice.did, messageWrite.message, messageWrite.dataStream); + expect(writeReply.status.code).to.equal(202); + const messageCid = await Message.getCid(messageWrite.message); + + // control: ensure that the event exists + const events = await eventLog.getEvents(alice.did); + expect(events.length).to.equal(1); + expect(events[0]).to.equal(messageCid); + + // await the event + await expect(messageSubscriptionPromise).to.eventually.equal(messageCid); + }); + + it('should not allow non-tenant to subscribe to an event stream they are not authorized for', async () => { + const alice = await DidKeyResolver.generate(); + const bob = await DidKeyResolver.generate(); + + // test anonymous request + const anonymousSubscription = await EventsSubscribe.create({}); + expect(anonymousSubscription.message.authorization).to.be.undefined; + + const anonymousReply = await dwn.processMessage(alice.did, anonymousSubscription.message); + expect(anonymousReply.status.code).to.equal(401); + expect(anonymousReply.subscription).to.be.undefined; + + // testing Subscription Request + const subscriptionRequest = await EventsSubscribe.create({ + signer: Jws.createSigner(bob), + }); + + const subscriptionReply = await dwn.processMessage(alice.did, subscriptionRequest.message); + expect(subscriptionReply.status.code).to.equal(401); + expect(subscriptionReply.subscription).to.be.undefined; + }); + + xit('should allow a non-tenant to subscribe to an event stream they are authorized for'); + + xit('should not allow to subscribe after a grant as been revoked'); + + xit('should not continue streaming messages after grant has been revoked'); + }); +} \ No newline at end of file diff --git a/tests/handlers/messages-get.spec.ts b/tests/handlers/messages-get.spec.ts index 4f87b1e6d..eedbec7c1 100644 --- a/tests/handlers/messages-get.spec.ts +++ b/tests/handlers/messages-get.spec.ts @@ -1,3 +1,4 @@ +import type { EventStream } from '../../src/types/event-stream.js'; import type { DataStore, EventLog, @@ -11,7 +12,7 @@ import { MessagesGetHandler } from '../../src/handlers/messages-get.js'; import { stubInterface } from 'ts-sinon'; import { TestDataGenerator } from '../utils/test-data-generator.js'; import { TestStores } from '../test-stores.js'; -import { DidKeyResolver, DidResolver, Dwn, DwnConstant } from '../../src/index.js'; +import { DidKeyResolver, DidResolver, Dwn, DwnConstant, EventStreamEmitter } from '../../src/index.js'; import sinon from 'sinon'; @@ -22,6 +23,7 @@ export function testMessagesGetHandler(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; // important to follow the `before` and `after` pattern to initialize and clean the stores in tests // so that different test suites can reuse the same backend store for testing @@ -32,8 +34,9 @@ export function testMessagesGetHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; + eventStream = new EventStreamEmitter({ messageStore, didResolver }); - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { diff --git a/tests/handlers/permissions-grant.spec.ts b/tests/handlers/permissions-grant.spec.ts index 90d148f2d..ed03fe085 100644 --- a/tests/handlers/permissions-grant.spec.ts +++ b/tests/handlers/permissions-grant.spec.ts @@ -1,3 +1,4 @@ +import type { EventStream } from '../../src/types/event-stream.js'; import type { DataStore, EventLog, @@ -11,7 +12,6 @@ import { DidResolver } from '../../src/did/did-resolver.js'; import { Dwn } from '../../src/dwn.js'; import { DwnErrorCode } from '../../src/core/dwn-error.js'; import { expect } from 'chai'; -import { Jws } from '../../src/index.js'; import { Message } from '../../src/core/message.js'; import { PermissionsGrant } from '../../src/interfaces/permissions-grant.js'; import { PermissionsGrantHandler } from '../../src/handlers/permissions-grant.js'; @@ -19,6 +19,7 @@ import { TestDataGenerator } from '../utils/test-data-generator.js'; import { TestStores } from '../test-stores.js'; import { Time } from '../../src/utils/time.js'; import { DwnInterfaceName, DwnMethodName } from '../../src/enums/dwn-interface-method.js'; +import { EventStreamEmitter, Jws } from '../../src/index.js'; export function testPermissionsGrantHandler(): void { describe('PermissionsGrantHandler.handle()', () => { @@ -26,6 +27,7 @@ export function testPermissionsGrantHandler(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; describe('functional tests', () => { @@ -39,8 +41,9 @@ export function testPermissionsGrantHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; + eventStream = new EventStreamEmitter({ messageStore, didResolver }); - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { @@ -134,7 +137,7 @@ export function testPermissionsGrantHandler(): void { const alice = await DidKeyResolver.generate(); const { message } = await TestDataGenerator.generatePermissionsGrant(); - const permissionsRequestHandler = new PermissionsGrantHandler(didResolver, messageStore, eventLog); + const permissionsRequestHandler = new PermissionsGrantHandler(didResolver, messageStore, eventLog, eventStream); // stub the `parse()` function to throw an error sinon.stub(PermissionsGrant, 'parse').throws('anyError'); diff --git a/tests/handlers/permissions-request.spec.ts b/tests/handlers/permissions-request.spec.ts index eeb68cb5c..ab08812c3 100644 --- a/tests/handlers/permissions-request.spec.ts +++ b/tests/handlers/permissions-request.spec.ts @@ -1,3 +1,4 @@ +import type { EventStream } from '../../src/types/event-stream.js'; import type { DataStore, EventLog, @@ -15,7 +16,7 @@ import { PermissionsRequest } from '../../src/interfaces/permissions-request.js' import { PermissionsRequestHandler } from '../../src/handlers/permissions-request.js'; import { TestDataGenerator } from '../utils/test-data-generator.js'; import { TestStores } from '../test-stores.js'; -import { DwnInterfaceName, DwnMethodName } from '../../src/index.js'; +import { DwnInterfaceName, DwnMethodName, EventStreamEmitter } from '../../src/index.js'; export function testPermissionsRequestHandler(): void { describe('PermissionsRequestHandler.handle()', () => { @@ -23,6 +24,7 @@ export function testPermissionsRequestHandler(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; describe('functional tests', () => { @@ -36,8 +38,9 @@ export function testPermissionsRequestHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; + eventStream = new EventStreamEmitter({ messageStore, didResolver }); - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { @@ -94,7 +97,7 @@ export function testPermissionsRequestHandler(): void { const alice = await DidKeyResolver.generate(); const { message } = await TestDataGenerator.generatePermissionsRequest(); - const permissionsRequestHandler = new PermissionsRequestHandler(didResolver, messageStore, eventLog); + const permissionsRequestHandler = new PermissionsRequestHandler(didResolver, messageStore, eventLog, eventStream); // stub the `parse()` function to throw an error sinon.stub(PermissionsRequest, 'parse').throws('anyError'); diff --git a/tests/handlers/permissions-revoke.spec.ts b/tests/handlers/permissions-revoke.spec.ts index 8643395b9..94c2cb6aa 100644 --- a/tests/handlers/permissions-revoke.spec.ts +++ b/tests/handlers/permissions-revoke.spec.ts @@ -1,12 +1,14 @@ import { expect } from 'chai'; import sinon from 'sinon'; + import { DataStoreLevel } from '../../src/store/data-store-level.js'; import { DidKeyResolver } from '../../src/did/did-key-resolver.js'; import { DidResolver } from '../../src/did/did-resolver.js'; import { Dwn } from '../../src/dwn.js'; import { DwnErrorCode } from '../../src/core/dwn-error.js'; import { EventLogLevel } from '../../src/event-log/event-log-level.js'; +import { EventStreamEmitter } from '../../src/event-log/event-stream.js'; import { Message } from '../../src/core/message.js'; import { MessageStoreLevel } from '../../src/store/message-store-level.js'; import { PermissionsRevoke } from '../../src/interfaces/permissions-revoke.js'; @@ -18,6 +20,7 @@ describe('PermissionsRevokeHandler.handle()', () => { let messageStore: MessageStoreLevel; let dataStore: DataStoreLevel; let eventLog: EventLogLevel; + let eventStream: EventStreamEmitter; let dwn: Dwn; describe('functional tests', () => { @@ -39,7 +42,9 @@ describe('PermissionsRevokeHandler.handle()', () => { location: 'TEST-EVENTLOG' }); - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + eventStream = new EventStreamEmitter({ didResolver, messageStore }); + + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { diff --git a/tests/handlers/protocols-configure.spec.ts b/tests/handlers/protocols-configure.spec.ts index 2d0c2a870..0c009e81a 100644 --- a/tests/handlers/protocols-configure.spec.ts +++ b/tests/handlers/protocols-configure.spec.ts @@ -1,3 +1,5 @@ + +import type { EventStream } from '../../src/types/event-stream.js'; import type { GenerateProtocolsConfigureOutput } from '../utils/test-data-generator.js'; import type { DataStore, @@ -21,7 +23,7 @@ import { TestStores } from '../test-stores.js'; import { TestStubGenerator } from '../utils/test-stub-generator.js'; import { Time } from '../../src/utils/time.js'; -import { DidResolver, Dwn, DwnErrorCode, Encoder, Jws } from '../../src/index.js'; +import { DidResolver, Dwn, DwnErrorCode, Encoder, EventStreamEmitter, Jws } from '../../src/index.js'; chai.use(chaiAsPromised); @@ -31,6 +33,7 @@ export function testProtocolsConfigureHandler(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; describe('functional tests', () => { @@ -44,8 +47,9 @@ export function testProtocolsConfigureHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; + eventStream = new EventStreamEmitter({ messageStore, didResolver }); - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { diff --git a/tests/handlers/protocols-query.spec.ts b/tests/handlers/protocols-query.spec.ts index d46aafde6..c31e3affd 100644 --- a/tests/handlers/protocols-query.spec.ts +++ b/tests/handlers/protocols-query.spec.ts @@ -1,3 +1,4 @@ +import type { EventStream } from '../../src/types/event-stream.js'; import type { DataStore, EventLog, @@ -16,7 +17,7 @@ import { TestDataGenerator } from '../utils/test-data-generator.js'; import { TestStores } from '../test-stores.js'; import { TestStubGenerator } from '../utils/test-stub-generator.js'; import { Time } from '../../src/utils/time.js'; -import { DidResolver, Dwn, DwnErrorCode, DwnInterfaceName, DwnMethodName, Encoder, Jws, ProtocolsQuery } from '../../src/index.js'; +import { DidResolver, Dwn, DwnErrorCode, DwnInterfaceName, DwnMethodName, Encoder, EventStreamEmitter, Jws, ProtocolsQuery } from '../../src/index.js'; chai.use(chaiAsPromised); @@ -26,6 +27,7 @@ export function testProtocolsQueryHandler(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; describe('functional tests', () => { @@ -39,8 +41,9 @@ export function testProtocolsQueryHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; + eventStream = new EventStreamEmitter({ messageStore, didResolver }); - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { diff --git a/tests/handlers/records-delete.spec.ts b/tests/handlers/records-delete.spec.ts index 70f31f8bd..3b9a97d00 100644 --- a/tests/handlers/records-delete.spec.ts +++ b/tests/handlers/records-delete.spec.ts @@ -1,3 +1,4 @@ +import type { EventStream } from '../../src/types/event-stream.js'; import type { DataStore, EventLog, @@ -17,7 +18,6 @@ import threadRoleProtocolDefinition from '../vectors/protocol-definitions/thread import { ArrayUtility } from '../../src/utils/array.js'; import { DidKeyResolver } from '../../src/did/did-key-resolver.js'; -import { DwnErrorCode } from '../../src/index.js'; import { DwnMethodName } from '../../src/enums/dwn-interface-method.js'; import { Message } from '../../src/core/message.js'; import { normalizeSchemaUrl } from '../../src/utils/url.js'; @@ -28,6 +28,7 @@ import { TestStores } from '../test-stores.js'; import { TestStubGenerator } from '../utils/test-stub-generator.js'; import { Time } from '../../src/utils/time.js'; import { DataStream, DidResolver, Dwn, Encoder, Jws, RecordsDelete, RecordsRead, RecordsWrite } from '../../src/index.js'; +import { DwnErrorCode, EventStreamEmitter } from '../../src/index.js'; chai.use(chaiAsPromised); @@ -37,6 +38,7 @@ export function testRecordsDeleteHandler(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; describe('functional tests', () => { @@ -50,8 +52,9 @@ export function testRecordsDeleteHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; + eventStream = new EventStreamEmitter({ messageStore, didResolver }); - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { @@ -760,7 +763,7 @@ export function testRecordsDeleteHandler(): void { const messageStore = stubInterface(); const dataStore = stubInterface(); - const recordsDeleteHandler = new RecordsDeleteHandler(didResolver, messageStore, dataStore, eventLog); + const recordsDeleteHandler = new RecordsDeleteHandler(didResolver, messageStore, dataStore, eventLog, eventStream); const reply = await recordsDeleteHandler.handle({ tenant, message }); expect(reply.status.code).to.equal(401); }); @@ -773,7 +776,7 @@ export function testRecordsDeleteHandler(): void { const messageStore = stubInterface(); const dataStore = stubInterface(); - const recordsDeleteHandler = new RecordsDeleteHandler(didResolver, messageStore, dataStore, eventLog); + const recordsDeleteHandler = new RecordsDeleteHandler(didResolver, messageStore, dataStore, eventLog, eventStream); // stub the `parse()` function to throw an error sinon.stub(RecordsDelete, 'parse').throws('anyError'); diff --git a/tests/handlers/records-query.spec.ts b/tests/handlers/records-query.spec.ts index d45757f2a..8a7ec9657 100644 --- a/tests/handlers/records-query.spec.ts +++ b/tests/handlers/records-query.spec.ts @@ -1,3 +1,4 @@ +import type { EventStream } from '../../src/types/event-stream.js'; import type { DataStore, EventLog, MessageStore } from '../../src/index.js'; import type { GenericMessage, RecordsWriteMessage } from '../../src/index.js'; import type { RecordsQueryReply, RecordsQueryReplyEntry, RecordsWriteDescriptor } from '../../src/types/records-types.js'; @@ -14,7 +15,6 @@ import { ArrayUtility } from '../../src/utils/array.js'; import { DateSort } from '../../src/types/records-types.js'; import { DidKeyResolver } from '../../src/did/did-key-resolver.js'; import { DwnConstant } from '../../src/core/dwn-constant.js'; -import { DwnErrorCode } from '../../src/index.js'; import { Encoder } from '../../src/utils/encoder.js'; import { Jws } from '../../src/utils/jws.js'; import { Message } from '../../src/core/message.js'; @@ -26,6 +26,7 @@ import { TestDataGenerator } from '../utils/test-data-generator.js'; import { TestStores } from '../test-stores.js'; import { TestStubGenerator } from '../utils/test-stub-generator.js'; import { DidResolver, Dwn, RecordsWrite, Time } from '../../src/index.js'; +import { DwnErrorCode, EventStreamEmitter } from '../../src/index.js'; chai.use(chaiAsPromised); @@ -36,6 +37,7 @@ export function testRecordsQueryHandler(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; // important to follow the `before` and `after` pattern to initialize and clean the stores in tests @@ -47,8 +49,9 @@ export function testRecordsQueryHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; + eventStream = new EventStreamEmitter({ messageStore, didResolver }); - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { @@ -1487,29 +1490,29 @@ export function testRecordsQueryHandler(): void { ); // directly inserting data to datastore so that we don't have to setup to grant Bob permission to write to Alice's DWN - const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog); + const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog, eventStream); - const additionalIndexes1 = await record1Data.recordsWrite.constructRecordsWriteIndexes(true); + const additionalIndexes1 = await record1Data.recordsWrite.constructIndexes(true); record1Data.message = await recordsWriteHandler.cloneAndAddEncodedData(record1Data.message, record1Data.dataBytes!); await messageStore.put(alice.did, record1Data.message, additionalIndexes1); await eventLog.append(alice.did, await Message.getCid(record1Data.message), additionalIndexes1); - const additionalIndexes2 = await record2Data.recordsWrite.constructRecordsWriteIndexes(true); + const additionalIndexes2 = await record2Data.recordsWrite.constructIndexes(true); record2Data.message = await recordsWriteHandler.cloneAndAddEncodedData(record2Data.message,record2Data.dataBytes!); await messageStore.put(alice.did, record2Data.message, additionalIndexes2); await eventLog.append(alice.did, await Message.getCid(record2Data.message), additionalIndexes1); - const additionalIndexes3 = await record3Data.recordsWrite.constructRecordsWriteIndexes(true); + const additionalIndexes3 = await record3Data.recordsWrite.constructIndexes(true); record3Data.message = await recordsWriteHandler.cloneAndAddEncodedData(record3Data.message, record3Data.dataBytes!); await messageStore.put(alice.did, record3Data.message, additionalIndexes3); await eventLog.append(alice.did, await Message.getCid(record3Data.message), additionalIndexes1); - const additionalIndexes4 = await record4Data.recordsWrite.constructRecordsWriteIndexes(true); + const additionalIndexes4 = await record4Data.recordsWrite.constructIndexes(true); record4Data.message = await recordsWriteHandler.cloneAndAddEncodedData(record4Data.message, record4Data.dataBytes!); await messageStore.put(alice.did, record4Data.message, additionalIndexes4); await eventLog.append(alice.did, await Message.getCid(record4Data.message), additionalIndexes1); - const additionalIndexes5 = await record5Data.recordsWrite.constructRecordsWriteIndexes(true); + const additionalIndexes5 = await record5Data.recordsWrite.constructIndexes(true); record5Data.message = await recordsWriteHandler.cloneAndAddEncodedData(record5Data.message, record5Data.dataBytes!); await messageStore.put(alice.did, record5Data.message, additionalIndexes5); await eventLog.append(alice.did, await Message.getCid(record5Data.message), additionalIndexes1); @@ -1612,11 +1615,11 @@ export function testRecordsQueryHandler(): void { ...aliceMessagesForBobPromise, ]; - const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog); + const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog, eventStream); const messages: GenericMessage[] = []; for await (const { recordsWrite, message, dataBytes } of messagePromises) { - const indexes = await recordsWrite.constructRecordsWriteIndexes(true); + const indexes = await recordsWrite.constructIndexes(true); const processedMessage = await recordsWriteHandler.cloneAndAddEncodedData(message, dataBytes!); await messageStore.put(alice.did, processedMessage, indexes); await eventLog.append(alice.did, await Message.getCid(processedMessage), indexes); diff --git a/tests/handlers/records-read.spec.ts b/tests/handlers/records-read.spec.ts index c14db8c7a..94edc8889 100644 --- a/tests/handlers/records-read.spec.ts +++ b/tests/handlers/records-read.spec.ts @@ -1,8 +1,9 @@ import type { DerivedPrivateJwk } from '../../src/utils/hd-key.js'; import type { EncryptionInput } from '../../src/interfaces/records-write.js'; +import type { EventStream } from '../../src/types/event-stream.js'; import type { DataStore, EventLog, MessageStore, ProtocolDefinition, ProtocolsConfigureMessage } from '../../src/index.js'; -import { DwnConstant, Message } from '../../src/index.js'; +import { DwnConstant, EventStreamEmitter, Message } from '../../src/index.js'; import { DwnInterfaceName, DwnMethodName } from '../../src/index.js'; import chaiAsPromised from 'chai-as-promised'; @@ -41,6 +42,7 @@ export function testRecordsReadHandler(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; describe('functional tests', () => { @@ -54,8 +56,9 @@ export function testRecordsReadHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; + eventStream = new EventStreamEmitter({ messageStore, didResolver }); - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { diff --git a/tests/handlers/records-subscribe.spec.ts b/tests/handlers/records-subscribe.spec.ts new file mode 100644 index 000000000..9e89ae2b9 --- /dev/null +++ b/tests/handlers/records-subscribe.spec.ts @@ -0,0 +1,720 @@ +import type { EventStream } from '../../src/types/event-stream.js'; +import type { GenericMessage } from '../../src/types/message-types.js'; +import type { DataStore, EventLog, MessageStore, RecordsWriteMessage } from '../../src/index.js'; +import type { RecordsDeleteMessage, RecordsFilter } from '../../src/types/records-types.js'; + +import chaiAsPromised from 'chai-as-promised'; +import sinon from 'sinon'; +import chai, { expect } from 'chai'; + +import friendRoleProtocolDefinition from '../vectors/protocol-definitions/friend-role.json' assert { type: 'json' }; +import threadRoleProtocolDefinition from '../vectors/protocol-definitions/thread-role.json' assert { type: 'json' }; + +import { DidKeyResolver } from '../../src/did/did-key-resolver.js'; +import { Jws } from '../../src/utils/jws.js'; +import { Message } from '../../src/core/message.js'; +import { RecordsSubscribe } from '../../src/interfaces/records-subscribe.js'; +import { RecordsSubscribeHandler } from '../../src/handlers/records-subscribe.js'; +import { stubInterface } from 'ts-sinon'; +import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestStores } from '../test-stores.js'; +import { TestStubGenerator } from '../utils/test-stub-generator.js'; +import { DidResolver, Dwn, EventStreamEmitter, Time } from '../../src/index.js'; +import { DwnErrorCode, DwnInterfaceName, DwnMethodName } from '../../src/index.js'; + +chai.use(chaiAsPromised); + +export function testRecordsSubscribeHandler(): void { + describe('RecordsSubscribeHandler.handle()', () => { + describe('functional tests', () => { + let didResolver: DidResolver; + let messageStore: MessageStore; + let dataStore: DataStore; + let eventLog: EventLog; + let eventStream: EventStream; + let dwn: Dwn; + + // important to follow the `before` and `after` pattern to initialize and clean the stores in tests + // so that different test suites can reuse the same backend store for testing + before(async () => { + didResolver = new DidResolver([new DidKeyResolver()]); + + const stores = TestStores.get(); + messageStore = stores.messageStore; + dataStore = stores.dataStore; + eventLog = stores.eventLog; + eventStream = new EventStreamEmitter({ messageStore, didResolver }); + + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); + }); + + beforeEach(async () => { + sinon.restore(); // wipe all previous stubs/spies/mocks/fakes + + // clean up before each test rather than after so that a test does not depend on other tests to do the clean up + await messageStore.clear(); + await dataStore.clear(); + await eventLog.clear(); + }); + + after(async () => { + await dwn.close(); + }); + + it('should return a subscription object', async () => { + const alice = await DidKeyResolver.generate(); + + // subscribe for non-normalized protocol + const recordsSubscribe = await TestDataGenerator.generateRecordsSubscribe({ + author : alice, + filter : { schema: 'some-schema' }, + }); + + // Send records subscribe message + const reply = await dwn.processMessage(alice.did, recordsSubscribe.message); + expect(reply.status.code).to.equal(200); + expect(reply.subscription).to.exist; + }); + + it('should return 400 if protocol is not normalized', async () => { + const alice = await DidKeyResolver.generate(); + + // subscribe for non-normalized protocol + const recordsSubscribe = await TestDataGenerator.generateRecordsSubscribe({ + author : alice, + filter : { protocol: 'example.com/' }, + }); + + // overwrite protocol because #create auto-normalizes protocol + recordsSubscribe.message.descriptor.filter.protocol = 'example.com/'; + + // Re-create auth because we altered the descriptor after signing + recordsSubscribe.message.authorization = await Message.createAuthorization({ + descriptor : recordsSubscribe.message.descriptor, + signer : Jws.createSigner(alice) + }); + + // Send records subscribe message + const reply = await dwn.processMessage(alice.did, recordsSubscribe.message); + expect(reply.status.code).to.equal(400); + expect(reply.status.detail).to.contain(DwnErrorCode.UrlProtocolNotNormalized); + }); + + it('should return 400 if schema is not normalized', async () => { + const alice = await DidKeyResolver.generate(); + + // subscribe for non-normalized schema + const recordsSubscribe = await TestDataGenerator.generateRecordsSubscribe({ + author : alice, + filter : { schema: 'example.com/' }, + }); + + // overwrite schema because #create auto-normalizes schema + recordsSubscribe.message.descriptor.filter.schema = 'example.com/'; + + // Re-create auth because we altered the descriptor after signing + recordsSubscribe.message.authorization = await Message.createAuthorization({ + descriptor : recordsSubscribe.message.descriptor, + signer : Jws.createSigner(alice) + }); + + // Send records subscribe message + const reply = await dwn.processMessage(alice.did, recordsSubscribe.message); + expect(reply.status.code).to.equal(400); + expect(reply.status.detail).to.contain(DwnErrorCode.UrlSchemaNotNormalized); + }); + + it('should return 400 if published is set to false and a datePublished range is provided', async () => { + const fromDatePublished = Time.getCurrentTimestamp(); + const alice = await DidKeyResolver.generate(); + // set to true so create does not fail + const recordSubscribe = await TestDataGenerator.generateRecordsSubscribe({ + author : alice, + filter : { datePublished: { from: fromDatePublished }, published: true } + }); + + // set to false + recordSubscribe.message.descriptor.filter.published = false; + const subscribeResponse = await dwn.processMessage(alice.did, recordSubscribe.message); + expect(subscribeResponse.status.code).to.equal(400); + expect(subscribeResponse.status.detail).to.contain('descriptor/filter/published: must be equal to one of the allowed values'); + }); + + it('should return 401 for anonymous subscriptions that filter explicitly for unpublished records', async () => { + const alice = await DidKeyResolver.generate(); + + // create an unpublished record + const draftWrite = await TestDataGenerator.generateRecordsWrite({ author: alice, schema: 'post' }); + const draftWriteReply = await dwn.processMessage(alice.did, draftWrite.message, draftWrite.dataStream); + expect(draftWriteReply.status.code).to.equal(202); + + // validate that alice can subscribe + const unpublishedPostSubscribe = await TestDataGenerator.generateRecordsSubscribe({ author: alice, filter: { schema: 'post', published: false } }); + const unpublishedPostReply = await dwn.processMessage(alice.did, unpublishedPostSubscribe.message); + expect(unpublishedPostReply.status.code).to.equal(200); + expect(unpublishedPostReply.subscription).to.exist; + + // anonymous subscribe for unpublished records + const unpublishedAnonymous = await RecordsSubscribe.create({ filter: { schema: 'post', published: false } }); + const anonymousPostReply = await dwn.processMessage(alice.did, unpublishedAnonymous.message); + expect(anonymousPostReply.status.code).to.equal(401); + expect(anonymousPostReply.status.detail).contains('Missing JWS'); + expect(anonymousPostReply.subscription).to.not.exist; + }); + + it('should return 401 if signature check fails', async () => { + const { author, message } = await TestDataGenerator.generateRecordsSubscribe(); + const tenant = author!.did; + + // setting up a stub did resolver & message store + // intentionally not supplying the public key so a different public key is generated to simulate invalid signature + const mismatchingPersona = await TestDataGenerator.generatePersona({ did: author!.did, keyId: author!.keyId }); + const didResolver = TestStubGenerator.createDidResolverStub(mismatchingPersona); + const messageStore = stubInterface(); + const eventStream = stubInterface(); + + const recordsSubscribeHandler = new RecordsSubscribeHandler(didResolver, messageStore, eventStream); + const reply = await recordsSubscribeHandler.handle({ tenant, message }); + + expect(reply.status.code).to.equal(401); + }); + + it('should return 400 if fail parsing the message', async () => { + const { author, message } = await TestDataGenerator.generateRecordsSubscribe(); + const tenant = author!.did; + + // setting up a stub method resolver & message store + const didResolver = TestStubGenerator.createDidResolverStub(author!); + const messageStore = stubInterface(); + const eventStream = stubInterface(); + const recordsSubscribeHandler = new RecordsSubscribeHandler(didResolver, messageStore, eventStream); + + // stub the `parse()` function to throw an error + sinon.stub(RecordsSubscribe, 'parse').throws('anyError'); + const reply = await recordsSubscribeHandler.handle({ tenant, message }); + + expect(reply.status.code).to.equal(400); + }); + + describe('protocol based subscriptions', () => { + it('does not try protocol authorization if protocolRole is not invoked', async () => { + // scenario: Alice creates a thread and writes some chat messages. Alice addresses + // only one chat message to Bob. Bob subscribes by protocol URI without invoking a protocolRole, + // and he is able to receive the message addressed to him. + + const alice = await DidKeyResolver.generate(); + const bob = await DidKeyResolver.generate(); + + const protocolDefinition = threadRoleProtocolDefinition; + + const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({ + author: alice, + protocolDefinition + }); + const protocolsConfigureReply = await dwn.processMessage(alice.did, protocolsConfig.message); + expect(protocolsConfigureReply.status.code).to.equal(202); + + const bobSubscription = await TestDataGenerator.generateRecordsSubscribe({ + author : bob, + filter : { + published : false, + protocol : protocolDefinition.protocol, + } + }); + const subscriptionReply = await dwn.processMessage(alice.did, bobSubscription.message); + expect(subscriptionReply.status.code).to.equal(200); + expect(subscriptionReply.subscription).to.exist; + const messageCids: string[] = []; + const addCid = async (message: RecordsWriteMessage | RecordsDeleteMessage): Promise => { + const messageCid = await Message.getCid(message); + messageCids.push(messageCid); + }; + subscriptionReply.subscription?.on(addCid); + + // Alice writes a 'thread' record + const threadRecord = await TestDataGenerator.generateRecordsWrite({ + author : alice, + protocol : protocolDefinition.protocol, + protocolPath : 'thread', + }); + const threadRoleReply = await dwn.processMessage(alice.did, threadRecord.message, threadRecord.dataStream); + expect(threadRoleReply.status.code).to.equal(202); + + // Alice writes one 'chat' record addressed to Bob + const chatRecordForBob = await TestDataGenerator.generateRecordsWrite({ + author : alice, + recipient : bob.did, + protocol : protocolDefinition.protocol, + protocolPath : 'thread/chat', + published : false, + contextId : threadRecord.message.contextId, + parentId : threadRecord.message.recordId, + data : new TextEncoder().encode('Bob can read this cuz he is my friend'), + }); + const chatRecordForBobReply = await dwn.processMessage(alice.did, chatRecordForBob.message, chatRecordForBob.dataStream); + expect(chatRecordForBobReply.status.code).to.equal(202); + + // Alice writes two 'chat' records NOT addressed to Bob + for (let i = 0; i < 2; i++) { + const chatRecord = await TestDataGenerator.generateRecordsWrite({ + author : alice, + recipient : alice.did, + protocol : protocolDefinition.protocol, + protocolPath : 'thread/chat', + published : false, + contextId : threadRecord.message.contextId, + parentId : threadRecord.message.recordId, + data : new TextEncoder().encode('Bob cannot read this'), + }); + const chatReply = await dwn.processMessage(alice.did, chatRecord.message, chatRecord.dataStream); + expect(chatReply.status.code).to.equal(202); + } + + expect(messageCids.length).to.equal(1); + expect(messageCids[0]).to.equal(await Message.getCid(chatRecordForBob.message)); + + // delete the chat addressed to bob + const deleteChatForBob = await TestDataGenerator.generateRecordsDelete({ recordId: chatRecordForBob.message.recordId, author: alice }); + const deleteChatReply = await dwn.processMessage(alice.did, deleteChatForBob.message); + expect(deleteChatReply.status.code).to.equal(202); + + expect(messageCids.length).to.equal(2); + expect(messageCids[1]).to.equal(await Message.getCid(deleteChatForBob.message)); + }); + + it('allows $globalRole authorized subscriptions', async () => { + // scenario: Alice creates a thread and writes some chat messages writes a chat message. Bob invokes his + // thread member role in order to subscribe to the chat messages. + + const alice = await DidKeyResolver.generate(); + const bob = await DidKeyResolver.generate(); + + const protocolDefinition = friendRoleProtocolDefinition; + + const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({ + author: alice, + protocolDefinition + }); + const protocolsConfigureReply = await dwn.processMessage(alice.did, protocolsConfig.message); + expect(protocolsConfigureReply.status.code).to.equal(202); + + const filter: RecordsFilter = { + published : false, + protocol : protocolDefinition.protocol, + protocolPath : 'chat' + }; + + // subscribe without role, expect no messages + const noRoleSubscription = await TestDataGenerator.generateRecordsSubscribe({ + author: bob, + filter + }); + + const subscriptionReply = await dwn.processMessage(alice.did, noRoleSubscription.message); + expect(subscriptionReply.status.code).to.equal(200); + expect(subscriptionReply.subscription).to.exist; + const noRoleRecords: string[] = []; + const addNoRole = async (message: GenericMessage): Promise => { + if (message.descriptor.interface === DwnInterfaceName.Records && message.descriptor.method === DwnMethodName.Write) { + const recordsWriteMessage = message as RecordsWriteMessage; + noRoleRecords.push(recordsWriteMessage.recordId); + } + }; + subscriptionReply.subscription?.on(addNoRole); + + // Alice writes a 'friend' $globalRole record with Bob as recipient + const friendRoleRecord = await TestDataGenerator.generateRecordsWrite({ + author : alice, + recipient : bob.did, + protocol : protocolDefinition.protocol, + protocolPath : 'friend', + data : new TextEncoder().encode('Bob is my friend'), + }); + const friendRoleReply = await dwn.processMessage(alice.did, friendRoleRecord.message, friendRoleRecord.dataStream); + expect(friendRoleReply.status.code).to.equal(202); + + // subscribe with friend role + const bobSubscriptionWithRole = await TestDataGenerator.generateRecordsSubscribe({ + filter, + author : bob, + protocolRole : 'friend', + }); + + const subscriptionWithRoleReply = await dwn.processMessage(alice.did, bobSubscriptionWithRole.message); + expect(subscriptionWithRoleReply.status.code).to.equal(200); + expect(subscriptionWithRoleReply.subscription).to.exist; + const recordIds: string[] = []; + const addRecord = async (message: GenericMessage): Promise => { + if (message.descriptor.interface === DwnInterfaceName.Records && message.descriptor.method === DwnMethodName.Write) { + const recordsWriteMessage = message as RecordsWriteMessage; + recordIds.push(recordsWriteMessage.recordId); + } + }; + subscriptionWithRoleReply.subscription?.on(addRecord); + + // Alice writes three 'chat' records + const chatRecordIds = []; + for (let i = 0; i < 3; i++) { + const chatRecord = await TestDataGenerator.generateRecordsWrite({ + author : alice, + recipient : alice.did, + protocol : protocolDefinition.protocol, + protocolPath : 'chat', + published : false, + data : new TextEncoder().encode('Bob can read this cuz he is my friend'), + }); + const chatReply = await dwn.processMessage(alice.did, chatRecord.message, chatRecord.dataStream); + expect(chatReply.status.code).to.equal(202); + chatRecordIds.push(chatRecord.message.recordId); + } + + // there should not be any messages in the subscription without a friend role. + expect(noRoleRecords.length).to.equal(0); + + // should have all chat messages + expect(recordIds).to.have.members(chatRecordIds); + }); + + it('allows protocol authorized subscriptions', async () => { + // scenario: Alice writes some chat messages writes a chat message. + // Bob, having a thread/participant record, can subscribe to the chat. + + const alice = await DidKeyResolver.generate(); + const bob = await DidKeyResolver.generate(); + + const protocolDefinition = threadRoleProtocolDefinition; + + const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({ + author: alice, + protocolDefinition + }); + const protocolsConfigureReply = await dwn.processMessage(alice.did, protocolsConfig.message); + expect(protocolsConfigureReply.status.code).to.equal(202); + + + // Alice writes a 'thread' record + const threadRecord = await TestDataGenerator.generateRecordsWrite({ + author : alice, + protocol : protocolDefinition.protocol, + protocolPath : 'thread', + }); + const threadRoleReply = await dwn.processMessage(alice.did, threadRecord.message, threadRecord.dataStream); + expect(threadRoleReply.status.code).to.equal(202); + + const filter: RecordsFilter = { + protocol : protocolDefinition.protocol, + protocolPath : 'thread/chat', + contextId : threadRecord.message.contextId, + }; + + // subscribe without role, expect no messages + const noRoleSubscription = await TestDataGenerator.generateRecordsSubscribe({ + author: bob, + filter + }); + + const subscriptionReply = await dwn.processMessage(alice.did, noRoleSubscription.message); + expect(subscriptionReply.status.code).to.equal(200); + expect(subscriptionReply.subscription).to.exist; + const noRoleRecords: string[] = []; + const addNoRole = async (message: GenericMessage): Promise => { + if (message.descriptor.interface === DwnInterfaceName.Records && message.descriptor.method === DwnMethodName.Write) { + const recordsWriteMessage = message as RecordsWriteMessage; + noRoleRecords.push(recordsWriteMessage.recordId); + } + }; + subscriptionReply.subscription?.on(addNoRole); + + // Alice writes a 'participant' $contextRole record with Bob as recipient + const participantRoleRecord = await TestDataGenerator.generateRecordsWrite({ + author : alice, + recipient : bob.did, + protocol : protocolDefinition.protocol, + protocolPath : 'thread/participant', + contextId : threadRecord.message.contextId, + parentId : threadRecord.message.recordId, + data : new TextEncoder().encode('Bob is my friend'), + }); + const participantRoleReply = await dwn.processMessage(alice.did, participantRoleRecord.message, participantRoleRecord.dataStream); + expect(participantRoleReply.status.code).to.equal(202); + + // subscribe with the participant role + const bobSubscriptionWithRole = await TestDataGenerator.generateRecordsSubscribe({ + filter, + author : bob, + protocolRole : 'thread/participant', + }); + + const subscriptionWithRoleReply = await dwn.processMessage(alice.did, bobSubscriptionWithRole.message); + expect(subscriptionWithRoleReply.status.code).to.equal(200); + expect(subscriptionWithRoleReply.subscription).to.exist; + const recordIds: string[] = []; + const addRecord = async (message: GenericMessage): Promise => { + if (message.descriptor.interface === DwnInterfaceName.Records && message.descriptor.method === DwnMethodName.Write) { + const recordsWriteMessage = message as RecordsWriteMessage; + recordIds.push(recordsWriteMessage.recordId); + } + }; + subscriptionWithRoleReply.subscription?.on(addRecord); + + // Alice writes three 'chat' records + const chatRecordIds = []; + for (let i = 0; i < 3; i++) { + const chatRecord = await TestDataGenerator.generateRecordsWrite({ + author : alice, + recipient : alice.did, + protocol : protocolDefinition.protocol, + protocolPath : 'thread/chat', + published : false, + contextId : threadRecord.message.contextId, + parentId : threadRecord.message.recordId, + data : new TextEncoder().encode('Bob can read this cuz he is my friend'), + }); + const chatReply = await dwn.processMessage(alice.did, chatRecord.message, chatRecord.dataStream); + expect(chatReply.status.code).to.equal(202); + chatRecordIds.push(chatRecord.message.recordId); + } + + // there should not be any messages in the subscription without a participant role. + expect(noRoleRecords.length).to.equal(0); + + // should have all chat messages. + expect(recordIds).to.have.members(chatRecordIds); + }); + + it('does not execute protocol subscriptions where protocolPath is missing from the filter', async () => { + // scenario: Alice writes some chat messages. Bob invokes his $globalRole to subscribe those messages, + // but his subscription filter does not include protocolPath. + + const alice = await DidKeyResolver.generate(); + const bob = await DidKeyResolver.generate(); + + const protocolDefinition = friendRoleProtocolDefinition; + + const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({ + author: alice, + protocolDefinition + }); + const protocolsConfigureReply = await dwn.processMessage(alice.did, protocolsConfig.message); + expect(protocolsConfigureReply.status.code).to.equal(202); + + // Alice writes a 'friend' $globalRole record with Bob as recipient + const friendRoleRecord = await TestDataGenerator.generateRecordsWrite({ + author : alice, + recipient : bob.did, + protocol : protocolDefinition.protocol, + protocolPath : 'friend', + data : new TextEncoder().encode('Bob is my friend'), + }); + const friendRoleReply = await dwn.processMessage(alice.did, friendRoleRecord.message, friendRoleRecord.dataStream); + expect(friendRoleReply.status.code).to.equal(202); + + // Alice writes three 'chat' records + const chatRecordIds = []; + for (let i = 0; i < 3; i++) { + const chatRecord = await TestDataGenerator.generateRecordsWrite({ + author : alice, + recipient : alice.did, + protocol : protocolDefinition.protocol, + protocolPath : 'chat', + published : false, + data : new TextEncoder().encode('Bob can read this cuz he is my friend'), + }); + const chatReply = await dwn.processMessage(alice.did, chatRecord.message, chatRecord.dataStream); + expect(chatReply.status.code).to.equal(202); + chatRecordIds.push(chatRecord.message.recordId); + } + + // Bob invokes his friendRole to subscribe but does not have `protocolPath` in the filter + const chatSubscribe = await TestDataGenerator.generateRecordsSubscribe({ + author : bob, + filter : { + protocol: protocolDefinition.protocol, + // protocolPath deliberately omitted + }, + protocolRole: 'friend', + }); + const chatSubscribeReply = await dwn.processMessage(alice.did, chatSubscribe.message); + expect(chatSubscribeReply.status.code).to.equal(400); + expect(chatSubscribeReply.status.detail).to.contain(DwnErrorCode.RecordsSubscribeFilterMissingRequiredProperties); + expect(chatSubscribeReply.subscription).to.not.exist; + }); + + it('does not execute $contextRole authorized subscriptions where contextId is missing from the filter', async () => { + // scenario: Alice writes some chat messages and gives Bob a role allowing him to access them. But Bob's filter + // does not contain a contextId so the subscription fails. + const alice = await DidKeyResolver.generate(); + const bob = await DidKeyResolver.generate(); + + const protocolDefinition = threadRoleProtocolDefinition; + + const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({ + author: alice, + protocolDefinition + }); + const protocolsConfigureReply = await dwn.processMessage(alice.did, protocolsConfig.message); + expect(protocolsConfigureReply.status.code).to.equal(202); + + // Alice writes a 'thread' record + const threadRecord = await TestDataGenerator.generateRecordsWrite({ + author : alice, + protocol : protocolDefinition.protocol, + protocolPath : 'thread', + }); + const threadRoleReply = await dwn.processMessage(alice.did, threadRecord.message, threadRecord.dataStream); + expect(threadRoleReply.status.code).to.equal(202); + + // Alice writes a 'friend' $globalRole record with Bob as recipient + const participantRoleRecord = await TestDataGenerator.generateRecordsWrite({ + author : alice, + recipient : bob.did, + protocol : protocolDefinition.protocol, + protocolPath : 'thread/participant', + contextId : threadRecord.message.contextId, + parentId : threadRecord.message.recordId, + data : new TextEncoder().encode('Bob is my friend'), + }); + const participantRoleReply = await dwn.processMessage(alice.did, participantRoleRecord.message, participantRoleRecord.dataStream); + expect(participantRoleReply.status.code).to.equal(202); + + // Alice writes three 'chat' records + const chatRecordIds = []; + for (let i = 0; i < 3; i++) { + const chatRecord = await TestDataGenerator.generateRecordsWrite({ + author : alice, + recipient : alice.did, + protocol : protocolDefinition.protocol, + protocolPath : 'thread/chat', + published : false, + contextId : threadRecord.message.contextId, + parentId : threadRecord.message.recordId, + data : new TextEncoder().encode('Bob can read this cuz he is my friend'), + }); + const chatReply = await dwn.processMessage(alice.did, chatRecord.message, chatRecord.dataStream); + expect(chatReply.status.code).to.equal(202); + chatRecordIds.push(chatRecord.message.recordId); + } + + // Bob invokes his thread participant role to subscribe + const chatSubscribe = await TestDataGenerator.generateRecordsSubscribe({ + author : bob, + filter : { + protocol : protocolDefinition.protocol, + protocolPath : 'thread/chat', + // contextId deliberately omitted + }, + protocolRole: 'thread/participant', + }); + const chatSubscribeReply = await dwn.processMessage(alice.did, chatSubscribe.message); + expect(chatSubscribeReply.status.code).to.eq(401); + expect(chatSubscribeReply.status.detail).to.contain(DwnErrorCode.ProtocolAuthorizationMissingContextId); + expect(chatSubscribeReply.subscription).to.not.exist; + }); + + it('rejects $globalRole authorized subscriptions if the request author does not have a matching $globalRole', async () => { + // scenario: Alice creates a thread and writes some chat messages writes a chat message. Bob invokes a + // $globalRole but fails because he does not actually have a role. + + const alice = await DidKeyResolver.generate(); + const bob = await DidKeyResolver.generate(); + + const protocolDefinition = friendRoleProtocolDefinition; + + const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({ + author: alice, + protocolDefinition + }); + const protocolsConfigureReply = await dwn.processMessage(alice.did, protocolsConfig.message); + expect(protocolsConfigureReply.status.code).to.equal(202); + + // Alice writes three 'chat' records + const chatRecordIds = []; + for (let i = 0; i < 3; i++) { + const chatRecord = await TestDataGenerator.generateRecordsWrite({ + author : alice, + recipient : alice.did, + protocol : protocolDefinition.protocol, + protocolPath : 'chat', + published : false, + data : new TextEncoder().encode('Bob can read this cuz he is my friend'), + }); + const chatReply = await dwn.processMessage(alice.did, chatRecord.message, chatRecord.dataStream); + expect(chatReply.status.code).to.equal(202); + chatRecordIds.push(chatRecord.message.recordId); + } + + // Bob invokes his friendRole to subscribe to the records + const chatSubscribe = await TestDataGenerator.generateRecordsSubscribe({ + author : bob, + filter : { + protocol : protocolDefinition.protocol, + protocolPath : 'chat', + }, + protocolRole: 'friend', + }); + const chatSubscribeReply = await dwn.processMessage(alice.did, chatSubscribe.message); + expect(chatSubscribeReply.status.code).to.eq(401); + expect(chatSubscribeReply.status.detail).to.contain(DwnErrorCode.ProtocolAuthorizationMissingRole); + expect(chatSubscribeReply.subscription).to.not.exist; + }); + + it('rejects protocol authorized subscriptions where the subscription author does not have a matching $contextRole', async () => { + + const alice = await DidKeyResolver.generate(); + const bob = await DidKeyResolver.generate(); + + const protocolDefinition = threadRoleProtocolDefinition; + + const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({ + author: alice, + protocolDefinition + }); + const protocolsConfigureReply = await dwn.processMessage(alice.did, protocolsConfig.message); + expect(protocolsConfigureReply.status.code).to.equal(202); + + // Alice writes a 'thread' record + const threadRecord = await TestDataGenerator.generateRecordsWrite({ + author : alice, + protocol : protocolDefinition.protocol, + protocolPath : 'thread', + }); + const threadRoleReply = await dwn.processMessage(alice.did, threadRecord.message, threadRecord.dataStream); + expect(threadRoleReply.status.code).to.equal(202); + + // Alice writes three 'chat' records + const chatRecordIds = []; + for (let i = 0; i < 3; i++) { + const chatRecord = await TestDataGenerator.generateRecordsWrite({ + author : alice, + recipient : alice.did, + protocol : protocolDefinition.protocol, + protocolPath : 'thread/chat', + published : false, + contextId : threadRecord.message.contextId, + parentId : threadRecord.message.recordId, + data : new TextEncoder().encode('Bob can read this cuz he is my friend'), + }); + const chatReply = await dwn.processMessage(alice.did, chatRecord.message, chatRecord.dataStream); + expect(chatReply.status.code).to.equal(202); + chatRecordIds.push(chatRecord.message.recordId); + } + + // Bob invokes his friendRole to subscribe to the records + const chatSubscribe = await TestDataGenerator.generateRecordsSubscribe({ + author : bob, + filter : { + protocol : protocolDefinition.protocol, + protocolPath : 'thread/chat', + contextId : threadRecord.message.contextId, + }, + protocolRole: 'thread/participant', + }); + const chatSubscribeReply = await dwn.processMessage(alice.did, chatSubscribe.message); + expect(chatSubscribeReply.status.code).to.eq(401); + expect(chatSubscribeReply.status.detail).to.contain(DwnErrorCode.ProtocolAuthorizationMissingRole); + expect(chatSubscribeReply.subscription).to.not.exist; + }); + }); + }); + }); +} \ No newline at end of file diff --git a/tests/handlers/records-write.spec.ts b/tests/handlers/records-write.spec.ts index b3f3b466c..9b618c8d9 100644 --- a/tests/handlers/records-write.spec.ts +++ b/tests/handlers/records-write.spec.ts @@ -1,4 +1,5 @@ import type { EncryptionInput } from '../../src/interfaces/records-write.js'; +import type { EventStream } from '../../src/types/event-stream.js'; import type { GenerateFromRecordsWriteOut } from '../utils/test-data-generator.js'; import type { ProtocolDefinition } from '../../src/types/protocols-types.js'; import type { RecordsQueryReplyEntry } from '../../src/types/records-types.js'; @@ -42,7 +43,7 @@ import { TestStores } from '../test-stores.js'; import { TestStubGenerator } from '../utils/test-stub-generator.js'; import { Time } from '../../src/utils/time.js'; -import { DwnConstant, DwnInterfaceName, DwnMethodName, KeyDerivationScheme, RecordsDelete, RecordsQuery } from '../../src/index.js'; +import { DwnConstant, DwnInterfaceName, DwnMethodName, EventStreamEmitter, KeyDerivationScheme, RecordsDelete, RecordsQuery } from '../../src/index.js'; import { Encryption, EncryptionAlgorithm } from '../../src/utils/encryption.js'; chai.use(chaiAsPromised); @@ -53,6 +54,7 @@ export function testRecordsWriteHandler(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; describe('functional tests', () => { @@ -66,8 +68,9 @@ export function testRecordsWriteHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; + eventStream = new EventStreamEmitter({ messageStore, didResolver }); - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { @@ -3077,7 +3080,7 @@ export function testRecordsWriteHandler(): void { // replace valid `encryption` property with a mismatching one message.encryption!.initializationVector = Encoder.stringToBase64Url('any value which will result in a different CID'); - const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog); + const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog, eventStream); const writeReply = await recordsWriteHandler.handle({ tenant: alice.did, message, dataStream: dataStream! }); expect(writeReply.status.code).to.equal(400); @@ -4204,7 +4207,7 @@ export function testRecordsWriteHandler(): void { const messageStore = stubInterface(); const dataStore = stubInterface(); - const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog); + const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog, eventStream); const reply = await recordsWriteHandler.handle({ tenant, message, dataStream: dataStream! }); expect(reply.status.code).to.equal(400); @@ -4228,7 +4231,7 @@ export function testRecordsWriteHandler(): void { const messageStore = stubInterface(); const dataStore = stubInterface(); - const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog); + const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog, eventStream); const reply = await recordsWriteHandler.handle({ tenant, message, dataStream: dataStream! }); expect(reply.status.code).to.equal(400); @@ -4246,7 +4249,7 @@ export function testRecordsWriteHandler(): void { const messageStore = stubInterface(); const dataStore = stubInterface(); - const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog); + const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog, eventStream); const reply = await recordsWriteHandler.handle({ tenant, message, dataStream: dataStream! }); expect(reply.status.code).to.equal(401); @@ -4261,7 +4264,7 @@ export function testRecordsWriteHandler(): void { const messageStore = stubInterface(); const dataStore = stubInterface(); - const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog); + const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog, eventStream); const tenant = await (await TestDataGenerator.generatePersona()).did; // unauthorized tenant const reply = await recordsWriteHandler.handle({ tenant, message, dataStream: dataStream! }); @@ -4294,7 +4297,7 @@ export function testRecordsWriteHandler(): void { const messageStore = stubInterface(); const dataStore = stubInterface(); - const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog); + const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog, eventStream); const reply = await recordsWriteHandler.handle({ tenant, message, dataStream: dataStream! }); expect(reply.status.code).to.equal(400); @@ -4306,7 +4309,7 @@ export function testRecordsWriteHandler(): void { const bob = await DidKeyResolver.generate(); const { message, dataStream } = await TestDataGenerator.generateRecordsWrite({ author: alice, attesters: [alice, bob] }); - const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog); + const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog, eventStream); const writeReply = await recordsWriteHandler.handle({ tenant: alice.did, message, dataStream: dataStream! }); expect(writeReply.status.code).to.equal(400); @@ -4321,7 +4324,7 @@ export function testRecordsWriteHandler(): void { const anotherWrite = await TestDataGenerator.generateRecordsWrite({ attesters: [alice] }); message.attestation = anotherWrite.message.attestation; - const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog); + const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog, eventStream); const writeReply = await recordsWriteHandler.handle({ tenant: alice.did, message, dataStream: dataStream! }); expect(writeReply.status.code).to.equal(400); @@ -4338,7 +4341,7 @@ export function testRecordsWriteHandler(): void { const attestationNotReferencedByAuthorization = await RecordsWrite['createAttestation'](descriptorCid, Jws.createSigners([bob])); message.attestation = attestationNotReferencedByAuthorization; - const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog); + const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog, eventStream); const writeReply = await recordsWriteHandler.handle({ tenant: alice.did, message, dataStream: dataStream! }); expect(writeReply.status.code).to.equal(400); @@ -4359,14 +4362,15 @@ export function testRecordsWriteHandler(): void { const { message, dataStream } = await TestDataGenerator.generateFromRecordsWrite({ author, existingWrite: initialWrite }); const tenant = author.did; - const didResolverStub = TestStubGenerator.createDidResolverStub(author); const messageStoreStub = stubInterface(); messageStoreStub.query.resolves({ messages: [ initialWriteMessage ] }); const dataStoreStub = stubInterface(); - const recordsWriteHandler = new RecordsWriteHandler(didResolverStub, messageStoreStub, dataStoreStub, eventLog); + + const recordsWriteHandler = new RecordsWriteHandler(didResolverStub, messageStoreStub, dataStoreStub, eventLog, eventStream); + // simulate throwing unexpected error sinon.stub(recordsWriteHandler as any, 'processMessageWithoutDataStream').throws(new Error('an unknown error in recordsWriteHandler.processMessageWithoutDataStream()')); sinon.stub(recordsWriteHandler as any, 'processMessageWithDataStream').throws(new Error('an unknown error in recordsWriteHandler.processMessageWithDataStream()')); diff --git a/tests/interfaces/events-subscribe.spec.ts b/tests/interfaces/events-subscribe.spec.ts new file mode 100644 index 000000000..a3e422551 --- /dev/null +++ b/tests/interfaces/events-subscribe.spec.ts @@ -0,0 +1,20 @@ +import { EventsSubscribe } from '../../src/interfaces/events-subscribe.js'; +import { DidKeyResolver, DwnInterfaceName, DwnMethodName, Jws } from '../../src/index.js'; + +import { expect } from 'chai'; + +describe('EventsSubscribe', () => { + describe('create()', () => { + it('should be able to create and authorize EventsSubscribe', async () => { + const alice = await DidKeyResolver.generate(); + const { message } = await EventsSubscribe.create({ + signer: Jws.createSigner(alice) + }); + + expect(message.descriptor.interface).to.eql(DwnInterfaceName.Events); + expect(message.descriptor.method).to.eql(DwnMethodName.Subscribe); + expect(message.authorization).to.exist; + }); + + }); +}); diff --git a/tests/interfaces/records-subscribe.spec.ts b/tests/interfaces/records-subscribe.spec.ts new file mode 100644 index 000000000..99e1ebebe --- /dev/null +++ b/tests/interfaces/records-subscribe.spec.ts @@ -0,0 +1,79 @@ +import chaiAsPromised from 'chai-as-promised'; +import chai, { expect } from 'chai'; + +import dexProtocolDefinition from '../vectors/protocol-definitions/dex.json' assert { type: 'json' }; +import { Jws } from '../../src/index.js'; +import { RecordsQuery } from '../../src/interfaces/records-query.js'; +import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { Time } from '../../src/utils/time.js'; + +chai.use(chaiAsPromised); + +describe('RecordsSubscribe', () => { + describe('create()', () => { + it('should not allow published to be set to false with a datePublished filter also set', async () => { + // test control + const randomDate = TestDataGenerator.randomTimestamp(); + const recordQueryControl = TestDataGenerator.generateRecordsQuery({ + filter: { datePublished: { from: randomDate, }, published: true } + }); + + await expect(recordQueryControl).to.eventually.not.be.rejected; + + const recordQueryRejected = TestDataGenerator.generateRecordsQuery({ + filter: { datePublished: { from: randomDate }, published: false } + }); + await expect(recordQueryRejected).to.eventually.be.rejectedWith('descriptor/filter/published: must be equal to one of the allowed values'); + }); + + it('should use `messageTimestamp` as is if given', async () => { + const alice = await TestDataGenerator.generatePersona(); + + const currentTime = Time.getCurrentTimestamp(); + const recordsQuery = await RecordsQuery.create({ + filter : { schema: 'anything' }, + messageTimestamp : currentTime, + signer : Jws.createSigner(alice), + }); + + expect(recordsQuery.message.descriptor.messageTimestamp).to.equal(currentTime); + }); + + it('should auto-normalize protocol URL', async () => { + const alice = await TestDataGenerator.generatePersona(); + + const options = { + recipient : alice.did, + data : TestDataGenerator.randomBytes(10), + dataFormat : 'application/json', + signer : Jws.createSigner(alice), + filter : { protocol: 'example.com/' }, + definition : dexProtocolDefinition + }; + const recordsQuery = await RecordsQuery.create(options); + + const message = recordsQuery.message; + + expect(message.descriptor.filter!.protocol).to.eq('http://example.com'); + }); + + it('should auto-normalize schema URL', async () => { + const alice = await TestDataGenerator.generatePersona(); + + const options = { + recipient : alice.did, + data : TestDataGenerator.randomBytes(10), + dataFormat : 'application/json', + signer : Jws.createSigner(alice), + filter : { schema: 'example.com/' }, + definition : dexProtocolDefinition + }; + const recordsQuery = await RecordsQuery.create(options); + + const message = recordsQuery.message; + + expect(message.descriptor.filter!.schema).to.eq('http://example.com'); + }); + }); +}); + diff --git a/tests/scenarios/delegated-grant.spec.ts b/tests/scenarios/delegated-grant.spec.ts index fd1388449..532e9b411 100644 --- a/tests/scenarios/delegated-grant.spec.ts +++ b/tests/scenarios/delegated-grant.spec.ts @@ -1,3 +1,4 @@ +import type { EventStream } from '../../src/types/event-stream.js'; import type { DataStore, EventLog, MessageStore, PermissionScope } from '../../src/index.js'; import chaiAsPromised from 'chai-as-promised'; @@ -19,7 +20,7 @@ import { TestDataGenerator } from '../utils/test-data-generator.js'; import { TestStores } from '../test-stores.js'; import { Time } from '../../src/utils/time.js'; -import { DwnInterfaceName, DwnMethodName, PermissionsGrant, RecordsDelete, RecordsQuery, RecordsRead } from '../../src/index.js'; +import { DwnInterfaceName, DwnMethodName, EventStreamEmitter, PermissionsGrant, RecordsDelete, RecordsQuery, RecordsRead } from '../../src/index.js'; chai.use(chaiAsPromised); @@ -29,6 +30,7 @@ export function testDelegatedGrantScenarios(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; // important to follow the `before` and `after` pattern to initialize and clean the stores in tests @@ -40,8 +42,9 @@ export function testDelegatedGrantScenarios(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; + eventStream = new EventStreamEmitter({ messageStore, didResolver }); - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { diff --git a/tests/scenarios/end-to-end-tests.spec.ts b/tests/scenarios/end-to-end-tests.spec.ts index 3582a91ca..449a47ac9 100644 --- a/tests/scenarios/end-to-end-tests.spec.ts +++ b/tests/scenarios/end-to-end-tests.spec.ts @@ -1,4 +1,5 @@ import type { DerivedPrivateJwk } from '../../src/utils/hd-key.js'; +import type { EventStream } from '../../src/types/event-stream.js'; import type { DataStore, EventLog, MessageStore, ProtocolDefinition, ProtocolsConfigureMessage, RecordsReadReply } from '../../src/index.js'; import chaiAsPromised from 'chai-as-promised'; @@ -7,12 +8,12 @@ import threadRoleProtocolDefinition from '../vectors/protocol-definitions/thread import { authenticate } from '../../src/core/auth.js'; import { DidKeyResolver } from '../../src/did/did-key-resolver.js'; -import { Encoder } from '../../src/index.js'; import { HdKey } from '../../src/utils/hd-key.js'; import { KeyDerivationScheme } from '../../src/utils/hd-key.js'; import { TestDataGenerator } from '../utils/test-data-generator.js'; import { TestStores } from '../test-stores.js'; import { TestStubGenerator } from '../utils/test-stub-generator.js'; +import { Encoder, EventStreamEmitter } from '../../src/index.js'; import chai, { expect } from 'chai'; import { DataStream, DidResolver, Dwn, Jws, Protocols, ProtocolsConfigure, ProtocolsQuery, Records, RecordsRead } from '../../src/index.js'; @@ -25,6 +26,7 @@ export function testEndToEndScenarios(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; // important to follow the `before` and `after` pattern to initialize and clean the stores in tests @@ -36,8 +38,9 @@ export function testEndToEndScenarios(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; + eventStream = new EventStreamEmitter({ messageStore, didResolver }); - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { diff --git a/tests/scenarios/events-query.spec.ts b/tests/scenarios/events-query.spec.ts index 246b1581f..0ba31fbd8 100644 --- a/tests/scenarios/events-query.spec.ts +++ b/tests/scenarios/events-query.spec.ts @@ -1,6 +1,7 @@ import type { DataStore, EventLog, + EventStream, MessageStore } from '../../src/index.js'; @@ -8,7 +9,7 @@ import freeForAll from '../vectors/protocol-definitions/free-for-all.json' asser import threadProtocol from '../vectors/protocol-definitions/thread-role.json' assert { type: 'json' }; import { TestStores } from '../test-stores.js'; -import { DidKeyResolver, DidResolver, Dwn, DwnConstant, DwnInterfaceName, DwnMethodName, Message, Time } from '../../src/index.js'; +import { DidKeyResolver, DidResolver, Dwn, DwnConstant, DwnInterfaceName, DwnMethodName, EventStreamEmitter, Message, Time } from '../../src/index.js'; import { expect } from 'chai'; import { TestDataGenerator } from '../utils/test-data-generator.js'; @@ -19,6 +20,7 @@ export function testEventsQueryScenarios(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; // important to follow the `before` and `after` pattern to initialize and clean the stores in tests @@ -30,8 +32,9 @@ export function testEventsQueryScenarios(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; + eventStream = new EventStreamEmitter({ messageStore, didResolver }); - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { diff --git a/tests/scenarios/subscriptions.spec.ts b/tests/scenarios/subscriptions.spec.ts new file mode 100644 index 000000000..2829ea39f --- /dev/null +++ b/tests/scenarios/subscriptions.spec.ts @@ -0,0 +1,579 @@ +import type { + DataStore, + EventLog, + EventStream, + GenericMessage, + MessageStore, + RecordsDeleteMessage, + RecordsWriteMessage, +} from '../../src/index.js'; + +import freeForAll from '../vectors/protocol-definitions/free-for-all.json' assert { type: 'json' }; +import friendRole from '../vectors/protocol-definitions/friend-role.json' assert { type: 'json' }; + +import { RecordsSubscriptionHandler } from '../../src/handlers/records-subscribe.js'; +import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestStores } from '../test-stores.js'; +import { Time } from '../../src/utils/time.js'; +import { DidKeyResolver, DidResolver, Dwn, EventStreamEmitter, Message } from '../../src/index.js'; + +import { expect } from 'chai'; +import sinon from 'sinon'; + +export function testSubscriptionScenarios(): void { + describe('subscriptions', () => { + describe('without reauthorization', () => { + let didResolver: DidResolver; + let messageStore: MessageStore; + let dataStore: DataStore; + let eventLog: EventLog; + let eventStream: EventStream; + let dwn: Dwn; + + // important to follow the `before` and `after` pattern to initialize and clean the stores in tests + // so that different test suites can reuse the same backend store for testing + before(async () => { + didResolver = new DidResolver([new DidKeyResolver()]); + + const stores = TestStores.get(); + messageStore = stores.messageStore; + dataStore = stores.dataStore; + eventLog = stores.eventLog; + eventStream = new EventStreamEmitter({ messageStore, didResolver }); + + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); + }); + + beforeEach(async () => { + // clean up before each test rather than after so that a test does not depend on other tests to do the clean up + await messageStore.clear(); + await dataStore.clear(); + await eventLog.clear(); + }); + + after(async () => { + await dwn.close(); + }); + + describe('records subscribe', () => { + it('filters by protocol', async () => { + const alice = await DidKeyResolver.generate(); + + // create a proto1 + const protoConf1 = await TestDataGenerator.generateProtocolsConfigure({ + author : alice, + protocolDefinition : { ...freeForAll, protocol: 'proto1' } + }); + + const postProperties = { + protocolPath : 'post', + schema : freeForAll.types.post.schema, + dataFormat : freeForAll.types.post.dataFormats[0], + }; + + // create a proto1 + const proto1 = protoConf1.message.descriptor.definition.protocol; + const protoConf1Response = await dwn.processMessage(alice.did, protoConf1.message); + expect(protoConf1Response.status.code).equals(202); + + // create a proto2 + const protoConf2 = await TestDataGenerator.generateProtocolsConfigure({ + author : alice, + protocolDefinition : { ...freeForAll, protocol: 'proto2' } + }); + const proto2 = protoConf2.message.descriptor.definition.protocol; + const protoConf2Response = await dwn.processMessage(alice.did, protoConf2.message); + expect(protoConf2Response.status.code).equals(202); + + // we will add messageCids to these arrays as they are received by their handler to check against later + const proto1Messages:string[] = []; + const proto2Messages:string[] = []; + + // subscribe to proto1 messages + const proto1Subscription = await TestDataGenerator.generateRecordsSubscribe({ author: alice, filter: { protocol: proto1 } }); + const proto1SubscriptionReply = await dwn.processMessage(alice.did, proto1Subscription.message); + expect(proto1SubscriptionReply.status.code).to.equal(200); + expect(proto1SubscriptionReply.subscription?.id).to.equal(await Message.getCid(proto1Subscription.message)); + + // we add a handler to the subscription and add the messageCid to the appropriate array + const proto1Handler = async (message:GenericMessage):Promise => { + const messageCid = await Message.getCid(message); + proto1Messages.push(messageCid); + }; + const proto1Sub = proto1SubscriptionReply.subscription!.on(proto1Handler); + + // subscribe to proto2 messages + const proto2Subscription = await TestDataGenerator.generateRecordsSubscribe({ author: alice, filter: { protocol: proto2 } }); + const proto2SubscriptionReply = await dwn.processMessage(alice.did, proto2Subscription.message); + expect(proto2SubscriptionReply.status.code).to.equal(200); + expect(proto2SubscriptionReply.subscription?.id).to.equal(await Message.getCid(proto2Subscription.message)); + // we add a handler to the subscription and add the messageCid to the appropriate array + const proto2Handler = async (message:GenericMessage):Promise => { + const messageCid = await Message.getCid(message); + proto2Messages.push(messageCid); + }; + proto2SubscriptionReply.subscription!.on(proto2Handler); + + // create some random record, will not show up in records subscription + const write1Random = await TestDataGenerator.generateRecordsWrite({ author: alice }); + const write1RandomResponse = await dwn.processMessage(alice.did, write1Random.message, write1Random.dataStream); + expect(write1RandomResponse.status.code).to.equal(202); + + // create a record for proto1 + const write1proto1 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto1, ...postProperties }); + const write1Response = await dwn.processMessage(alice.did, write1proto1.message, write1proto1.dataStream); + expect(write1Response.status.code).equals(202); + + // create a record for proto2 + const write1proto2 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto2, ...postProperties }); + const write1Proto2Response = await dwn.processMessage(alice.did, write1proto2.message, write1proto2.dataStream); + expect(write1Proto2Response.status.code).equals(202); + + expect(proto1Messages.length).to.equal(1, 'proto1'); + expect(proto1Messages).to.include(await Message.getCid(write1proto1.message)); + expect(proto2Messages.length).to.equal(1, 'proto2'); + expect(proto2Messages).to.include(await Message.getCid(write1proto2.message)); + + // remove listener for proto1 + proto1Sub.off(); + + // create another record for proto1 + const write2proto1 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto1, ...postProperties }); + const write2Response = await dwn.processMessage(alice.did, write2proto1.message, write2proto1.dataStream); + expect(write2Response.status.code).equals(202); + + // create another record for proto2 + const write2proto2 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto2, ...postProperties }); + const write2Proto2Response = await dwn.processMessage(alice.did, write2proto2.message, write2proto2.dataStream); + expect(write2Proto2Response.status.code).equals(202); + + // proto1 messages from handler do not change. + expect(proto1Messages.length).to.equal(1, 'proto1 after subscription.off()'); + expect(proto1Messages).to.include(await Message.getCid(write1proto1.message)); + + //proto2 messages from handler have the new message. + expect(proto2Messages.length).to.equal(2, 'proto2 after subscription.off()'); + expect(proto2Messages).to.have.members([await Message.getCid(write1proto2.message), await Message.getCid(write2proto2.message)]); + }); + + it('unsubscribes', async () => { + const alice = await DidKeyResolver.generate(); + + // subscribe to schema1 + const schema1Subscription = await TestDataGenerator.generateRecordsSubscribe({ author: alice, filter: { schema: 'schema1' } }); + const schema1SubscriptionRepl = await dwn.processMessage(alice.did, schema1Subscription.message); + expect(schema1SubscriptionRepl.status.code).to.equal(200); + + // messageCids of schema1 + const schema1Messages:string[] = []; + + const schema1Handler = async (message: GenericMessage): Promise => { + const messageCid = await Message.getCid(message); + schema1Messages.push(messageCid); + }; + schema1SubscriptionRepl.subscription!.on(schema1Handler); + expect(schema1Messages.length).to.equal(0); // no messages exist; + + const record1 = await TestDataGenerator.generateRecordsWrite({ author: alice, schema: 'schema1' }); + const record1Reply = await dwn.processMessage(alice.did, record1.message, record1.dataStream); + expect(record1Reply.status.code).to.equal(202); + const record1MessageCid = await Message.getCid(record1.message); + + expect(schema1Messages.length).to.equal(1); // message exists + expect(schema1Messages).to.eql([ record1MessageCid ]); + + // unsubscribe, this should be used as clean up. + await schema1SubscriptionRepl.subscription!.close(); + + // write another message. + const record2 = await TestDataGenerator.generateRecordsWrite({ author: alice, schema: 'schema1' }); + const record2Reply = await dwn.processMessage(alice.did, record2.message, record2.dataStream); + expect(record2Reply.status.code).to.equal(202); + + expect(schema1Messages.length).to.equal(1); // same as before + expect(schema1Messages).to.eql([ record1MessageCid ]); + }); + }); + + describe('events subscribe', () => { + xit('filters by protocol', async () => { + }); + }); + }); + + describe('reauthorization', () => { + let didResolver: DidResolver; + let messageStore: MessageStore; + let dataStore: DataStore; + let eventLog: EventLog; + let eventStream: EventStream; + let dwn: Dwn; + + before(async () => { + const stores = TestStores.get(); + messageStore = stores.messageStore; + dataStore = stores.dataStore; + eventLog = stores.eventLog; + + didResolver = new DidResolver([new DidKeyResolver()]); + eventStream = new EventStreamEmitter({ messageStore, didResolver }); + + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); + }); + + after(async () => { + sinon.restore(); + await dwn.close(); + }); + + beforeEach(async () => { + // clean up before each test rather than after so that a test does not depend on other tests to do the clean up + await messageStore.clear(); + await dataStore.clear(); + await eventLog.clear(); + }); + + it('does not reauthorize if TTL is set to zero', async () => { + const eventStream = new EventStreamEmitter({ messageStore, didResolver, reauthorizationTTL: 0 }); + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); + + const authorizeSpy = sinon.spy(RecordsSubscriptionHandler.prototype as any, 'reauthorize'); + + const alice = await DidKeyResolver.generate(); + const bob = await DidKeyResolver.generate(); + + // alice writes the friend role protocol + const protocolConf = await TestDataGenerator.generateProtocolsConfigure({ + author : alice, + published : true, + protocolDefinition : { ...friendRole } + }); + const proto = protocolConf.message.descriptor.definition.protocol; + const protoConfResponse = await dwn.processMessage(alice.did, protocolConf.message); + expect(protoConfResponse.status.code).equals(202); + + // alice adds bob as a friend. + const bobFriend = await TestDataGenerator.generateRecordsWrite({ + author : alice, + recipient : bob.did, + protocol : proto, + protocolPath : 'friend', + }); + const bobFriendReply = await dwn.processMessage(alice.did, bobFriend.message, bobFriend.dataStream); + expect(bobFriendReply.status.code).to.equal(202); + + // bob subscribes + const bobSubscribe = await TestDataGenerator.generateRecordsSubscribe({ + author : bob, + filter : { + protocol : proto, + protocolPath : 'chat', + }, + protocolRole: 'friend' + }); + const bobSubscribeReply = await dwn.processMessage(alice.did, bobSubscribe.message); + expect(bobSubscribeReply.status.code).to.equal(200); + + // capture the messageCids from the subscription + const messageCids: string[] = []; + const captureFunction = async (message: RecordsWriteMessage | RecordsDeleteMessage):Promise => { + const messageCid = await Message.getCid(message); + messageCids.push(messageCid); + }; + bobSubscribeReply.subscription!.on(captureFunction); + + //write some chat messages + const aliceMessage1 = await TestDataGenerator.generateRecordsWrite({ + author : alice, + protocol : proto, + protocolPath : 'chat' + }); + const aliceMessage1Cid = await Message.getCid(aliceMessage1.message); + const aliceMessage1Reply = await dwn.processMessage(alice.did, aliceMessage1.message, aliceMessage1.dataStream); + expect(aliceMessage1Reply.status.code).to.equal(202); + + const aliceMessage2 = await TestDataGenerator.generateRecordsWrite({ + author : alice, + protocol : proto, + protocolPath : 'chat' + }); + const aliceMessage2Cid = await Message.getCid(aliceMessage2.message); + const aliceMessage2Reply = await dwn.processMessage(alice.did, aliceMessage2.message, aliceMessage2.dataStream); + expect(aliceMessage2Reply.status.code).to.equal(202); + authorizeSpy.restore(); + + await Time.minimalSleep(); + + expect(authorizeSpy.callCount).to.equal(0, 'reauthorize'); // authorize is never called + expect(messageCids.length).to.equal(2, 'messageCids'); + expect(messageCids).to.have.members([ aliceMessage1Cid, aliceMessage2Cid ]); + }); + + it('reauthorize on every event emitted if TTL is less than zero', async () => { + const eventStream = new EventStreamEmitter({ messageStore, didResolver, reauthorizationTTL: -1 }); + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); + + const authorizeSpy = sinon.spy(RecordsSubscriptionHandler.prototype as any, 'reauthorize'); + + const alice = await DidKeyResolver.generate(); + const bob = await DidKeyResolver.generate(); + + // alice writes the friend role protocol + const protocolConf = await TestDataGenerator.generateProtocolsConfigure({ + author : alice, + published : true, + protocolDefinition : { ...friendRole } + }); + const proto = protocolConf.message.descriptor.definition.protocol; + const protoConfResponse = await dwn.processMessage(alice.did, protocolConf.message); + expect(protoConfResponse.status.code).equals(202); + + // alice adds bob as a friend. + const bobFriend = await TestDataGenerator.generateRecordsWrite({ + author : alice, + recipient : bob.did, + protocol : proto, + protocolPath : 'friend', + }); + const bobFriendReply = await dwn.processMessage(alice.did, bobFriend.message, bobFriend.dataStream); + expect(bobFriendReply.status.code).to.equal(202); + + // bob subscribes + const bobSubscribe = await TestDataGenerator.generateRecordsSubscribe({ + author : bob, + filter : { + protocol : proto, + protocolPath : 'chat', + }, + protocolRole: 'friend' + }); + const bobSubscribeReply = await dwn.processMessage(alice.did, bobSubscribe.message); + expect(bobSubscribeReply.status.code).to.equal(200); + + // capture the messageCids from the subscription + const messageCids: string[] = []; + const captureFunction = async (message: RecordsWriteMessage | RecordsDeleteMessage):Promise => { + const messageCid = await Message.getCid(message); + messageCids.push(messageCid); + }; + bobSubscribeReply.subscription!.on(captureFunction); + + //write some chat messages + const aliceMessage1 = await TestDataGenerator.generateRecordsWrite({ + author : alice, + protocol : proto, + protocolPath : 'chat' + }); + const aliceMessage1Cid = await Message.getCid(aliceMessage1.message); + const aliceMessage1Reply = await dwn.processMessage(alice.did, aliceMessage1.message, aliceMessage1.dataStream); + expect(aliceMessage1Reply.status.code).to.equal(202); + + const aliceMessage2 = await TestDataGenerator.generateRecordsWrite({ + author : alice, + protocol : proto, + protocolPath : 'chat' + }); + const aliceMessage2Cid = await Message.getCid(aliceMessage2.message); + const aliceMessage2Reply = await dwn.processMessage(alice.did, aliceMessage2.message, aliceMessage2.dataStream); + expect(aliceMessage2Reply.status.code).to.equal(202); + authorizeSpy.restore(); + + await Time.minimalSleep(); + + expect(authorizeSpy.callCount).to.equal(2, 'reauthorize'); // authorize on each message + expect(messageCids.length).to.equal(2, 'messageCids'); + expect(messageCids).to.have.members([ aliceMessage1Cid, aliceMessage2Cid ]); + }); + + it('reauthorizes after the ttl', async () => { + const clock = sinon.useFakeTimers(); + + const eventStream = new EventStreamEmitter({ messageStore, didResolver, reauthorizationTTL: 1 }); // every second + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); + + const authorizeSpy = sinon.spy(RecordsSubscriptionHandler.prototype as any, 'reauthorize'); + + const alice = await DidKeyResolver.generate(); + const bob = await DidKeyResolver.generate(); + + // alice writes the friend role protocol + const protocolConf = await TestDataGenerator.generateProtocolsConfigure({ + author : alice, + published : true, + protocolDefinition : { ...friendRole } + }); + const proto = protocolConf.message.descriptor.definition.protocol; + const protoConfResponse = await dwn.processMessage(alice.did, protocolConf.message); + expect(protoConfResponse.status.code).equals(202); + + // alice adds bob as a friend. + const bobFriend = await TestDataGenerator.generateRecordsWrite({ + author : alice, + recipient : bob.did, + protocol : proto, + protocolPath : 'friend', + }); + const bobFriendReply = await dwn.processMessage(alice.did, bobFriend.message, bobFriend.dataStream); + expect(bobFriendReply.status.code).to.equal(202); + + // bob subscribes + const bobSubscribe = await TestDataGenerator.generateRecordsSubscribe({ + author : bob, + filter : { + protocol : proto, + protocolPath : 'chat', + }, + protocolRole: 'friend' + }); + const bobSubscribeReply = await dwn.processMessage(alice.did, bobSubscribe.message); + expect(bobSubscribeReply.status.code).to.equal(200); + + // capture the messageCids from the subscription + const messageCids: string[] = []; + const captureFunction = async (message: RecordsWriteMessage | RecordsDeleteMessage):Promise => { + const messageCid = await Message.getCid(message); + messageCids.push(messageCid); + }; + bobSubscribeReply.subscription!.on(captureFunction); + + //write some chat messages + const aliceMessage1 = await TestDataGenerator.generateRecordsWrite({ + author : alice, + protocol : proto, + protocolPath : 'chat' + }); + const aliceMessage1Cid = await Message.getCid(aliceMessage1.message); + const aliceMessage1Reply = await dwn.processMessage(alice.did, aliceMessage1.message, aliceMessage1.dataStream); + expect(aliceMessage1Reply.status.code).to.equal(202); + + const aliceMessage2 = await TestDataGenerator.generateRecordsWrite({ + author : alice, + protocol : proto, + protocolPath : 'chat' + }); + const aliceMessage2Cid = await Message.getCid(aliceMessage2.message); + const aliceMessage2Reply = await dwn.processMessage(alice.did, aliceMessage2.message, aliceMessage2.dataStream); + expect(aliceMessage2Reply.status.code).to.equal(202); + + await clock.nextAsync(); + + expect(authorizeSpy.callCount).to.equal(0, 'reauthorize'); // has not reached TTL yet + expect(messageCids.length).to.equal(2, 'messageCids'); + expect(messageCids).to.have.members([ aliceMessage1Cid, aliceMessage2Cid ]); + + await clock.tickAsync(1000); + + const aliceMessage3 = await TestDataGenerator.generateRecordsWrite({ + author : alice, + protocol : proto, + protocolPath : 'chat' + }); + const aliceMessage3Cid = await Message.getCid(aliceMessage3.message); + const aliceMessage3Reply = await dwn.processMessage(alice.did, aliceMessage3.message, aliceMessage3.dataStream); + expect(aliceMessage3Reply.status.code).to.equal(202); + + authorizeSpy.restore(); + clock.restore(); + + await Time.minimalSleep(); + + expect(authorizeSpy.callCount).to.equal(1, 'reauthorize'); // called once after the TTL has passed + expect(messageCids.length).to.equal(3, 'messageCids'); + expect(messageCids).to.have.members([ aliceMessage1Cid, aliceMessage2Cid, aliceMessage3Cid ]); + + }); + + it('no longer sends to subscription handler if subscription becomes un-authorized', async () => { + const eventStream = new EventStreamEmitter({ messageStore, didResolver, reauthorizationTTL: -1 }); // reauthorize with each event + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); + + const alice = await DidKeyResolver.generate(); + const bob = await DidKeyResolver.generate(); + + // spy on subscription close to test for + const subscriptionCloseSpy = sinon.spy(RecordsSubscriptionHandler.prototype, 'close'); + + // alice writes the friend role protocol + const protocolConf = await TestDataGenerator.generateProtocolsConfigure({ + author : alice, + published : true, + protocolDefinition : { ...friendRole } + }); + const proto = protocolConf.message.descriptor.definition.protocol; + const protoConfResponse = await dwn.processMessage(alice.did, protocolConf.message); + expect(protoConfResponse.status.code).equals(202); + + // alice adds bob as a friend. + const bobFriend = await TestDataGenerator.generateRecordsWrite({ + author : alice, + recipient : bob.did, + protocol : proto, + protocolPath : 'friend', + }); + const bobFriendReply = await dwn.processMessage(alice.did, bobFriend.message, bobFriend.dataStream); + expect(bobFriendReply.status.code).to.equal(202); + + // bob subscribes + const bobSubscribe = await TestDataGenerator.generateRecordsSubscribe({ + author : bob, + filter : { + protocol : proto, + protocolPath : 'chat', + }, + protocolRole: 'friend' + }); + const bobSubscribeReply = await dwn.processMessage(alice.did, bobSubscribe.message); + expect(bobSubscribeReply.status.code).to.equal(200); + + + // capture the messageCids from the subscription + const messageCids: string[] = []; + const captureFunction = async (message: RecordsWriteMessage | RecordsDeleteMessage):Promise => { + const messageCid = await Message.getCid(message); + messageCids.push(messageCid); + }; + bobSubscribeReply.subscription!.on(captureFunction); + + //write a chat messages + const aliceMessage1 = await TestDataGenerator.generateRecordsWrite({ + author : alice, + protocol : proto, + protocolPath : 'chat' + }); + const aliceMessage1Cid = await Message.getCid(aliceMessage1.message); + const aliceMessage1Reply = await dwn.processMessage(alice.did, aliceMessage1.message, aliceMessage1.dataStream); + expect(aliceMessage1Reply.status.code).to.equal(202); + + // delete friend role + const deleteBobFriend = await TestDataGenerator.generateRecordsDelete({ + author : alice, + recordId : bobFriend.message.recordId, + }); + const deleteBobFriendReply = await dwn.processMessage(alice.did, deleteBobFriend.message); + expect(deleteBobFriendReply.status.code).to.equal(202); + + const aliceMessage2 = await TestDataGenerator.generateRecordsWrite({ + author : alice, + protocol : proto, + protocolPath : 'chat' + }); + const aliceMessage2Reply = await dwn.processMessage(alice.did, aliceMessage2.message, aliceMessage2.dataStream); + expect(aliceMessage2Reply.status.code).to.equal(202); + + const aliceMessage3 = await TestDataGenerator.generateRecordsWrite({ + author : alice, + protocol : proto, + protocolPath : 'chat' + }); + const aliceMessage3Reply = await dwn.processMessage(alice.did, aliceMessage3.message, aliceMessage3.dataStream); + expect(aliceMessage3Reply.status.code).to.equal(202); + + await Time.minimalSleep(); + + expect(messageCids.length).to.equal(1, 'messageCids'); + expect(messageCids).to.have.members([ aliceMessage1Cid ]); + expect(subscriptionCloseSpy.called).to.be.true; + }); + }); + }); +} \ No newline at end of file diff --git a/tests/store/message-store.spec.ts b/tests/store/message-store.spec.ts index a0d1fbc2c..6860e6ebe 100644 --- a/tests/store/message-store.spec.ts +++ b/tests/store/message-store.spec.ts @@ -260,7 +260,7 @@ export function testMessageStore(): void { messageTimestamp: TestDataGenerator.randomTimestamp() }))); for (const message of messages) { - await messageStore.put(alice.did, message.message, await message.recordsWrite.constructRecordsWriteIndexes(true)); + await messageStore.put(alice.did, message.message, await message.recordsWrite.constructIndexes(true)); } const { messages: messageQuery } = await messageStore.query(alice.did, [{}]); @@ -280,7 +280,7 @@ export function testMessageStore(): void { messageTimestamp: TestDataGenerator.randomTimestamp() }))); for (const message of messages) { - await messageStore.put(alice.did, message.message, await message.recordsWrite.constructRecordsWriteIndexes(true)); + await messageStore.put(alice.did, message.message, await message.recordsWrite.constructIndexes(true)); } const { messages: messageQuery } = await messageStore.query(alice.did, [{}], { messageTimestamp: SortDirection.Ascending }); expect(messageQuery.length).to.equal(messages.length); @@ -298,7 +298,7 @@ export function testMessageStore(): void { dateCreated: TestDataGenerator.randomTimestamp(), }))); for (const message of messages) { - await messageStore.put(alice.did, message.message, await message.recordsWrite.constructRecordsWriteIndexes(true)); + await messageStore.put(alice.did, message.message, await message.recordsWrite.constructIndexes(true)); } const { messages: messageQuery } = await messageStore.query(alice.did, [{}], { dateCreated: SortDirection.Ascending }); @@ -318,7 +318,7 @@ export function testMessageStore(): void { dateCreated: TestDataGenerator.randomTimestamp(), }))); for (const message of messages) { - await messageStore.put(alice.did, message.message, await message.recordsWrite.constructRecordsWriteIndexes(true)); + await messageStore.put(alice.did, message.message, await message.recordsWrite.constructIndexes(true)); } const { messages: messageQuery } = await messageStore.query(alice.did, [{}], { dateCreated: SortDirection.Descending }); @@ -339,7 +339,7 @@ export function testMessageStore(): void { datePublished : TestDataGenerator.randomTimestamp() }))); for (const message of messages) { - await messageStore.put(alice.did, message.message, await message.recordsWrite.constructRecordsWriteIndexes(true)); + await messageStore.put(alice.did, message.message, await message.recordsWrite.constructIndexes(true)); } const { messages: messageQuery } = await messageStore.query(alice.did, [{}], { datePublished: SortDirection.Ascending }); @@ -360,7 +360,7 @@ export function testMessageStore(): void { datePublished : TestDataGenerator.randomTimestamp() }))); for (const message of messages) { - await messageStore.put(alice.did, message.message, await message.recordsWrite.constructRecordsWriteIndexes(true)); + await messageStore.put(alice.did, message.message, await message.recordsWrite.constructIndexes(true)); } const { messages: messageQuery } = await messageStore.query(alice.did, [{}], { datePublished: SortDirection.Descending }); @@ -382,7 +382,7 @@ export function testMessageStore(): void { messageTimestamp: TestDataGenerator.randomTimestamp() }))); for (const message of messages) { - await messageStore.put(alice.did, message.message, await message.recordsWrite.constructRecordsWriteIndexes(true)); + await messageStore.put(alice.did, message.message, await message.recordsWrite.constructIndexes(true)); } const { messages: limitQuery } = await messageStore.query(alice.did, [{}]); @@ -395,7 +395,7 @@ export function testMessageStore(): void { messageTimestamp: TestDataGenerator.randomTimestamp() }))); for (const message of messages) { - await messageStore.put(alice.did, message.message, await message.recordsWrite.constructRecordsWriteIndexes(true)); + await messageStore.put(alice.did, message.message, await message.recordsWrite.constructIndexes(true)); } const sortedRecords = messages.sort((a,b) => @@ -416,7 +416,7 @@ export function testMessageStore(): void { messageTimestamp: TestDataGenerator.randomTimestamp() }))); for (const message of messages) { - await messageStore.put(alice.did, message.message, await message.recordsWrite.constructRecordsWriteIndexes(true)); + await messageStore.put(alice.did, message.message, await message.recordsWrite.constructIndexes(true)); } // get all of the records @@ -434,7 +434,7 @@ export function testMessageStore(): void { messageTimestamp: TestDataGenerator.randomTimestamp() }))); for (const message of messages) { - await messageStore.put(alice.did, message.message, await message.recordsWrite.constructRecordsWriteIndexes(true)); + await messageStore.put(alice.did, message.message, await message.recordsWrite.constructIndexes(true)); } const sortedRecords = messages.sort((a,b) => @@ -457,7 +457,7 @@ export function testMessageStore(): void { messageTimestamp: TestDataGenerator.randomTimestamp() }))); for (const message of messages) { - await messageStore.put(alice.did, message.message, await message.recordsWrite.constructRecordsWriteIndexes(true)); + await messageStore.put(alice.did, message.message, await message.recordsWrite.constructIndexes(true)); } const sortedRecords = messages.sort((a,b) => @@ -481,7 +481,7 @@ export function testMessageStore(): void { messageTimestamp: TestDataGenerator.randomTimestamp() }))); for (const message of messages) { - await messageStore.put(alice.did, message.message, await message.recordsWrite.constructRecordsWriteIndexes(true)); + await messageStore.put(alice.did, message.message, await message.recordsWrite.constructIndexes(true)); } const limit = 6; diff --git a/tests/test-stores.ts b/tests/test-stores.ts index db4dfb074..5761c3f9c 100644 --- a/tests/test-stores.ts +++ b/tests/test-stores.ts @@ -43,7 +43,7 @@ export class TestStores { return { messageStore : TestStores.messageStore, dataStore : TestStores.dataStore, - eventLog : TestStores.eventLog + eventLog : TestStores.eventLog, }; } } \ No newline at end of file diff --git a/tests/test-suite.ts b/tests/test-suite.ts index 21c373709..1e2d51cb3 100644 --- a/tests/test-suite.ts +++ b/tests/test-suite.ts @@ -7,6 +7,7 @@ import { testEventLog } from './event-log/event-log.spec.js'; import { testEventsGetHandler } from './handlers/events-get.spec.js'; import { testEventsQueryHandler } from './handlers/events-query.spec.js'; import { testEventsQueryScenarios } from './scenarios/events-query.spec.js'; +import { testEventsSubscribeHandler } from './handlers/events-subscribe.spec.js'; import { testMessagesGetHandler } from './handlers/messages-get.spec.js'; import { testMessageStore } from './store/message-store.spec.js'; import { testPermissionsGrantHandler } from './handlers/permissions-grant.spec.js'; @@ -16,8 +17,10 @@ import { testProtocolsQueryHandler } from './handlers/protocols-query.spec.js'; import { testRecordsDeleteHandler } from './handlers/records-delete.spec.js'; import { testRecordsQueryHandler } from './handlers/records-query.spec.js'; import { testRecordsReadHandler } from './handlers/records-read.spec.js'; +import { testRecordsSubscribeHandler } from './handlers/records-subscribe.spec.js'; import { testRecordsWriteHandler } from './handlers/records-write.spec.js'; import { TestStores } from './test-stores.js'; +import { testSubscriptionScenarios } from './scenarios/subscriptions.spec.js'; /** * Class for running DWN tests from an external repository that depends on this SDK. @@ -40,6 +43,7 @@ export class TestSuite { // handler tests testEventsGetHandler(); + testEventsSubscribeHandler(); testEventsQueryHandler(); testMessagesGetHandler(); testPermissionsGrantHandler(); @@ -49,11 +53,13 @@ export class TestSuite { testRecordsDeleteHandler(); testRecordsQueryHandler(); testRecordsReadHandler(); + testRecordsSubscribeHandler(); testRecordsWriteHandler(); // scenario tests testDelegatedGrantScenarios(); testEndToEndScenarios(); testEventsQueryScenarios(); + testSubscriptionScenarios(); } } \ No newline at end of file diff --git a/tests/utils/test-data-generator.ts b/tests/utils/test-data-generator.ts index 0f6f1a9a9..e203d7818 100644 --- a/tests/utils/test-data-generator.ts +++ b/tests/utils/test-data-generator.ts @@ -2,6 +2,7 @@ import type { DerivedPrivateJwk } from '../../src/utils/hd-key.js'; import type { DidResolutionResult } from '../../src/types/did-types.js'; import type { EventsGetOptions } from '../../src/interfaces/events-get.js'; import type { EventsQueryOptions } from '../../src/interfaces/events-query.js'; +import type { EventsSubscribeOptions } from '../../src/interfaces/events-subscribe.js'; import type { GeneralJws } from '../../src/types/jws-types.js'; import type { MessagesGetMessage } from '../../src/types/messages-types.js'; import type { MessagesGetOptions } from '../../src/interfaces/messages-get.js'; @@ -10,16 +11,17 @@ import type { ProtocolsConfigureOptions } from '../../src/interfaces/protocols-c import type { ProtocolsQueryOptions } from '../../src/interfaces/protocols-query.js'; import type { Readable } from 'readable-stream'; import type { RecordsQueryOptions } from '../../src/interfaces/records-query.js'; -import type { RecordsWriteMessage } from '../../src/types/records-types.js'; +import type { RecordsSubscribeOptions } from '../../src/interfaces/records-subscribe.js'; import type { Signer } from '../../src/types/signer.js'; import type { AuthorizationModel, Pagination } from '../../src/types/message-types.js'; import type { CreateFromOptions, EncryptionInput, KeyEncryptionInput, RecordsWriteOptions } from '../../src/interfaces/records-write.js'; import type { DateSort, RecordsDeleteMessage, RecordsFilter, RecordsQueryMessage } from '../../src/types/records-types.js'; -import type { EventsGetMessage, EventsQueryFilter, EventsQueryMessage } from '../../src/types/event-types.js'; +import type { EventsFilter, EventsGetMessage, EventsQueryMessage, EventsSubscribeMessage } from '../../src/types/event-types.js'; import type { PermissionConditions, PermissionScope } from '../../src/types/permissions-grant-descriptor.js'; import type { PermissionsGrantMessage, PermissionsRequestMessage, PermissionsRevokeMessage } from '../../src/types/permissions-types.js'; import type { PrivateJwk, PublicJwk } from '../../src/types/jose-types.js'; import type { ProtocolDefinition, ProtocolsConfigureMessage, ProtocolsQueryMessage } from '../../src/types/protocols-types.js'; +import type { RecordsSubscribeMessage, RecordsWriteMessage } from '../../src/types/records-types.js'; import * as cbor from '@ipld/dag-cbor'; @@ -29,6 +31,7 @@ import { DidKeyResolver } from '../../src/did/did-key-resolver.js'; import { Encryption } from '../../src/utils/encryption.js'; import { EventsGet } from '../../src/interfaces/events-get.js'; import { EventsQuery } from '../../src/interfaces/events-query.js'; +import { EventsSubscribe } from '../../src/interfaces/events-subscribe.js'; import { Jws } from '../../src/utils/jws.js'; import { MessagesGet } from '../../src/interfaces/messages-get.js'; import { PermissionsGrant } from '../../src/interfaces/permissions-grant.js'; @@ -40,6 +43,7 @@ import { ProtocolsQuery } from '../../src/interfaces/protocols-query.js'; import { Records } from '../../src/utils/records.js'; import { RecordsDelete } from '../../src/interfaces/records-delete.js'; import { RecordsQuery } from '../../src/interfaces/records-query.js'; +import { RecordsSubscribe } from '../../src/interfaces/records-subscribe.js'; import { RecordsWrite } from '../../src/interfaces/records-write.js'; import { removeUndefinedProperties } from '../../src/utils/object.js'; import { Secp256k1 } from '../../src/utils/secp256k1.js'; @@ -164,6 +168,22 @@ export type GenerateRecordsQueryOutput = { message: RecordsQueryMessage; }; +export type GenerateRecordsSubscribeInput = { + /** + * Treated as `false` if not given. + */ + anonymous?: boolean; + author?: Persona; + messageTimestamp?: string; + filter?: RecordsFilter; + protocolRole?: string; +}; + +export type GenerateRecordsSubscribeOutput = { + author: Persona | undefined; + message: RecordsSubscribeMessage; +}; + export type GenerateRecordsDeleteInput = { author?: Persona; recordId?: string; @@ -237,7 +257,7 @@ export type GenerateEventsGetOutput = { export type GenerateEventsQueryInput = { author?: Persona; - filters: EventsQueryFilter[]; + filters: EventsFilter[]; cursor?: PaginationCursor; }; @@ -247,6 +267,17 @@ export type GenerateEventsQueryOutput = { message: EventsQueryMessage; }; +export type GenerateEventsSubscribeInput = { + author: Persona; + filters: EventsFilter[]; +}; + +export type GenerateEventsSubscribeOutput = { + author: Persona; + eventsSubscribe: EventsSubscribe; + message: EventsSubscribeMessage; +}; + export type GenerateMessagesGetInput = { author?: Persona; messageCids: string[] @@ -638,6 +669,44 @@ export class TestDataGenerator { }; }; + /** + * Generates a RecordsSubscribe message for testing. + */ + public static async generateRecordsSubscribe(input?: GenerateRecordsSubscribeInput): Promise { + let author = input?.author; + const anonymous: boolean = input?.anonymous ?? false; + + if (anonymous && author) { + throw new Error('Cannot have `author` and be anonymous at the same time.'); + } + + // generate author if needed + if (author === undefined && !anonymous) { + author = await TestDataGenerator.generatePersona(); + } + + let signer = undefined; + if (author !== undefined) { + signer = Jws.createSigner(author); + } + + const options: RecordsSubscribeOptions = { + messageTimestamp : input?.messageTimestamp, + signer, + filter : input?.filter ?? { schema: TestDataGenerator.randomString(10) }, // must have one filter property if no filter is given + protocolRole : input?.protocolRole, + }; + removeUndefinedProperties(options); + + const recordsSubscribe = await RecordsSubscribe.create(options); + const message = recordsSubscribe.message; + + return { + author, + message + }; + } + /** * Generates a RecordsDelete for testing. */ @@ -766,6 +835,29 @@ export class TestDataGenerator { }; } + /** + * Generates a EventsSubscribe message for testing. + */ + public static async generateEventsSubscribe(input?: GenerateEventsSubscribeInput): Promise { + const author = input?.author ?? await TestDataGenerator.generatePersona(); + const signer = Jws.createSigner(author); + + const options: EventsSubscribeOptions = { + filters: input?.filters, + signer, + }; + removeUndefinedProperties(options); + + const eventsSubscribe = await EventsSubscribe.create(options); + const message = eventsSubscribe.message; + + return { + author, + eventsSubscribe, + message + }; + } + public static async generateMessagesGet(input: GenerateMessagesGetInput): Promise { const author = input?.author ?? await TestDataGenerator.generatePersona(); const signer = Jws.createSigner(author); diff --git a/tests/vectors/protocol-definitions/friend-role.json b/tests/vectors/protocol-definitions/friend-role.json index fc8e650ab..88ac3901c 100644 --- a/tests/vectors/protocol-definitions/friend-role.json +++ b/tests/vectors/protocol-definitions/friend-role.json @@ -34,6 +34,10 @@ "role": "friend", "can": "query" }, + { + "role": "friend", + "can": "subscribe" + }, { "role": "admin", "can": "update" diff --git a/tests/vectors/protocol-definitions/thread-role.json b/tests/vectors/protocol-definitions/thread-role.json index 88ac9842d..ce2a07d72 100644 --- a/tests/vectors/protocol-definitions/thread-role.json +++ b/tests/vectors/protocol-definitions/thread-role.json @@ -49,6 +49,10 @@ "role": "thread/participant", "can": "query" }, + { + "role": "thread/participant", + "can": "subscribe" + }, { "role": "thread/admin", "can": "update" From b5c904e9ec2f0743ded017de339cb312138ee711 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Thu, 21 Dec 2023 15:46:29 -0500 Subject: [PATCH 02/44] slight refactor --- src/dwn.ts | 4 ++-- src/event-log/event-log-level.ts | 2 +- src/event-log/event-stream.ts | 12 +++++------ src/event-log/subscription.ts | 8 ++++---- src/handlers/events-get.ts | 2 +- src/handlers/events-query.ts | 2 +- src/handlers/events-subscribe.ts | 4 ++-- src/handlers/permissions-grant.ts | 2 +- src/handlers/permissions-request.ts | 2 +- src/handlers/permissions-revoke.ts | 2 +- src/handlers/protocols-configure.ts | 2 +- src/handlers/records-delete.ts | 2 +- src/handlers/records-subscribe.ts | 2 +- src/handlers/records-write.ts | 2 +- src/index.ts | 4 ++-- src/interfaces/events-get.ts | 2 +- src/interfaces/events-query.ts | 2 +- src/interfaces/events-subscribe.ts | 2 +- src/types/{event-types.ts => events-types.ts} | 8 ++++---- .../{event-stream.ts => subscriptions.ts} | 20 +++++++++++-------- tests/dwn.spec.ts | 2 +- tests/handlers/events-get.spec.ts | 2 +- tests/handlers/events-subscribe.spec.ts | 2 +- tests/handlers/messages-get.spec.ts | 2 +- tests/handlers/permissions-grant.spec.ts | 2 +- tests/handlers/permissions-request.spec.ts | 2 +- tests/handlers/protocols-configure.spec.ts | 2 +- tests/handlers/protocols-query.spec.ts | 2 +- tests/handlers/records-delete.spec.ts | 2 +- tests/handlers/records-query.spec.ts | 2 +- tests/handlers/records-read.spec.ts | 2 +- tests/handlers/records-subscribe.spec.ts | 2 +- tests/handlers/records-write.spec.ts | 2 +- tests/interfaces/events-query.spec.ts | 2 +- tests/scenarios/delegated-grant.spec.ts | 2 +- tests/scenarios/end-to-end-tests.spec.ts | 2 +- tests/utils/test-data-generator.ts | 2 +- 37 files changed, 62 insertions(+), 58 deletions(-) rename src/types/{event-types.ts => events-types.ts} (92%) rename src/types/{event-stream.ts => subscriptions.ts} (55%) diff --git a/src/dwn.ts b/src/dwn.ts index d3d50fce8..15bb80cdc 100644 --- a/src/dwn.ts +++ b/src/dwn.ts @@ -1,11 +1,11 @@ import type { DataStore } from './types/data-store.js'; import type { EventLog } from './types/event-log.js'; -import type { EventStream } from './types/event-stream.js'; +import type { EventStream } from './types/subscriptions.js'; import type { MessageStore } from './types/message-store.js'; import type { MethodHandler } from './types/method-handler.js'; import type { TenantGate } from './core/tenant-gate.js'; import type { UnionMessageReply } from './core/message-reply.js'; -import type { EventsGetMessage, EventsGetReply, EventsQueryMessage, EventsQueryReply, EventsSubscribeMessage, EventsSubscribeReply } from './types/event-types.js'; +import type { EventsGetMessage, EventsGetReply, EventsQueryMessage, EventsQueryReply, EventsSubscribeMessage, EventsSubscribeReply } from './types/events-types.js'; import type { GenericMessage, GenericMessageReply, MessageOptions } from './types/message-types.js'; import type { MessagesGetMessage, MessagesGetReply } from './types/messages-types.js'; import type { PermissionsGrantMessage, PermissionsRequestMessage, PermissionsRevokeMessage } from './types/permissions-types.js'; diff --git a/src/event-log/event-log-level.ts b/src/event-log/event-log-level.ts index d1d548113..581b1bc46 100644 --- a/src/event-log/event-log-level.ts +++ b/src/event-log/event-log-level.ts @@ -1,5 +1,5 @@ import type { EventLog } from '../types/event-log.js'; -import type { EventStream } from '../types/event-stream.js'; +import type { EventStream } from '../types/subscriptions.js'; import type { ULIDFactory } from 'ulidx'; import type { Filter, KeyValues, PaginationCursor } from '../types/query-types.js'; diff --git a/src/event-log/event-stream.ts b/src/event-log/event-stream.ts index c34278bab..f48338e59 100644 --- a/src/event-log/event-stream.ts +++ b/src/event-log/event-stream.ts @@ -1,9 +1,9 @@ import type { DidResolver } from '../did/did-resolver.js'; -import type { GenericMessage } from '../types/message-types.js'; import type { MessageStore } from '../types/message-store.js'; -import type { EventsSubscribeMessage, EventSubscription } from '../types/event-types.js'; -import type { EventStream, EventStreamSubscription } from '../types/event-stream.js'; +import type { EventsSubscribeMessage, EventsSubscription } from '../types/events-types.js'; +import type { EventStream, Subscription } from '../types/subscriptions.js'; import type { Filter, KeyValues } from '../types/query-types.js'; +import type { GenericMessage, GenericMessageSubscription } from '../types/message-types.js'; import type { RecordsSubscribeMessage, RecordsSubscription } from '../types/records-types.js'; import { EventEmitter } from 'events'; @@ -30,7 +30,7 @@ export class EventStreamEmitter implements EventStream { private reauthorizationTTL: number; private isOpen: boolean = false; - private subscriptions: Map = new Map(); + private subscriptions: Map = new Map(); constructor(config: EventStreamConfig) { this.didResolver = config.didResolver; @@ -49,9 +49,9 @@ export class EventStreamEmitter implements EventStream { console.error('event emitter error', error); }; - async subscribe(tenant: string, message: EventsSubscribeMessage, filters: Filter[]): Promise; + async subscribe(tenant: string, message: EventsSubscribeMessage, filters: Filter[]): Promise; async subscribe(tenant: string, message: RecordsSubscribeMessage, filters: Filter[]): Promise; - async subscribe(tenant: string, message: GenericMessage, filters: Filter[]): Promise { + async subscribe(tenant: string, message: GenericMessage, filters: Filter[]): Promise { const messageCid = await Message.getCid(message); let subscription = this.subscriptions.get(messageCid); if (subscription !== undefined) { diff --git a/src/event-log/subscription.ts b/src/event-log/subscription.ts index 61dd19b53..c2c0c5ba2 100644 --- a/src/event-log/subscription.ts +++ b/src/event-log/subscription.ts @@ -1,12 +1,12 @@ import type { EventEmitter } from 'events'; -import type { EventHandler } from '../types/event-types.js'; -import type { GenericMessage } from '../types/message-types.js'; import type { MessageStore } from '../types/message-store.js'; +import type { Subscription } from '../types/subscriptions.js'; import type { Filter, KeyValues } from '../types/query-types.js'; +import type { GenericMessage, GenericMessageHandler } from '../types/message-types.js'; import { FilterUtility } from '../utils/filter.js'; -export class SubscriptionBase { +export class SubscriptionBase implements Subscription { protected eventEmitter: EventEmitter; protected messageStore: MessageStore; protected filters: Filter[]; @@ -56,7 +56,7 @@ export class SubscriptionBase { } }; - on(handler: EventHandler): { off: () => void } { + on(handler: GenericMessageHandler): { off: () => void } { this.eventEmitter.on(this.eventChannel, handler); return { off: (): void => { diff --git a/src/handlers/events-get.ts b/src/handlers/events-get.ts index 25b4084c0..31f459061 100644 --- a/src/handlers/events-get.ts +++ b/src/handlers/events-get.ts @@ -1,7 +1,7 @@ import type { DidResolver } from '../did/did-resolver.js'; import type { EventLog } from '../types/event-log.js'; import type { MethodHandler } from '../types/method-handler.js'; -import type { EventsGetMessage, EventsGetReply } from '../types/event-types.js'; +import type { EventsGetMessage, EventsGetReply } from '../types/events-types.js'; import { EventsGet } from '../interfaces/events-get.js'; import { messageReplyFromError } from '../core/message-reply.js'; diff --git a/src/handlers/events-query.ts b/src/handlers/events-query.ts index 6d5974d62..6e7f38eb1 100644 --- a/src/handlers/events-query.ts +++ b/src/handlers/events-query.ts @@ -1,7 +1,7 @@ import type { DidResolver } from '../did/did-resolver.js'; import type { EventLog } from '../types/event-log.js'; import type { MethodHandler } from '../types/method-handler.js'; -import type { EventsQueryMessage, EventsQueryReply } from '../types/event-types.js'; +import type { EventsQueryMessage, EventsQueryReply } from '../types/events-types.js'; import { EventsQuery } from '../interfaces/events-query.js'; import { messageReplyFromError } from '../core/message-reply.js'; diff --git a/src/handlers/events-subscribe.ts b/src/handlers/events-subscribe.ts index 3c843b988..522bc4a08 100644 --- a/src/handlers/events-subscribe.ts +++ b/src/handlers/events-subscribe.ts @@ -1,10 +1,10 @@ import type { DidResolver } from '../did/did-resolver.js'; import type EventEmitter from 'events'; -import type { EventStream } from '../types/event-stream.js'; +import type { EventStream } from '../types/subscriptions.js'; import type { Filter } from '../types/query-types.js'; import type { MessageStore } from '../types/message-store.js'; import type { MethodHandler } from '../types/method-handler.js'; -import type { EventsSubscribeMessage, EventsSubscribeReply } from '../types/event-types.js'; +import type { EventsSubscribeMessage, EventsSubscribeReply } from '../types/events-types.js'; import { EventsSubscribe } from '../interfaces/events-subscribe.js'; import { Message } from '../core/message.js'; diff --git a/src/handlers/permissions-grant.ts b/src/handlers/permissions-grant.ts index 8ed205d7b..59394010b 100644 --- a/src/handlers/permissions-grant.ts +++ b/src/handlers/permissions-grant.ts @@ -1,6 +1,6 @@ import type { DidResolver } from '../did/did-resolver.js'; import type { EventLog } from '../types//event-log.js'; -import type { EventStream } from '../types/event-stream.js'; +import type { EventStream } from '../types/subscriptions.js'; import type { GenericMessageReply } from '../types/message-types.js'; import type { KeyValues } from '../types/query-types.js'; import type { MessageStore } from '../types//message-store.js'; diff --git a/src/handlers/permissions-request.ts b/src/handlers/permissions-request.ts index 1c3ddb013..7cb1d9468 100644 --- a/src/handlers/permissions-request.ts +++ b/src/handlers/permissions-request.ts @@ -1,6 +1,6 @@ import type { DidResolver } from '../did/did-resolver.js'; import type { EventLog } from '../types//event-log.js'; -import type { EventStream } from '../types/event-stream.js'; +import type { EventStream } from '../types/subscriptions.js'; import type { GenericMessageReply } from '../types/message-types.js'; import type { MessageStore } from '../types//message-store.js'; import type { MethodHandler } from '../types/method-handler.js'; diff --git a/src/handlers/permissions-revoke.ts b/src/handlers/permissions-revoke.ts index c31d9772e..a529b5b2e 100644 --- a/src/handlers/permissions-revoke.ts +++ b/src/handlers/permissions-revoke.ts @@ -1,6 +1,6 @@ import type { DidResolver } from '../did/did-resolver.js'; import type { EventLog } from '../types/event-log.js'; -import type { EventStream } from '../types/event-stream.js'; +import type { EventStream } from '../types/subscriptions.js'; import type { GenericMessageReply } from '../types/message-types.js'; import type { KeyValues } from '../types/query-types.js'; import type { MessageStore } from '../types/message-store.js'; diff --git a/src/handlers/protocols-configure.ts b/src/handlers/protocols-configure.ts index a0eb5b3de..573cda1ec 100644 --- a/src/handlers/protocols-configure.ts +++ b/src/handlers/protocols-configure.ts @@ -1,6 +1,6 @@ import type { DidResolver } from '../did/did-resolver.js'; import type { EventLog } from '../types/event-log.js'; -import type { EventStream } from '../types/event-stream.js'; +import type { EventStream } from '../types/subscriptions.js'; import type { GenericMessageReply } from '../types/message-types.js'; import type { MessageStore } from '../types//message-store.js'; import type { MethodHandler } from '../types/method-handler.js'; diff --git a/src/handlers/records-delete.ts b/src/handlers/records-delete.ts index 5168cfc81..f83822b02 100644 --- a/src/handlers/records-delete.ts +++ b/src/handlers/records-delete.ts @@ -1,7 +1,7 @@ import type { DataStore } from '../types/data-store.js'; import type { DidResolver } from '../did/did-resolver.js'; import type { EventLog } from '../types/event-log.js'; -import type { EventStream } from '../types/event-stream.js'; +import type { EventStream } from '../types/subscriptions.js'; import type { GenericMessageReply } from '../types/message-types.js'; import type { MessageStore } from '../types//message-store.js'; import type { MethodHandler } from '../types/method-handler.js'; diff --git a/src/handlers/records-subscribe.ts b/src/handlers/records-subscribe.ts index fa14e3f50..5ccdee7a4 100644 --- a/src/handlers/records-subscribe.ts +++ b/src/handlers/records-subscribe.ts @@ -1,6 +1,6 @@ import type { DidResolver } from '../did/did-resolver.js'; import type EventEmitter from 'events'; -import type { EventStream } from '../types/event-stream.js'; +import type { EventStream } from '../types/subscriptions.js'; import type { GenericMessage } from '../types/message-types.js'; import type { MessageStore } from '../types//message-store.js'; import type { MethodHandler } from '../types/method-handler.js'; diff --git a/src/handlers/records-write.ts b/src/handlers/records-write.ts index 899d2c690..b88ce2918 100644 --- a/src/handlers/records-write.ts +++ b/src/handlers/records-write.ts @@ -1,7 +1,7 @@ import type { DataStore } from '../types/data-store.js'; import type { DidResolver } from '../did/did-resolver.js'; import type { EventLog } from '../types/event-log.js'; -import type { EventStream } from '../types/event-stream.js'; +import type { EventStream } from '../types/subscriptions.js'; import type { GenericMessageReply } from '../types/message-types.js'; import type { MessageStore } from '../types//message-store.js'; import type { MethodHandler } from '../types/method-handler.js'; diff --git a/src/index.ts b/src/index.ts index 15e2a2ac4..5c7c78b02 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,8 +2,8 @@ export type { DwnConfig } from './dwn.js'; export type { DidMethodResolver, DwnServiceEndpoint, ServiceEndpoint, DidDocument, DidResolutionResult, DidResolutionMetadata, DidDocumentMetadata, VerificationMethod } from './types/did-types.js'; export type { EventLog } from './types/event-log.js'; -export type { EventStream } from './types/event-stream.js'; -export type { EventsGetMessage, EventsGetReply, EventsQueryMessage, EventsQueryReply, EventsSubscribeDescriptor, EventsSubscribeMessage, EventsSubscribeReply, EventSubscription } from './types/event-types.js'; +export type { EventsGetMessage, EventsGetReply, EventsQueryMessage, EventsQueryReply, EventsSubscribeDescriptor, EventsSubscribeMessage, EventsSubscribeReply } from './types/events-types.js'; +export type { EventStream, SubscriptionReply } from './types/subscriptions.js'; export type { GenericMessage, GenericMessageReply, MessageSort, Pagination, QueryResultEntry } from './types/message-types.js'; export type { MessagesGetMessage, MessagesGetReply, MessagesGetReplyEntry } from './types/messages-types.js'; export type { Filter, EqualFilter, OneOfFilter, RangeFilter, RangeCriterion, PaginationCursor, QueryOptions } from './types/query-types.js'; diff --git a/src/interfaces/events-get.ts b/src/interfaces/events-get.ts index 2ddd50bb3..f168268a2 100644 --- a/src/interfaces/events-get.ts +++ b/src/interfaces/events-get.ts @@ -1,6 +1,6 @@ import type { PaginationCursor } from '../types/query-types.js'; import type { Signer } from '../types/signer.js'; -import type { EventsGetDescriptor, EventsGetMessage } from '../types/event-types.js'; +import type { EventsGetDescriptor, EventsGetMessage } from '../types/events-types.js'; import { AbstractMessage } from '../core/abstract-message.js'; import { Message } from '../core/message.js'; diff --git a/src/interfaces/events-query.ts b/src/interfaces/events-query.ts index 5885dce1b..1ee4c30c1 100644 --- a/src/interfaces/events-query.ts +++ b/src/interfaces/events-query.ts @@ -1,6 +1,6 @@ import type { ProtocolsQueryFilter } from '../types/protocols-types.js'; import type { Signer } from '../types/signer.js'; -import type { EventsFilter, EventsMessageFilter, EventsQueryDescriptor, EventsQueryMessage, EventsRecordsFilter } from '../types/event-types.js'; +import type { EventsFilter, EventsMessageFilter, EventsQueryDescriptor, EventsQueryMessage, EventsRecordsFilter } from '../types/events-types.js'; import type { Filter, PaginationCursor } from '../types/query-types.js'; import { AbstractMessage } from '../core/abstract-message.js'; diff --git a/src/interfaces/events-subscribe.ts b/src/interfaces/events-subscribe.ts index e392e22b3..a9b365703 100644 --- a/src/interfaces/events-subscribe.ts +++ b/src/interfaces/events-subscribe.ts @@ -1,6 +1,6 @@ import type { GenericMessage } from '../types/message-types.js'; import type { Signer } from '../types/signer.js'; -import type { EventsFilter, EventsSubscribeDescriptor, EventsSubscribeMessage } from '../types/event-types.js'; +import type { EventsFilter, EventsSubscribeDescriptor, EventsSubscribeMessage } from '../types/events-types.js'; import { AbstractMessage } from '../core/abstract-message.js'; import { Message } from '../core/message.js'; diff --git a/src/types/event-types.ts b/src/types/events-types.ts similarity index 92% rename from src/types/event-types.ts rename to src/types/events-types.ts index c2d1c1a5b..74581eac8 100644 --- a/src/types/event-types.ts +++ b/src/types/events-types.ts @@ -47,16 +47,16 @@ export type EventsSubscribeMessage = { descriptor: EventsSubscribeDescriptor; }; -export type EventHandler = (message: GenericMessage) => void; +export type EventsHandler = (message: GenericMessage) => void; -export type EventSubscription = { +export type EventsSubscription = { id: string; - on: (handler: EventHandler) => { off: () => void }; + on: (handler: EventsHandler) => { off: () => void }; close: () => Promise; }; export type EventsSubscribeReply = GenericMessageReply & { - subscription?: EventSubscription; + subscription?: EventsSubscription; }; export type EventsSubscribeDescriptor = { diff --git a/src/types/event-stream.ts b/src/types/subscriptions.ts similarity index 55% rename from src/types/event-stream.ts rename to src/types/subscriptions.ts index e101efb71..d00f548cf 100644 --- a/src/types/event-stream.ts +++ b/src/types/subscriptions.ts @@ -1,21 +1,25 @@ -import type { GenericMessage } from './message-types.js'; -import type { EventHandler, EventsSubscribeMessage, EventSubscription } from './event-types.js'; +import type { GenericMessageReply } from '../types/message-types.js'; +import type { EventsSubscribeMessage, EventsSubscription } from './events-types.js'; import type { Filter, KeyValues } from './query-types.js'; +import type { GenericMessage, GenericMessageHandler, GenericMessageSubscription } from './message-types.js'; import type { RecordsSubscribeMessage, RecordsSubscription } from './records-types.js'; - export interface EventStream { - subscribe(tenant: string, message: EventsSubscribeMessage, filters: Filter[]): Promise; + subscribe(tenant: string, message: EventsSubscribeMessage, filters: Filter[]): Promise; subscribe(tenant: string, message: RecordsSubscribeMessage, filters: Filter[]): Promise; - subscribe(tenant: string, message: GenericMessage, filters: Filter[]): Promise; + subscribe(tenant: string, message: GenericMessage, filters: Filter[]): Promise; emit(tenant: string, message: GenericMessage, ...matchIndexes: KeyValues[]): void; open(): Promise; close(): Promise; } -export interface EventStreamSubscription { +export interface Subscription { id: string; listener: (tenant: string, message: GenericMessage, ...indexes: KeyValues[]) => void; - on: (handler: EventHandler) => { off: () => void }; + on: (handler: GenericMessageHandler) => { off: () => void }; close: () => Promise; -} \ No newline at end of file +} + +export type SubscriptionReply = GenericMessageReply & { + subscription?: GenericMessageSubscription; +}; \ No newline at end of file diff --git a/tests/dwn.spec.ts b/tests/dwn.spec.ts index 58d285482..6a1b4952c 100644 --- a/tests/dwn.spec.ts +++ b/tests/dwn.spec.ts @@ -1,4 +1,4 @@ -import type { EventStream } from '../src/types/event-stream.js'; +import type { EventStream } from '../src/types/subscriptions.js'; import type { ActiveTenantCheckResult, EventsGetReply, TenantGate } from '../src/index.js'; import type { DataStore, EventLog, MessageStore } from '../src/index.js'; diff --git a/tests/handlers/events-get.spec.ts b/tests/handlers/events-get.spec.ts index 64034c896..0a4aeca24 100644 --- a/tests/handlers/events-get.spec.ts +++ b/tests/handlers/events-get.spec.ts @@ -1,4 +1,4 @@ -import type { EventStream } from '../../src/types/event-stream.js'; +import type { EventStream } from '../../src/types/subscriptions.js'; import type { DataStore, EventLog, diff --git a/tests/handlers/events-subscribe.spec.ts b/tests/handlers/events-subscribe.spec.ts index 72f518e4d..e09e699c1 100644 --- a/tests/handlers/events-subscribe.spec.ts +++ b/tests/handlers/events-subscribe.spec.ts @@ -1,4 +1,4 @@ -import type { EventStream } from '../../src/types/event-stream.js'; +import type { EventStream } from '../../src/types/subscriptions.js'; import type { DataStore, EventLog, GenericMessage, MessageStore } from '../../src/index.js'; import { DidKeyResolver } from '../../src/did/did-key-resolver.js'; diff --git a/tests/handlers/messages-get.spec.ts b/tests/handlers/messages-get.spec.ts index eedbec7c1..d458fee03 100644 --- a/tests/handlers/messages-get.spec.ts +++ b/tests/handlers/messages-get.spec.ts @@ -1,4 +1,4 @@ -import type { EventStream } from '../../src/types/event-stream.js'; +import type { EventStream } from '../../src/types/subscriptions.js'; import type { DataStore, EventLog, diff --git a/tests/handlers/permissions-grant.spec.ts b/tests/handlers/permissions-grant.spec.ts index ed03fe085..459e6dde8 100644 --- a/tests/handlers/permissions-grant.spec.ts +++ b/tests/handlers/permissions-grant.spec.ts @@ -1,4 +1,4 @@ -import type { EventStream } from '../../src/types/event-stream.js'; +import type { EventStream } from '../../src/types/subscriptions.js'; import type { DataStore, EventLog, diff --git a/tests/handlers/permissions-request.spec.ts b/tests/handlers/permissions-request.spec.ts index ab08812c3..0bfe88030 100644 --- a/tests/handlers/permissions-request.spec.ts +++ b/tests/handlers/permissions-request.spec.ts @@ -1,4 +1,4 @@ -import type { EventStream } from '../../src/types/event-stream.js'; +import type { EventStream } from '../../src/types/subscriptions.js'; import type { DataStore, EventLog, diff --git a/tests/handlers/protocols-configure.spec.ts b/tests/handlers/protocols-configure.spec.ts index 0c009e81a..bbd5ee06c 100644 --- a/tests/handlers/protocols-configure.spec.ts +++ b/tests/handlers/protocols-configure.spec.ts @@ -1,5 +1,5 @@ -import type { EventStream } from '../../src/types/event-stream.js'; +import type { EventStream } from '../../src/types/subscriptions.js'; import type { GenerateProtocolsConfigureOutput } from '../utils/test-data-generator.js'; import type { DataStore, diff --git a/tests/handlers/protocols-query.spec.ts b/tests/handlers/protocols-query.spec.ts index c31e3affd..3be0ac829 100644 --- a/tests/handlers/protocols-query.spec.ts +++ b/tests/handlers/protocols-query.spec.ts @@ -1,4 +1,4 @@ -import type { EventStream } from '../../src/types/event-stream.js'; +import type { EventStream } from '../../src/types/subscriptions.js'; import type { DataStore, EventLog, diff --git a/tests/handlers/records-delete.spec.ts b/tests/handlers/records-delete.spec.ts index 3b9a97d00..3b89acd58 100644 --- a/tests/handlers/records-delete.spec.ts +++ b/tests/handlers/records-delete.spec.ts @@ -1,4 +1,4 @@ -import type { EventStream } from '../../src/types/event-stream.js'; +import type { EventStream } from '../../src/types/subscriptions.js'; import type { DataStore, EventLog, diff --git a/tests/handlers/records-query.spec.ts b/tests/handlers/records-query.spec.ts index 8a7ec9657..dbf9a5ee5 100644 --- a/tests/handlers/records-query.spec.ts +++ b/tests/handlers/records-query.spec.ts @@ -1,4 +1,4 @@ -import type { EventStream } from '../../src/types/event-stream.js'; +import type { EventStream } from '../../src/types/subscriptions.js'; import type { DataStore, EventLog, MessageStore } from '../../src/index.js'; import type { GenericMessage, RecordsWriteMessage } from '../../src/index.js'; import type { RecordsQueryReply, RecordsQueryReplyEntry, RecordsWriteDescriptor } from '../../src/types/records-types.js'; diff --git a/tests/handlers/records-read.spec.ts b/tests/handlers/records-read.spec.ts index 94edc8889..be5bdf079 100644 --- a/tests/handlers/records-read.spec.ts +++ b/tests/handlers/records-read.spec.ts @@ -1,6 +1,6 @@ import type { DerivedPrivateJwk } from '../../src/utils/hd-key.js'; import type { EncryptionInput } from '../../src/interfaces/records-write.js'; -import type { EventStream } from '../../src/types/event-stream.js'; +import type { EventStream } from '../../src/types/subscriptions.js'; import type { DataStore, EventLog, MessageStore, ProtocolDefinition, ProtocolsConfigureMessage } from '../../src/index.js'; import { DwnConstant, EventStreamEmitter, Message } from '../../src/index.js'; diff --git a/tests/handlers/records-subscribe.spec.ts b/tests/handlers/records-subscribe.spec.ts index 9e89ae2b9..a07586c21 100644 --- a/tests/handlers/records-subscribe.spec.ts +++ b/tests/handlers/records-subscribe.spec.ts @@ -1,4 +1,4 @@ -import type { EventStream } from '../../src/types/event-stream.js'; +import type { EventStream } from '../../src/types/subscriptions.js'; import type { GenericMessage } from '../../src/types/message-types.js'; import type { DataStore, EventLog, MessageStore, RecordsWriteMessage } from '../../src/index.js'; import type { RecordsDeleteMessage, RecordsFilter } from '../../src/types/records-types.js'; diff --git a/tests/handlers/records-write.spec.ts b/tests/handlers/records-write.spec.ts index 9b618c8d9..34bd472a9 100644 --- a/tests/handlers/records-write.spec.ts +++ b/tests/handlers/records-write.spec.ts @@ -1,5 +1,5 @@ import type { EncryptionInput } from '../../src/interfaces/records-write.js'; -import type { EventStream } from '../../src/types/event-stream.js'; +import type { EventStream } from '../../src/types/subscriptions.js'; import type { GenerateFromRecordsWriteOut } from '../utils/test-data-generator.js'; import type { ProtocolDefinition } from '../../src/types/protocols-types.js'; import type { RecordsQueryReplyEntry } from '../../src/types/records-types.js'; diff --git a/tests/interfaces/events-query.spec.ts b/tests/interfaces/events-query.spec.ts index 00039eb4d..c9bd9c94e 100644 --- a/tests/interfaces/events-query.spec.ts +++ b/tests/interfaces/events-query.spec.ts @@ -1,4 +1,4 @@ -import type { EventsQueryMessage } from '../../src/types/event-types.js'; +import type { EventsQueryMessage } from '../../src/types/events-types.js'; import type { ProtocolsQueryFilter } from '../../src/types/protocols-types.js'; import type { RecordsFilter } from '../../src/types/records-types.js'; diff --git a/tests/scenarios/delegated-grant.spec.ts b/tests/scenarios/delegated-grant.spec.ts index 532e9b411..2915011ca 100644 --- a/tests/scenarios/delegated-grant.spec.ts +++ b/tests/scenarios/delegated-grant.spec.ts @@ -1,4 +1,4 @@ -import type { EventStream } from '../../src/types/event-stream.js'; +import type { EventStream } from '../../src/types/subscriptions.js'; import type { DataStore, EventLog, MessageStore, PermissionScope } from '../../src/index.js'; import chaiAsPromised from 'chai-as-promised'; diff --git a/tests/scenarios/end-to-end-tests.spec.ts b/tests/scenarios/end-to-end-tests.spec.ts index 449a47ac9..504b8aa84 100644 --- a/tests/scenarios/end-to-end-tests.spec.ts +++ b/tests/scenarios/end-to-end-tests.spec.ts @@ -1,5 +1,5 @@ import type { DerivedPrivateJwk } from '../../src/utils/hd-key.js'; -import type { EventStream } from '../../src/types/event-stream.js'; +import type { EventStream } from '../../src/types/subscriptions.js'; import type { DataStore, EventLog, MessageStore, ProtocolDefinition, ProtocolsConfigureMessage, RecordsReadReply } from '../../src/index.js'; import chaiAsPromised from 'chai-as-promised'; diff --git a/tests/utils/test-data-generator.ts b/tests/utils/test-data-generator.ts index e203d7818..8ef996938 100644 --- a/tests/utils/test-data-generator.ts +++ b/tests/utils/test-data-generator.ts @@ -16,7 +16,7 @@ import type { Signer } from '../../src/types/signer.js'; import type { AuthorizationModel, Pagination } from '../../src/types/message-types.js'; import type { CreateFromOptions, EncryptionInput, KeyEncryptionInput, RecordsWriteOptions } from '../../src/interfaces/records-write.js'; import type { DateSort, RecordsDeleteMessage, RecordsFilter, RecordsQueryMessage } from '../../src/types/records-types.js'; -import type { EventsFilter, EventsGetMessage, EventsQueryMessage, EventsSubscribeMessage } from '../../src/types/event-types.js'; +import type { EventsFilter, EventsGetMessage, EventsQueryMessage, EventsSubscribeMessage } from '../../src/types/events-types.js'; import type { PermissionConditions, PermissionScope } from '../../src/types/permissions-grant-descriptor.js'; import type { PermissionsGrantMessage, PermissionsRequestMessage, PermissionsRevokeMessage } from '../../src/types/permissions-types.js'; import type { PrivateJwk, PublicJwk } from '../../src/types/jose-types.js'; From c5cf75cadda170985a5ebffdaf2f47fde670549d Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Fri, 22 Dec 2023 17:45:42 -0500 Subject: [PATCH 03/44] add timeouts to tests --- tests/scenarios/subscriptions.spec.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/scenarios/subscriptions.spec.ts b/tests/scenarios/subscriptions.spec.ts index 2829ea39f..0ffda0984 100644 --- a/tests/scenarios/subscriptions.spec.ts +++ b/tests/scenarios/subscriptions.spec.ts @@ -302,7 +302,9 @@ export function testSubscriptionScenarios(): void { expect(aliceMessage2Reply.status.code).to.equal(202); authorizeSpy.restore(); - await Time.minimalSleep(); + while (messageCids.length < 2) { + await Time.minimalSleep(); + } expect(authorizeSpy.callCount).to.equal(0, 'reauthorize'); // authorize is never called expect(messageCids.length).to.equal(2, 'messageCids'); @@ -378,7 +380,9 @@ export function testSubscriptionScenarios(): void { expect(aliceMessage2Reply.status.code).to.equal(202); authorizeSpy.restore(); - await Time.minimalSleep(); + while (messageCids.length < 2) { + await Time.minimalSleep(); + } expect(authorizeSpy.callCount).to.equal(2, 'reauthorize'); // authorize on each message expect(messageCids.length).to.equal(2, 'messageCids'); @@ -475,7 +479,9 @@ export function testSubscriptionScenarios(): void { authorizeSpy.restore(); clock.restore(); - await Time.minimalSleep(); + while (messageCids.length < 3) { + await Time.minimalSleep(); + } expect(authorizeSpy.callCount).to.equal(1, 'reauthorize'); // called once after the TTL has passed expect(messageCids.length).to.equal(3, 'messageCids'); From 727dc733fc4532d583f0694ac61bdbe4bd9fd364 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Fri, 22 Dec 2023 18:43:30 -0500 Subject: [PATCH 04/44] test delegate grant --- .../permissions/permissions-definitions.json | 3 + json-schemas/permissions/scopes.json | 18 +++++ src/types/permissions-grant-descriptor.ts | 2 +- tests/scenarios/delegated-grant.spec.ts | 67 ++++++++++++++++++- 4 files changed, 86 insertions(+), 4 deletions(-) diff --git a/json-schemas/permissions/permissions-definitions.json b/json-schemas/permissions/permissions-definitions.json index 985167cff..4e19ef0ab 100644 --- a/json-schemas/permissions/permissions-definitions.json +++ b/json-schemas/permissions/permissions-definitions.json @@ -28,6 +28,9 @@ }, { "$ref": "https://identity.foundation/dwn/json-schemas/permissions/scopes.json#/definitions/records-query-scope" + }, + { + "$ref": "https://identity.foundation/dwn/json-schemas/permissions/scopes.json#/definitions/records-subscribe-scope" } ] }, diff --git a/json-schemas/permissions/scopes.json b/json-schemas/permissions/scopes.json index 5bd34e0ca..af83b84cd 100644 --- a/json-schemas/permissions/scopes.json +++ b/json-schemas/permissions/scopes.json @@ -106,6 +106,24 @@ "type": "string" } } + }, + "records-subscribe-scope": { + "type": "object", + "required": [ + "interface", + "method" + ], + "properties": { + "interface": { + "const": "Records" + }, + "method": { + "const": "Subscribe" + }, + "protocol": { + "type": "string" + } + } } } } \ No newline at end of file diff --git a/src/types/permissions-grant-descriptor.ts b/src/types/permissions-grant-descriptor.ts index 4218f3f5e..0b552d30f 100644 --- a/src/types/permissions-grant-descriptor.ts +++ b/src/types/permissions-grant-descriptor.ts @@ -53,7 +53,7 @@ export type PermissionScope = { // Method-specific scopes export type RecordsPermissionScope = { interface: DwnInterfaceName.Records; - method: DwnMethodName.Read | DwnMethodName.Write | DwnMethodName.Query | DwnMethodName.Delete; + method: DwnMethodName.Read | DwnMethodName.Write | DwnMethodName.Query | DwnMethodName.Subscribe | DwnMethodName.Delete; /** May only be present when `schema` is undefined */ protocol?: string; /** May only be present when `protocol` is defined and `protocolPath` is undefined */ diff --git a/tests/scenarios/delegated-grant.spec.ts b/tests/scenarios/delegated-grant.spec.ts index 2915011ca..37b443d6b 100644 --- a/tests/scenarios/delegated-grant.spec.ts +++ b/tests/scenarios/delegated-grant.spec.ts @@ -1,5 +1,5 @@ import type { EventStream } from '../../src/types/subscriptions.js'; -import type { DataStore, EventLog, MessageStore, PermissionScope } from '../../src/index.js'; +import type { DataStore, EventLog, MessageStore, PermissionScope, RecordsDeleteMessage, RecordsWriteMessage } from '../../src/index.js'; import chaiAsPromised from 'chai-as-promised'; import emailProtocolDefinition from '../vectors/protocol-definitions/email.json' assert { type: 'json' }; @@ -20,7 +20,7 @@ import { TestDataGenerator } from '../utils/test-data-generator.js'; import { TestStores } from '../test-stores.js'; import { Time } from '../../src/utils/time.js'; -import { DwnInterfaceName, DwnMethodName, EventStreamEmitter, PermissionsGrant, RecordsDelete, RecordsQuery, RecordsRead } from '../../src/index.js'; +import { DwnInterfaceName, DwnMethodName, EventStreamEmitter, PermissionsGrant, RecordsDelete, RecordsQuery, RecordsRead, RecordsSubscribe } from '../../src/index.js'; chai.use(chaiAsPromised); @@ -182,7 +182,7 @@ export function testDelegatedGrantScenarios(): void { expect(carolWriteReply.status.detail).to.contain(DwnErrorCode.RecordsValidateIntegrityGrantedToAndSignerMismatch); }); - it('should only allow correct entity invoking a delegated grant to read or query', async () => { + it('should only allow correct entity invoking a delegated grant to read, query or subscribe', async () => { // scenario: // 1. Alice creates read and query delegated grants for device X, // 2. Bob starts a chat thread with Alice on his DWN @@ -225,6 +225,63 @@ export function testDelegatedGrantScenarios(): void { const participantRoleReply = await dwn.processMessage(bob.did, participantRoleRecord.message, { dataStream: participantRoleRecord.dataStream }); expect(participantRoleReply.status.code).to.equal(202); + // Alice creates a delegated subscribe grant for device X to act as Alice. + const subscribeGrantForDeviceX = await PermissionsGrant.create({ + delegated : true, // this is a delegated grant + dateExpires : Time.createOffsetTimestamp({ seconds: 100 }), + grantedBy : alice.did, + grantedTo : deviceX.did, + grantedFor : alice.did, + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Subscribe, + protocol + }, + signer: Jws.createSigner(alice) + }); + + // verify device X is able to subscribe the chat message from Bob's DWN + const recordsSubscribeByDeviceX = await RecordsSubscribe.create({ + signer : Jws.createSigner(deviceX), + delegatedGrant : subscribeGrantForDeviceX.asDelegatedGrant(), + protocolRole : 'thread/participant', + filter : { + contextId : threadRecord.message.contextId, + protocol : protocolDefinition.protocol, + protocolPath : 'thread/chat' + } + }); + const recordsSubscribeByDeviceXReply = await dwn.processMessage(bob.did, recordsSubscribeByDeviceX.message); + console.log(recordsSubscribeByDeviceXReply.status); + expect(recordsSubscribeByDeviceXReply.status.code).to.equal(200, 'subscribe'); + + const subscriptionChatRecords:Set = new Set(); + const captureChatRecords = async (message: RecordsWriteMessage | RecordsDeleteMessage): Promise => { + if (message.descriptor.method === DwnMethodName.Delete) { + const recordId = message.descriptor.recordId; + subscriptionChatRecords.delete(recordId); + } else { + const recordId = (message as RecordsWriteMessage).recordId; + subscriptionChatRecords.add(recordId); + } + }; + const deviceXSub = recordsSubscribeByDeviceXReply.subscription!.on(captureChatRecords); + + // Verify that Carol cannot subscribe as Alice by invoking the delegated grant granted to Device X + const recordsSubscribeByCarol = await RecordsSubscribe.create({ + signer : Jws.createSigner(carol), + delegatedGrant : subscribeGrantForDeviceX.asDelegatedGrant(), + protocolRole : 'thread/participant', + filter : { + contextId : threadRecord.message.contextId, + protocol : protocolDefinition.protocol, + protocolPath : 'thread/chat' + } + }); + const recordsSubscribeByCarolReply = await dwn.processMessage(bob.did, recordsSubscribeByCarol.message); + expect(recordsSubscribeByCarolReply.status.code).to.equal(400, 'carol subscribe'); + expect(recordsSubscribeByCarolReply.status.detail).to.contain(DwnErrorCode.RecordsValidateIntegrityGrantedToAndSignerMismatch); + // Bob writes a chat message in the thread const chatRecord = await TestDataGenerator.generateRecordsWrite({ author : bob, @@ -344,6 +401,10 @@ export function testDelegatedGrantScenarios(): void { const recordsReadByCarolReply = await dwn.processMessage(bob.did, recordsReadByCarol.message); expect(recordsReadByCarolReply.status.code).to.equal(400); expect(recordsQueryByCarolReply.status.detail).to.contain(DwnErrorCode.RecordsValidateIntegrityGrantedToAndSignerMismatch); + + deviceXSub.off(); + expect(subscriptionChatRecords.size).to.equal(1); + expect([...subscriptionChatRecords]).to.have.members([chatRecord.message.recordId]); }); it('should only allow correct entity invoking a delegated grant to delete', async () => { From cc60ed6f2449054dc167d2c4df3c1a51027216fe Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Sat, 23 Dec 2023 01:18:46 -0500 Subject: [PATCH 05/44] signal when record is updated --- src/event-log/event-stream.ts | 4 ++-- src/event-log/subscription.ts | 19 ++++++++++++------- src/handlers/records-subscribe.ts | 16 ++++++++-------- src/handlers/records-write.ts | 6 +++--- src/types/events-types.ts | 2 +- src/types/message-types.ts | 2 +- src/types/records-types.ts | 2 +- src/types/subscriptions.ts | 7 +++++-- tests/scenarios/delegated-grant.spec.ts | 1 - 9 files changed, 33 insertions(+), 26 deletions(-) diff --git a/src/event-log/event-stream.ts b/src/event-log/event-stream.ts index f48338e59..70209a5eb 100644 --- a/src/event-log/event-stream.ts +++ b/src/event-log/event-stream.ts @@ -100,13 +100,13 @@ export class EventStreamEmitter implements EventStream { this.eventEmitter.removeAllListeners(); } - emit(tenant: string, message: GenericMessage, ...matchIndexes: KeyValues[]): void { + emit(tenant: string, message: GenericMessage, initialIndexes:KeyValues, ...additionalIndexes: KeyValues[]): void { if (!this.isOpen) { //todo: dwn error throw new Error('Event stream is not open. Cannot add to the stream.'); } try { - this.eventEmitter.emit(this.eventChannel, tenant, message, ...matchIndexes); + this.eventEmitter.emit(this.eventChannel, tenant, message, initialIndexes, ...additionalIndexes); } catch (error) { //todo: dwn catch error; throw error; // You can choose to handle or propagate the error as needed. diff --git a/src/event-log/subscription.ts b/src/event-log/subscription.ts index c2c0c5ba2..75831ae52 100644 --- a/src/event-log/subscription.ts +++ b/src/event-log/subscription.ts @@ -1,6 +1,6 @@ import type { EventEmitter } from 'events'; import type { MessageStore } from '../types/message-store.js'; -import type { Subscription } from '../types/subscriptions.js'; +import type { EmitFunction, Subscription } from '../types/subscriptions.js'; import type { Filter, KeyValues } from '../types/query-types.js'; import type { GenericMessage, GenericMessageHandler } from '../types/message-types.js'; @@ -45,14 +45,19 @@ export class SubscriptionBase implements Subscription { return this.#id; } - protected matchFilter(tenant: string, ...indexes: KeyValues[]):boolean { - return this.tenant === tenant && - indexes.find(index => FilterUtility.matchAnyFilter(index, this.filters)) !== undefined; + protected matchFilter(tenant: string, indexes: KeyValues, ...additionalIndexes: KeyValues[]): { match: boolean, updated?: boolean } { + const initialMatch = FilterUtility.matchAnyFilter(indexes, this.filters); + const additionalMatch = + additionalIndexes.length > 0 ? additionalIndexes.find(index => FilterUtility.matchAnyFilter(index, this.filters)) !== undefined : undefined; + const match = this.tenant === tenant && (initialMatch === true || additionalMatch === true); + const updated = additionalMatch ? additionalMatch === true && !initialMatch : undefined; + return { match, updated }; } - public listener = (tenant: string, message: GenericMessage, ...indexes: KeyValues[]):void => { - if (this.matchFilter(tenant, ...indexes)) { - this.eventEmitter.emit(this.eventChannel, message); + public listener: EmitFunction = (tenant, message, indexes, ...additionalIndexes):void => { + const { match, updated } = this.matchFilter(tenant, indexes, ...additionalIndexes); + if (match === true) { + this.eventEmitter.emit(this.eventChannel, message, updated); } }; diff --git a/src/handlers/records-subscribe.ts b/src/handlers/records-subscribe.ts index 5ccdee7a4..2a43cbe9f 100644 --- a/src/handlers/records-subscribe.ts +++ b/src/handlers/records-subscribe.ts @@ -1,10 +1,9 @@ import type { DidResolver } from '../did/did-resolver.js'; -import type EventEmitter from 'events'; -import type { EventStream } from '../types/subscriptions.js'; -import type { GenericMessage } from '../types/message-types.js'; +import type { EventEmitter } from 'events'; +import type { Filter } from '../types/query-types.js'; import type { MessageStore } from '../types//message-store.js'; import type { MethodHandler } from '../types/method-handler.js'; -import type { Filter, KeyValues } from '../types/query-types.js'; +import type { EmitFunction, EventStream } from '../types/subscriptions.js'; import type { RecordsDeleteMessage, RecordsSubscribeMessage, RecordsSubscribeReply, RecordsWriteMessage } from '../types/records-types.js'; import { authenticate } from '../core/auth.js'; @@ -287,8 +286,9 @@ export class RecordsSubscriptionHandler extends SubscriptionBase { return new RecordsSubscriptionHandler({ ...options, id, recordsSubscribe }); } - public listener = async (tenant: string, message: GenericMessage, ...indexes: KeyValues[]):Promise => { - if (this.matchFilter(tenant, ...indexes)) { + public listener: EmitFunction = async (tenant, message, initialIndex, ...additionalIndexes):Promise => { + const { match, updated } = this.matchFilter(tenant, initialIndex, ...additionalIndexes); + if (match === true) { if (this.shouldAuthorize) { try { await this.reauthorize(); @@ -300,12 +300,12 @@ export class RecordsSubscriptionHandler extends SubscriptionBase { } if (RecordsWrite.isRecordsWriteMessage(message) || RecordsDelete.isRecordsDeleteMessage(message)) { - this.eventEmitter.emit(this.eventChannel, message); + this.eventEmitter.emit(this.eventChannel, message, updated); } } }; - on(handler:(message: RecordsWriteMessage | RecordsDeleteMessage) => void): { off: () => void } { + on(handler:(message: RecordsWriteMessage | RecordsDeleteMessage, updated?: boolean) => void): { off: () => void } { this.eventEmitter.on(this.eventChannel, handler); return { off: (): void => { diff --git a/src/handlers/records-write.ts b/src/handlers/records-write.ts index b88ce2918..cd8111f16 100644 --- a/src/handlers/records-write.ts +++ b/src/handlers/records-write.ts @@ -133,12 +133,12 @@ export class RecordsWriteHandler implements MethodHandler { // we use the same KeyValues as the store indexes for the event stream match fields // if it is not the initial write, we also include the indexes from the most recent write - const eventIndexes = [ indexes ]; + const additionalIndexes = []; if (!newMessageIsInitialWrite && newestExistingMessage?.descriptor.method === DwnMethodName.Write) { const newistExistingRecordsWrite = await RecordsWrite.parse(newestExistingMessage as RecordsQueryReplyEntry); - eventIndexes.push(await newistExistingRecordsWrite.constructIndexes(false)); + additionalIndexes.push(await newistExistingRecordsWrite.constructIndexes(false)); } - this.eventStream.emit(tenant, message, ...eventIndexes); + this.eventStream.emit(tenant, message, indexes, ...additionalIndexes); } catch (error) { const e = error as any; if (e.code === DwnErrorCode.RecordsWriteMissingEncodedDataInPrevious || diff --git a/src/types/events-types.ts b/src/types/events-types.ts index 74581eac8..fbd4afbe9 100644 --- a/src/types/events-types.ts +++ b/src/types/events-types.ts @@ -47,7 +47,7 @@ export type EventsSubscribeMessage = { descriptor: EventsSubscribeDescriptor; }; -export type EventsHandler = (message: GenericMessage) => void; +export type EventsHandler = (message: GenericMessage, updated?: boolean) => void; export type EventsSubscription = { id: string; diff --git a/src/types/message-types.ts b/src/types/message-types.ts index 1b0c18722..6b673cb2b 100644 --- a/src/types/message-types.ts +++ b/src/types/message-types.ts @@ -75,7 +75,7 @@ export type QueryResultEntry = GenericMessage & { encodedData?: string; }; -export type GenericMessageHandler = (message: GenericMessage) => void; +export type GenericMessageHandler = (message: GenericMessage, updated?: boolean) => void; export type GenericMessageSubscription = { id: string; diff --git a/src/types/records-types.ts b/src/types/records-types.ts index 1847c85a4..cdd2ed739 100644 --- a/src/types/records-types.ts +++ b/src/types/records-types.ts @@ -197,6 +197,6 @@ export type RecordsDeleteDescriptor = { export type RecordsSubscription = { id: string; - on: (handler:(message: RecordsWriteMessage | RecordsDeleteMessage) => void) => { off: () => void }; + on: (handler:(message: RecordsWriteMessage | RecordsDeleteMessage) => void, updated?: boolean) => { off: () => void }; close: () => Promise; }; \ No newline at end of file diff --git a/src/types/subscriptions.ts b/src/types/subscriptions.ts index d00f548cf..d500ab5d0 100644 --- a/src/types/subscriptions.ts +++ b/src/types/subscriptions.ts @@ -4,18 +4,21 @@ import type { Filter, KeyValues } from './query-types.js'; import type { GenericMessage, GenericMessageHandler, GenericMessageSubscription } from './message-types.js'; import type { RecordsSubscribeMessage, RecordsSubscription } from './records-types.js'; +export type EmitFunction = (tenant: string, message: GenericMessage, indexes: KeyValues, ...additionalIndexes: KeyValues[]) => void; + export interface EventStream { subscribe(tenant: string, message: EventsSubscribeMessage, filters: Filter[]): Promise; subscribe(tenant: string, message: RecordsSubscribeMessage, filters: Filter[]): Promise; subscribe(tenant: string, message: GenericMessage, filters: Filter[]): Promise; - emit(tenant: string, message: GenericMessage, ...matchIndexes: KeyValues[]): void; + emit(tenant: string, message: GenericMessage, indexes: KeyValues, ...additionalIndexes: KeyValues[]):void; open(): Promise; close(): Promise; } + export interface Subscription { id: string; - listener: (tenant: string, message: GenericMessage, ...indexes: KeyValues[]) => void; + listener: EmitFunction; on: (handler: GenericMessageHandler) => { off: () => void }; close: () => Promise; } diff --git a/tests/scenarios/delegated-grant.spec.ts b/tests/scenarios/delegated-grant.spec.ts index 37b443d6b..1e4951ea6 100644 --- a/tests/scenarios/delegated-grant.spec.ts +++ b/tests/scenarios/delegated-grant.spec.ts @@ -252,7 +252,6 @@ export function testDelegatedGrantScenarios(): void { } }); const recordsSubscribeByDeviceXReply = await dwn.processMessage(bob.did, recordsSubscribeByDeviceX.message); - console.log(recordsSubscribeByDeviceXReply.status); expect(recordsSubscribeByDeviceXReply.status.code).to.equal(200, 'subscribe'); const subscriptionChatRecords:Set = new Set(); From e33e7cb84e822f6e813265a1ebbf9dcf6d3dafec Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Fri, 29 Dec 2023 18:02:34 -0500 Subject: [PATCH 06/44] update notifier signature --- src/event-log/event-stream.ts | 8 +++---- src/event-log/subscription.ts | 29 +++++++++++++------------ src/handlers/permissions-grant.ts | 2 +- src/handlers/permissions-request.ts | 2 +- src/handlers/permissions-revoke.ts | 2 +- src/handlers/protocols-configure.ts | 2 +- src/handlers/records-delete.ts | 2 +- src/handlers/records-subscribe.ts | 33 +++++++++++++++++------------ src/handlers/records-write.ts | 10 +++++---- src/types/subscriptions.ts | 9 ++++++-- 10 files changed, 57 insertions(+), 42 deletions(-) diff --git a/src/event-log/event-stream.ts b/src/event-log/event-stream.ts index 70209a5eb..7b3018712 100644 --- a/src/event-log/event-stream.ts +++ b/src/event-log/event-stream.ts @@ -1,8 +1,8 @@ import type { DidResolver } from '../did/did-resolver.js'; +import type { Filter } from '../types/query-types.js'; import type { MessageStore } from '../types/message-store.js'; +import type { EventMessageData, EventStream, Subscription } from '../types/subscriptions.js'; import type { EventsSubscribeMessage, EventsSubscription } from '../types/events-types.js'; -import type { EventStream, Subscription } from '../types/subscriptions.js'; -import type { Filter, KeyValues } from '../types/query-types.js'; import type { GenericMessage, GenericMessageSubscription } from '../types/message-types.js'; import type { RecordsSubscribeMessage, RecordsSubscription } from '../types/records-types.js'; @@ -100,13 +100,13 @@ export class EventStreamEmitter implements EventStream { this.eventEmitter.removeAllListeners(); } - emit(tenant: string, message: GenericMessage, initialIndexes:KeyValues, ...additionalIndexes: KeyValues[]): void { + emit(tenant: string, message: EventMessageData, mostRecentMessage?: EventMessageData): void { if (!this.isOpen) { //todo: dwn error throw new Error('Event stream is not open. Cannot add to the stream.'); } try { - this.eventEmitter.emit(this.eventChannel, tenant, message, initialIndexes, ...additionalIndexes); + this.eventEmitter.emit(this.eventChannel, tenant, message, mostRecentMessage); } catch (error) { //todo: dwn catch error; throw error; // You can choose to handle or propagate the error as needed. diff --git a/src/event-log/subscription.ts b/src/event-log/subscription.ts index 75831ae52..b9b6fa2d5 100644 --- a/src/event-log/subscription.ts +++ b/src/event-log/subscription.ts @@ -1,7 +1,7 @@ import type { EventEmitter } from 'events'; +import type { Filter } from '../types/query-types.js'; import type { MessageStore } from '../types/message-store.js'; -import type { EmitFunction, Subscription } from '../types/subscriptions.js'; -import type { Filter, KeyValues } from '../types/query-types.js'; +import type { EmitFunction, EventMessageData, Subscription } from '../types/subscriptions.js'; import type { GenericMessage, GenericMessageHandler } from '../types/message-types.js'; import { FilterUtility } from '../utils/filter.js'; @@ -45,19 +45,22 @@ export class SubscriptionBase implements Subscription { return this.#id; } - protected matchFilter(tenant: string, indexes: KeyValues, ...additionalIndexes: KeyValues[]): { match: boolean, updated?: boolean } { - const initialMatch = FilterUtility.matchAnyFilter(indexes, this.filters); - const additionalMatch = - additionalIndexes.length > 0 ? additionalIndexes.find(index => FilterUtility.matchAnyFilter(index, this.filters)) !== undefined : undefined; - const match = this.tenant === tenant && (initialMatch === true || additionalMatch === true); - const updated = additionalMatch ? additionalMatch === true && !initialMatch : undefined; - return { match, updated }; + protected matchMessages(tenant: string, current: EventMessageData, mostRecent?: EventMessageData): GenericMessage[] { + const emitArgs:GenericMessage[] = []; + if (tenant === this.tenant) { + if (FilterUtility.matchAnyFilter(current.indexes, this.filters)) { + emitArgs.push(current.message); + } else if (mostRecent !== undefined && FilterUtility.matchAnyFilter(mostRecent.indexes, this.filters)) { + emitArgs.push(current.message, mostRecent.message);; + } + } + return emitArgs; } - public listener: EmitFunction = (tenant, message, indexes, ...additionalIndexes):void => { - const { match, updated } = this.matchFilter(tenant, indexes, ...additionalIndexes); - if (match === true) { - this.eventEmitter.emit(this.eventChannel, message, updated); + public listener: EmitFunction = (tenant, current, mostRecent):void => { + const emitArgs = this.matchMessages(tenant, current, mostRecent); + if (emitArgs.length > 0) { + this.eventEmitter.emit(this.eventChannel, ...emitArgs); } }; diff --git a/src/handlers/permissions-grant.ts b/src/handlers/permissions-grant.ts index 59394010b..791979edc 100644 --- a/src/handlers/permissions-grant.ts +++ b/src/handlers/permissions-grant.ts @@ -47,7 +47,7 @@ export class PermissionsGrantHandler implements MethodHandler { if (existingMessage === undefined) { await this.messageStore.put(tenant, message, indexes); await this.eventLog.append(tenant, messageCid, indexes); - this.eventStream.emit(tenant, message, indexes); + this.eventStream.emit(tenant, { message, indexes }); } return { diff --git a/src/handlers/permissions-request.ts b/src/handlers/permissions-request.ts index 7cb1d9468..f9bb264fc 100644 --- a/src/handlers/permissions-request.ts +++ b/src/handlers/permissions-request.ts @@ -51,7 +51,7 @@ export class PermissionsRequestHandler implements MethodHandler { if (existingMessage === undefined) { await this.messageStore.put(tenant, message, indexes); await this.eventLog.append(tenant, messageCid, indexes); - this.eventStream.emit(tenant, message, indexes); + this.eventStream.emit(tenant, { message, indexes }); } return { diff --git a/src/handlers/permissions-revoke.ts b/src/handlers/permissions-revoke.ts index a529b5b2e..5ba597307 100644 --- a/src/handlers/permissions-revoke.ts +++ b/src/handlers/permissions-revoke.ts @@ -96,7 +96,7 @@ export class PermissionsRevokeHandler implements MethodHandler { await this.eventLog.append(tenant, await Message.getCid(message), indexes); // emit revoke and exercise any revocation necessary within the event stream - this.eventStream.emit(tenant, message, indexes); + this.eventStream.emit(tenant, { message, indexes }); // Delete existing revokes which are all newer than the incoming message const removedRevokeCids: string[] = []; diff --git a/src/handlers/protocols-configure.ts b/src/handlers/protocols-configure.ts index 573cda1ec..9baec7408 100644 --- a/src/handlers/protocols-configure.ts +++ b/src/handlers/protocols-configure.ts @@ -66,7 +66,7 @@ export class ProtocolsConfigureHandler implements MethodHandler { await this.messageStore.put(tenant, message, indexes); const messageCid = await Message.getCid(message); await this.eventLog.append(tenant, messageCid, indexes); - this.eventStream.emit(tenant, message, indexes); + this.eventStream.emit(tenant, { message, indexes }); messageReply = { status: { code: 202, detail: 'Accepted' } diff --git a/src/handlers/records-delete.ts b/src/handlers/records-delete.ts index f83822b02..21ab567c2 100644 --- a/src/handlers/records-delete.ts +++ b/src/handlers/records-delete.ts @@ -99,7 +99,7 @@ export class RecordsDeleteHandler implements MethodHandler { const latestRecordsWrite = newestExistingMessage as RecordsWriteMessage; const mostRecentWrite = await RecordsWrite.parse(latestRecordsWrite); const mostRecentWriteIndexes = await mostRecentWrite.constructIndexes(false); - this.eventStream.emit(tenant, message, indexes, mostRecentWriteIndexes); + this.eventStream.emit(tenant, { message, indexes }, { message: mostRecentWrite.message, indexes: mostRecentWriteIndexes }); // delete all existing messages that are not newest, except for the initial write await StorageController.deleteAllOlderMessagesButKeepInitialWrite( diff --git a/src/handlers/records-subscribe.ts b/src/handlers/records-subscribe.ts index 2a43cbe9f..1ae97db25 100644 --- a/src/handlers/records-subscribe.ts +++ b/src/handlers/records-subscribe.ts @@ -7,6 +7,7 @@ import type { EmitFunction, EventStream } from '../types/subscriptions.js'; import type { RecordsDeleteMessage, RecordsSubscribeMessage, RecordsSubscribeReply, RecordsWriteMessage } from '../types/records-types.js'; import { authenticate } from '../core/auth.js'; +import { FilterUtility } from '../utils/filter.js'; import { Message } from '../core/message.js'; import { messageReplyFromError } from '../core/message-reply.js'; import { ProtocolAuthorization } from '../core/protocol-authorization.js'; @@ -286,21 +287,25 @@ export class RecordsSubscriptionHandler extends SubscriptionBase { return new RecordsSubscriptionHandler({ ...options, id, recordsSubscribe }); } - public listener: EmitFunction = async (tenant, message, initialIndex, ...additionalIndexes):Promise => { - const { match, updated } = this.matchFilter(tenant, initialIndex, ...additionalIndexes); - if (match === true) { - if (this.shouldAuthorize) { - try { - await this.reauthorize(); - } catch (error) { - //todo: check for known authorization errors - // console.log('reauthorize error', error); - await this.close(); + public listener: EmitFunction = async (tenant, current, mostRecent):Promise => { + if (RecordsWrite.isRecordsWriteMessage(current.message) || RecordsDelete.isRecordsDeleteMessage(current.message)) { + try { + const emitArgs = []; + if (FilterUtility.matchAnyFilter(current.indexes, this.filters)) { + emitArgs.push(current.message); + } else if (mostRecent !== undefined && FilterUtility.matchAnyFilter(mostRecent.indexes, this.filters)) { + emitArgs.push(current.message, mostRecent.message); } - } - - if (RecordsWrite.isRecordsWriteMessage(message) || RecordsDelete.isRecordsDeleteMessage(message)) { - this.eventEmitter.emit(this.eventChannel, message, updated); + if (emitArgs.length > 0) { + if (this.shouldAuthorize) { + await this.reauthorize(); + } + this.eventEmitter.emit(this.eventChannel, ...emitArgs); + } + } catch (error) { + //todo: check for known authorization errors + // console.log('reauthorize error', error); + await this.close(); } } }; diff --git a/src/handlers/records-write.ts b/src/handlers/records-write.ts index cd8111f16..86a776a49 100644 --- a/src/handlers/records-write.ts +++ b/src/handlers/records-write.ts @@ -133,12 +133,14 @@ export class RecordsWriteHandler implements MethodHandler { // we use the same KeyValues as the store indexes for the event stream match fields // if it is not the initial write, we also include the indexes from the most recent write - const additionalIndexes = []; + let mostRecentMessage; if (!newMessageIsInitialWrite && newestExistingMessage?.descriptor.method === DwnMethodName.Write) { - const newistExistingRecordsWrite = await RecordsWrite.parse(newestExistingMessage as RecordsQueryReplyEntry); - additionalIndexes.push(await newistExistingRecordsWrite.constructIndexes(false)); + const newestExistingWrite = await RecordsWrite.parse(newestExistingMessage as RecordsQueryReplyEntry); + const newestExistingIndexes = await newestExistingWrite.constructIndexes(false); + mostRecentMessage = { message: newestExistingMessage, indexes: newestExistingIndexes }; } - this.eventStream.emit(tenant, message, indexes, ...additionalIndexes); + + this.eventStream.emit(tenant, { message, indexes }, mostRecentMessage); } catch (error) { const e = error as any; if (e.code === DwnErrorCode.RecordsWriteMissingEncodedDataInPrevious || diff --git a/src/types/subscriptions.ts b/src/types/subscriptions.ts index d500ab5d0..1b417c7bb 100644 --- a/src/types/subscriptions.ts +++ b/src/types/subscriptions.ts @@ -4,13 +4,18 @@ import type { Filter, KeyValues } from './query-types.js'; import type { GenericMessage, GenericMessageHandler, GenericMessageSubscription } from './message-types.js'; import type { RecordsSubscribeMessage, RecordsSubscription } from './records-types.js'; -export type EmitFunction = (tenant: string, message: GenericMessage, indexes: KeyValues, ...additionalIndexes: KeyValues[]) => void; +export type EventMessageData = { + message: GenericMessage; + indexes: KeyValues; +}; + +export type EmitFunction = (tenant: string, message: EventMessageData, mostRecent?: EventMessageData) => void; export interface EventStream { subscribe(tenant: string, message: EventsSubscribeMessage, filters: Filter[]): Promise; subscribe(tenant: string, message: RecordsSubscribeMessage, filters: Filter[]): Promise; subscribe(tenant: string, message: GenericMessage, filters: Filter[]): Promise; - emit(tenant: string, message: GenericMessage, indexes: KeyValues, ...additionalIndexes: KeyValues[]):void; + emit(tenant: string, message: EventMessageData, mostRecentMessage?: EventMessageData): void; open(): Promise; close(): Promise; } From 2942d63a6126e30846eec88ff8789fdbe30dee8e Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 3 Jan 2024 16:07:36 -0500 Subject: [PATCH 07/44] handler signature should just be the incoming message, ignoring previous values --- src/event-log/event-stream.ts | 8 ++++---- src/event-log/subscription.ts | 23 +++++++---------------- src/handlers/permissions-grant.ts | 2 +- src/handlers/permissions-request.ts | 2 +- src/handlers/permissions-revoke.ts | 2 +- src/handlers/protocols-configure.ts | 2 +- src/handlers/records-delete.ts | 6 +----- src/handlers/records-subscribe.ts | 22 +++++++--------------- src/handlers/records-write.ts | 12 +----------- src/types/events-types.ts | 4 ++-- src/types/message-types.ts | 2 +- src/types/subscriptions.ts | 10 ++-------- tests/handlers/records-subscribe.spec.ts | 8 -------- 13 files changed, 29 insertions(+), 74 deletions(-) diff --git a/src/event-log/event-stream.ts b/src/event-log/event-stream.ts index 7b3018712..d526921d5 100644 --- a/src/event-log/event-stream.ts +++ b/src/event-log/event-stream.ts @@ -1,8 +1,8 @@ import type { DidResolver } from '../did/did-resolver.js'; -import type { Filter } from '../types/query-types.js'; import type { MessageStore } from '../types/message-store.js'; -import type { EventMessageData, EventStream, Subscription } from '../types/subscriptions.js'; import type { EventsSubscribeMessage, EventsSubscription } from '../types/events-types.js'; +import type { EventStream, Subscription } from '../types/subscriptions.js'; +import type { Filter, KeyValues } from '../types/query-types.js'; import type { GenericMessage, GenericMessageSubscription } from '../types/message-types.js'; import type { RecordsSubscribeMessage, RecordsSubscription } from '../types/records-types.js'; @@ -100,13 +100,13 @@ export class EventStreamEmitter implements EventStream { this.eventEmitter.removeAllListeners(); } - emit(tenant: string, message: EventMessageData, mostRecentMessage?: EventMessageData): void { + emit(tenant: string, message: GenericMessage, indexes: KeyValues): void { if (!this.isOpen) { //todo: dwn error throw new Error('Event stream is not open. Cannot add to the stream.'); } try { - this.eventEmitter.emit(this.eventChannel, tenant, message, mostRecentMessage); + this.eventEmitter.emit(this.eventChannel, tenant, message, indexes); } catch (error) { //todo: dwn catch error; throw error; // You can choose to handle or propagate the error as needed. diff --git a/src/event-log/subscription.ts b/src/event-log/subscription.ts index b9b6fa2d5..43eb7182f 100644 --- a/src/event-log/subscription.ts +++ b/src/event-log/subscription.ts @@ -1,7 +1,7 @@ import type { EventEmitter } from 'events'; -import type { Filter } from '../types/query-types.js'; import type { MessageStore } from '../types/message-store.js'; -import type { EmitFunction, EventMessageData, Subscription } from '../types/subscriptions.js'; +import type { EmitFunction, Subscription } from '../types/subscriptions.js'; +import type { Filter, KeyValues } from '../types/query-types.js'; import type { GenericMessage, GenericMessageHandler } from '../types/message-types.js'; import { FilterUtility } from '../utils/filter.js'; @@ -45,22 +45,13 @@ export class SubscriptionBase implements Subscription { return this.#id; } - protected matchMessages(tenant: string, current: EventMessageData, mostRecent?: EventMessageData): GenericMessage[] { - const emitArgs:GenericMessage[] = []; - if (tenant === this.tenant) { - if (FilterUtility.matchAnyFilter(current.indexes, this.filters)) { - emitArgs.push(current.message); - } else if (mostRecent !== undefined && FilterUtility.matchAnyFilter(mostRecent.indexes, this.filters)) { - emitArgs.push(current.message, mostRecent.message);; - } - } - return emitArgs; + protected matchFilters(tenant: string, indexes: KeyValues): boolean { + return tenant === this.tenant && FilterUtility.matchAnyFilter(indexes, this.filters); } - public listener: EmitFunction = (tenant, current, mostRecent):void => { - const emitArgs = this.matchMessages(tenant, current, mostRecent); - if (emitArgs.length > 0) { - this.eventEmitter.emit(this.eventChannel, ...emitArgs); + public listener: EmitFunction = (tenant, message, indexes):void => { + if (this.matchFilters(tenant, indexes)) { + this.eventEmitter.emit(this.eventChannel, message); } }; diff --git a/src/handlers/permissions-grant.ts b/src/handlers/permissions-grant.ts index 791979edc..59394010b 100644 --- a/src/handlers/permissions-grant.ts +++ b/src/handlers/permissions-grant.ts @@ -47,7 +47,7 @@ export class PermissionsGrantHandler implements MethodHandler { if (existingMessage === undefined) { await this.messageStore.put(tenant, message, indexes); await this.eventLog.append(tenant, messageCid, indexes); - this.eventStream.emit(tenant, { message, indexes }); + this.eventStream.emit(tenant, message, indexes); } return { diff --git a/src/handlers/permissions-request.ts b/src/handlers/permissions-request.ts index f9bb264fc..7cb1d9468 100644 --- a/src/handlers/permissions-request.ts +++ b/src/handlers/permissions-request.ts @@ -51,7 +51,7 @@ export class PermissionsRequestHandler implements MethodHandler { if (existingMessage === undefined) { await this.messageStore.put(tenant, message, indexes); await this.eventLog.append(tenant, messageCid, indexes); - this.eventStream.emit(tenant, { message, indexes }); + this.eventStream.emit(tenant, message, indexes); } return { diff --git a/src/handlers/permissions-revoke.ts b/src/handlers/permissions-revoke.ts index 5ba597307..a529b5b2e 100644 --- a/src/handlers/permissions-revoke.ts +++ b/src/handlers/permissions-revoke.ts @@ -96,7 +96,7 @@ export class PermissionsRevokeHandler implements MethodHandler { await this.eventLog.append(tenant, await Message.getCid(message), indexes); // emit revoke and exercise any revocation necessary within the event stream - this.eventStream.emit(tenant, { message, indexes }); + this.eventStream.emit(tenant, message, indexes); // Delete existing revokes which are all newer than the incoming message const removedRevokeCids: string[] = []; diff --git a/src/handlers/protocols-configure.ts b/src/handlers/protocols-configure.ts index 9baec7408..573cda1ec 100644 --- a/src/handlers/protocols-configure.ts +++ b/src/handlers/protocols-configure.ts @@ -66,7 +66,7 @@ export class ProtocolsConfigureHandler implements MethodHandler { await this.messageStore.put(tenant, message, indexes); const messageCid = await Message.getCid(message); await this.eventLog.append(tenant, messageCid, indexes); - this.eventStream.emit(tenant, { message, indexes }); + this.eventStream.emit(tenant, message, indexes); messageReply = { status: { code: 202, detail: 'Accepted' } diff --git a/src/handlers/records-delete.ts b/src/handlers/records-delete.ts index 21ab567c2..98ba7a591 100644 --- a/src/handlers/records-delete.ts +++ b/src/handlers/records-delete.ts @@ -95,11 +95,7 @@ export class RecordsDeleteHandler implements MethodHandler { const messageCid = await Message.getCid(message); await this.messageStore.put(tenant, message, indexes); await this.eventLog.append(tenant, messageCid, indexes); - - const latestRecordsWrite = newestExistingMessage as RecordsWriteMessage; - const mostRecentWrite = await RecordsWrite.parse(latestRecordsWrite); - const mostRecentWriteIndexes = await mostRecentWrite.constructIndexes(false); - this.eventStream.emit(tenant, { message, indexes }, { message: mostRecentWrite.message, indexes: mostRecentWriteIndexes }); + this.eventStream.emit(tenant, message, indexes); // delete all existing messages that are not newest, except for the initial write await StorageController.deleteAllOlderMessagesButKeepInitialWrite( diff --git a/src/handlers/records-subscribe.ts b/src/handlers/records-subscribe.ts index 1ae97db25..82a883e86 100644 --- a/src/handlers/records-subscribe.ts +++ b/src/handlers/records-subscribe.ts @@ -7,7 +7,6 @@ import type { EmitFunction, EventStream } from '../types/subscriptions.js'; import type { RecordsDeleteMessage, RecordsSubscribeMessage, RecordsSubscribeReply, RecordsWriteMessage } from '../types/records-types.js'; import { authenticate } from '../core/auth.js'; -import { FilterUtility } from '../utils/filter.js'; import { Message } from '../core/message.js'; import { messageReplyFromError } from '../core/message-reply.js'; import { ProtocolAuthorization } from '../core/protocol-authorization.js'; @@ -287,21 +286,14 @@ export class RecordsSubscriptionHandler extends SubscriptionBase { return new RecordsSubscriptionHandler({ ...options, id, recordsSubscribe }); } - public listener: EmitFunction = async (tenant, current, mostRecent):Promise => { - if (RecordsWrite.isRecordsWriteMessage(current.message) || RecordsDelete.isRecordsDeleteMessage(current.message)) { + public listener: EmitFunction = async (tenant, message, indexes):Promise => { + if ((RecordsWrite.isRecordsWriteMessage(message) || RecordsDelete.isRecordsDeleteMessage(message)) && this.matchFilters(tenant, indexes)) { try { - const emitArgs = []; - if (FilterUtility.matchAnyFilter(current.indexes, this.filters)) { - emitArgs.push(current.message); - } else if (mostRecent !== undefined && FilterUtility.matchAnyFilter(mostRecent.indexes, this.filters)) { - emitArgs.push(current.message, mostRecent.message); - } - if (emitArgs.length > 0) { - if (this.shouldAuthorize) { - await this.reauthorize(); - } - this.eventEmitter.emit(this.eventChannel, ...emitArgs); + if (this.shouldAuthorize) { + await this.reauthorize(); } + + this.eventEmitter.emit(this.eventChannel, message); } catch (error) { //todo: check for known authorization errors // console.log('reauthorize error', error); @@ -310,7 +302,7 @@ export class RecordsSubscriptionHandler extends SubscriptionBase { } }; - on(handler:(message: RecordsWriteMessage | RecordsDeleteMessage, updated?: boolean) => void): { off: () => void } { + on(handler:(message: RecordsWriteMessage | RecordsDeleteMessage) => void): { off: () => void } { this.eventEmitter.on(this.eventChannel, handler); return { off: (): void => { diff --git a/src/handlers/records-write.ts b/src/handlers/records-write.ts index 86a776a49..b852b3fd4 100644 --- a/src/handlers/records-write.ts +++ b/src/handlers/records-write.ts @@ -130,17 +130,7 @@ export class RecordsWriteHandler implements MethodHandler { const indexes = await recordsWrite.constructIndexes(isLatestBaseState); await this.messageStore.put(tenant, messageWithOptionalEncodedData, indexes); await this.eventLog.append(tenant, await Message.getCid(message), indexes); - - // we use the same KeyValues as the store indexes for the event stream match fields - // if it is not the initial write, we also include the indexes from the most recent write - let mostRecentMessage; - if (!newMessageIsInitialWrite && newestExistingMessage?.descriptor.method === DwnMethodName.Write) { - const newestExistingWrite = await RecordsWrite.parse(newestExistingMessage as RecordsQueryReplyEntry); - const newestExistingIndexes = await newestExistingWrite.constructIndexes(false); - mostRecentMessage = { message: newestExistingMessage, indexes: newestExistingIndexes }; - } - - this.eventStream.emit(tenant, { message, indexes }, mostRecentMessage); + this.eventStream.emit(tenant, message, indexes); } catch (error) { const e = error as any; if (e.code === DwnErrorCode.RecordsWriteMissingEncodedDataInPrevious || diff --git a/src/types/events-types.ts b/src/types/events-types.ts index fbd4afbe9..d87dcc401 100644 --- a/src/types/events-types.ts +++ b/src/types/events-types.ts @@ -1,7 +1,7 @@ import type { ProtocolsQueryFilter } from './protocols-types.js'; import type { AuthorizationModel, GenericMessage, GenericMessageReply } from './message-types.js'; import type { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; -import type { PaginationCursor, RangeCriterion, RangeFilter } from './query-types.js'; +import type { KeyValues, PaginationCursor, RangeCriterion, RangeFilter } from './query-types.js'; export type EventsMessageFilter = { interface?: string; @@ -47,7 +47,7 @@ export type EventsSubscribeMessage = { descriptor: EventsSubscribeDescriptor; }; -export type EventsHandler = (message: GenericMessage, updated?: boolean) => void; +export type EventsHandler = (message: GenericMessage, indexes: KeyValues) => void; export type EventsSubscription = { id: string; diff --git a/src/types/message-types.ts b/src/types/message-types.ts index 6b673cb2b..1b0c18722 100644 --- a/src/types/message-types.ts +++ b/src/types/message-types.ts @@ -75,7 +75,7 @@ export type QueryResultEntry = GenericMessage & { encodedData?: string; }; -export type GenericMessageHandler = (message: GenericMessage, updated?: boolean) => void; +export type GenericMessageHandler = (message: GenericMessage) => void; export type GenericMessageSubscription = { id: string; diff --git a/src/types/subscriptions.ts b/src/types/subscriptions.ts index 1b417c7bb..266b3a584 100644 --- a/src/types/subscriptions.ts +++ b/src/types/subscriptions.ts @@ -4,23 +4,17 @@ import type { Filter, KeyValues } from './query-types.js'; import type { GenericMessage, GenericMessageHandler, GenericMessageSubscription } from './message-types.js'; import type { RecordsSubscribeMessage, RecordsSubscription } from './records-types.js'; -export type EventMessageData = { - message: GenericMessage; - indexes: KeyValues; -}; - -export type EmitFunction = (tenant: string, message: EventMessageData, mostRecent?: EventMessageData) => void; +export type EmitFunction = (tenant: string, message: GenericMessage, indexes: KeyValues) => void; export interface EventStream { subscribe(tenant: string, message: EventsSubscribeMessage, filters: Filter[]): Promise; subscribe(tenant: string, message: RecordsSubscribeMessage, filters: Filter[]): Promise; subscribe(tenant: string, message: GenericMessage, filters: Filter[]): Promise; - emit(tenant: string, message: EventMessageData, mostRecentMessage?: EventMessageData): void; + emit(tenant: string, message: GenericMessage, indexes: KeyValues): void; open(): Promise; close(): Promise; } - export interface Subscription { id: string; listener: EmitFunction; diff --git a/tests/handlers/records-subscribe.spec.ts b/tests/handlers/records-subscribe.spec.ts index a07586c21..d6386e031 100644 --- a/tests/handlers/records-subscribe.spec.ts +++ b/tests/handlers/records-subscribe.spec.ts @@ -272,14 +272,6 @@ export function testRecordsSubscribeHandler(): void { expect(messageCids.length).to.equal(1); expect(messageCids[0]).to.equal(await Message.getCid(chatRecordForBob.message)); - - // delete the chat addressed to bob - const deleteChatForBob = await TestDataGenerator.generateRecordsDelete({ recordId: chatRecordForBob.message.recordId, author: alice }); - const deleteChatReply = await dwn.processMessage(alice.did, deleteChatForBob.message); - expect(deleteChatReply.status.code).to.equal(202); - - expect(messageCids.length).to.equal(2); - expect(messageCids[1]).to.equal(await Message.getCid(deleteChatForBob.message)); }); it('allows $globalRole authorized subscriptions', async () => { From 8b39c8c05d5a9e64e589e7c11e6f496f71ea0540 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Thu, 4 Jan 2024 10:31:32 -0500 Subject: [PATCH 08/44] update tests and comments --- src/core/protocol-authorization.ts | 7 +- src/core/records-grant-authorization.ts | 7 +- src/dwn.ts | 1 - src/event-log/event-stream.ts | 19 ++- src/handlers/events-subscribe.ts | 9 +- src/handlers/records-subscribe.ts | 14 +- src/interfaces/records-subscribe.ts | 10 +- src/types/protocols-types.ts | 4 +- ...m.spec.ts => event-stream-emitter.spec.ts} | 39 ++--- tests/scenarios/subscriptions.spec.ts | 152 +++++++++++++++++- tests/utils/test-data-generator.ts | 2 +- 11 files changed, 205 insertions(+), 59 deletions(-) rename tests/event-log/{event-stream.spec.ts => event-stream-emitter.spec.ts} (74%) diff --git a/src/core/protocol-authorization.ts b/src/core/protocol-authorization.ts index fb4d50ee0..957b604f9 100644 --- a/src/core/protocol-authorization.ts +++ b/src/core/protocol-authorization.ts @@ -153,19 +153,18 @@ export class ProtocolAuthorization { ); } - // maybe combine with query? public static async authorizeSubscription( tenant: string, incomingMessage: RecordsSubscribe, messageStore: MessageStore, ): Promise { - // validate that required properties exist in query filter + // validate that required properties exist in subscription filter const { protocol, protocolPath, contextId } = incomingMessage.message.descriptor.filter; // fetch the protocol definition const protocolDefinition = await ProtocolAuthorization.fetchProtocolDefinition( tenant, - protocol!, // authorizeQuery` is only called if `protocol` is present + protocol!, // `authorizeSubscription` is only called if `protocol` is present messageStore, ); @@ -190,7 +189,7 @@ export class ProtocolAuthorization { tenant, incomingMessage, inboundMessageRuleSet, - [], // ancestor chain is not relevant to subscribes + [], // ancestor chain is not relevant to subscriptions messageStore, ); } diff --git a/src/core/records-grant-authorization.ts b/src/core/records-grant-authorization.ts index 9f48c6c48..7062d40da 100644 --- a/src/core/records-grant-authorization.ts +++ b/src/core/records-grant-authorization.ts @@ -100,13 +100,16 @@ export class RecordsGrantAuthorization { * Authorizes the scope of a PermissionsGrant for RecordsSubscribe. * @param messageStore Used to check if the grant has been revoked. */ - public static async authorizeSubscribe( + public static async authorizeSubscribe(input: { recordsSubscribeMessage: RecordsSubscribeMessage, expectedGrantedToInGrant: string, expectedGrantedForInGrant: string, permissionsGrantMessage: PermissionsGrantMessage, messageStore: MessageStore, - ): Promise { + }): Promise { + const { + recordsSubscribeMessage, expectedGrantedToInGrant, expectedGrantedForInGrant, permissionsGrantMessage, messageStore + } = input; await GrantAuthorization.performBaseValidation({ incomingMessage: recordsSubscribeMessage, diff --git a/src/dwn.ts b/src/dwn.ts index 15bb80cdc..c4f63f748 100644 --- a/src/dwn.ts +++ b/src/dwn.ts @@ -61,7 +61,6 @@ export class Dwn { ), [DwnInterfaceName.Events+ DwnMethodName.Subscribe]: new EventsSubscribeHandler( this.didResolver, - this.messageStore, this.eventStream, ), [DwnInterfaceName.Messages + DwnMethodName.Get]: new MessagesGetHandler( diff --git a/src/event-log/event-stream.ts b/src/event-log/event-stream.ts index d526921d5..f8d7ee98b 100644 --- a/src/event-log/event-stream.ts +++ b/src/event-log/event-stream.ts @@ -25,7 +25,6 @@ type EventStreamConfig = { export class EventStreamEmitter implements EventStream { private eventEmitter: EventEmitter; - private didResolver: DidResolver; private messageStore: MessageStore; private reauthorizationTTL: number; @@ -33,7 +32,6 @@ export class EventStreamEmitter implements EventStream { private subscriptions: Map = new Map(); constructor(config: EventStreamConfig) { - this.didResolver = config.didResolver; this.messageStore = config.messageStore; this.reauthorizationTTL = config.reauthorizationTTL ?? 0; // if set to zero it does not reauthorize @@ -58,20 +56,25 @@ export class EventStreamEmitter implements EventStream { return subscription; } - const unsubscribe = async ():Promise => { await this.unsubscribe(messageCid); }; - if (RecordsSubscribe.isRecordsSubscribeMessage(message)) { subscription = await RecordsSubscriptionHandler.create({ tenant, message, filters, - unsubscribe, + unsubscribe : () => this.unsubscribe(messageCid), eventEmitter : this.eventEmitter, messageStore : this.messageStore, reauthorizationTTL : this.reauthorizationTTL, }); } else if (EventsSubscribe.isEventsSubscribeMessage(message)) { - subscription = await EventsSubscriptionHandler.create(tenant, message, filters, this.eventEmitter, this.messageStore, unsubscribe); + subscription = await EventsSubscriptionHandler.create({ + tenant, + message, + filters, + unsubscribe : () => this.unsubscribe(messageCid), + eventEmitter : this.eventEmitter, + messageStore : this.messageStore + }); } else { throw new DwnError(DwnErrorCode.EventStreamSubscriptionNotSupported, 'not a supported subscription message'); } @@ -102,8 +105,8 @@ export class EventStreamEmitter implements EventStream { emit(tenant: string, message: GenericMessage, indexes: KeyValues): void { if (!this.isOpen) { - //todo: dwn error - throw new Error('Event stream is not open. Cannot add to the stream.'); + // silently ignore. + return; } try { this.eventEmitter.emit(this.eventChannel, tenant, message, indexes); diff --git a/src/handlers/events-subscribe.ts b/src/handlers/events-subscribe.ts index 522bc4a08..2321df9fa 100644 --- a/src/handlers/events-subscribe.ts +++ b/src/handlers/events-subscribe.ts @@ -15,7 +15,6 @@ import { authenticate, authorizeOwner } from '../core/auth.js'; export class EventsSubscribeHandler implements MethodHandler { constructor( private didResolver: DidResolver, - private messageStore: MessageStore, private eventStream: EventStream ) {} @@ -54,16 +53,16 @@ export class EventsSubscribeHandler implements MethodHandler { } export class EventsSubscriptionHandler extends SubscriptionBase { - public static async create( + public static async create(input: { tenant: string, message: EventsSubscribeMessage, filters: Filter[], eventEmitter: EventEmitter, messageStore: MessageStore, unsubscribe: () => Promise - ): Promise { - const id = await Message.getCid(message); - return new EventsSubscriptionHandler({ tenant, message, id, filters, eventEmitter, messageStore, unsubscribe }); + }): Promise { + const id = await Message.getCid(input.message); + return new EventsSubscriptionHandler({ ...input, id }); } }; diff --git a/src/handlers/records-subscribe.ts b/src/handlers/records-subscribe.ts index 82a883e86..c67716a7b 100644 --- a/src/handlers/records-subscribe.ts +++ b/src/handlers/records-subscribe.ts @@ -60,12 +60,6 @@ export class RecordsSubscribeHandler implements MethodHandler { }; } - // 1) owner filters - // 2) public filters - // 3) authorized filters - // a) protocol authorized - // b) grant authorized - /** * Fetches the records as the owner of the DWN with no additional filtering. */ @@ -272,7 +266,7 @@ export class RecordsSubscriptionHandler extends SubscriptionBase { await RecordsSubscribeHandler.authorizeRecordsSubscribe(this.tenant, this.recordsSubscribe, this.messageStore); } - public static async create(options: { + public static async create(input: { tenant: string, message: RecordsSubscribeMessage, filters: Filter[], @@ -281,9 +275,9 @@ export class RecordsSubscriptionHandler extends SubscriptionBase { unsubscribe: () => Promise; reauthorizationTTL: number }): Promise { - const id = await Message.getCid(options.message); - const recordsSubscribe = await RecordsSubscribe.parse(options.message); - return new RecordsSubscriptionHandler({ ...options, id, recordsSubscribe }); + const id = await Message.getCid(input.message); + const recordsSubscribe = await RecordsSubscribe.parse(input.message); + return new RecordsSubscriptionHandler({ ...input, id, recordsSubscribe }); } public listener: EmitFunction = async (tenant, message, indexes):Promise => { diff --git a/src/interfaces/records-subscribe.ts b/src/interfaces/records-subscribe.ts index dcb8c5953..c4be87c9f 100644 --- a/src/interfaces/records-subscribe.ts +++ b/src/interfaces/records-subscribe.ts @@ -93,10 +93,14 @@ export class RecordsSubscribe extends AbstractMessage { * @param messageStore Used to check if the grant has been revoked. */ public async authorizeDelegate(messageStore: MessageStore): Promise { - const grantedTo = this.signer!; - const grantedFor = this.author!; const delegatedGrant = this.message.authorization!.authorDelegatedGrant!; - await RecordsGrantAuthorization.authorizeSubscribe(this.message, grantedTo, grantedFor, delegatedGrant, messageStore); + await RecordsGrantAuthorization.authorizeSubscribe({ + recordsSubscribeMessage : this.message, + expectedGrantedToInGrant : this.signer!, + expectedGrantedForInGrant : this.author!, + permissionsGrantMessage : delegatedGrant, + messageStore + }); } public static isRecordsSubscribeMessage(message: GenericMessage): message is RecordsSubscribeMessage { diff --git a/src/types/protocols-types.ts b/src/types/protocols-types.ts index f3e4eabc4..00be73bb8 100644 --- a/src/types/protocols-types.ts +++ b/src/types/protocols-types.ts @@ -90,8 +90,8 @@ export type ProtocolActionRule = { /** * Action that the actor can perform. - * May be 'query' | 'read' | 'write' | 'update' | 'delete'. - * 'query' is only supported for `role` rules. + * May be 'query' | 'read' | 'write' | 'update' | 'delete' | 'subscribe'. + * 'query' and 'subscribe' are only supported for `role` rules. */ can: string; }; diff --git a/tests/event-log/event-stream.spec.ts b/tests/event-log/event-stream-emitter.spec.ts similarity index 74% rename from tests/event-log/event-stream.spec.ts rename to tests/event-log/event-stream-emitter.spec.ts index ca7141e6c..195f84259 100644 --- a/tests/event-log/event-stream.spec.ts +++ b/tests/event-log/event-stream-emitter.spec.ts @@ -11,7 +11,7 @@ import chai, { expect } from 'chai'; chai.use(chaiAsPromised); -describe('Event Stream Tests', () => { +describe('EventStreamEmitter', () => { let eventStream: EventStreamEmitter; let didResolver: DidResolver; let messageStore: MessageStore; @@ -22,8 +22,6 @@ describe('Event Stream Tests', () => { blockstoreLocation : 'TEST-MESSAGESTORE', indexLocation : 'TEST-INDEX' }); - // Create a new instance of EventStream before each test - eventStream = new EventStreamEmitter({ didResolver, messageStore }); }); beforeEach(async () => { @@ -36,31 +34,21 @@ describe('Event Stream Tests', () => { await eventStream.close(); }); - xit('test add callback', async () => { - }); - - xit('test bad message', async () => { - }); - - xit('should throw an error when adding events to a closed stream', async () => { - }); - - xit('should handle concurrent event sending', async () => { - }); - - xit('test emitter chaining', async () => { - }); - it('should remove listeners when unsubscribe method is used', async () => { const alice = await DidKeyResolver.generate(); + const emitter = new EventEmitter(); - const eventEmitter = new EventStreamEmitter({ emitter, messageStore, didResolver }); + eventStream = new EventStreamEmitter({ emitter, messageStore, didResolver }); + + // count the `events_bus` listeners, which represents all listeners expect(emitter.listenerCount('events_bus')).to.equal(0); + // initiate a subscription, which should add a listener const { message } = await TestDataGenerator.generateRecordsSubscribe({ author: alice }); - const sub = await eventEmitter.subscribe(alice.did, message, []); + const sub = await eventStream.subscribe(alice.did, message, []); expect(emitter.listenerCount('events_bus')).to.equal(1); + // close the subscription, which should remove the listener await sub.close(); expect(emitter.listenerCount('events_bus')).to.equal(0); }); @@ -68,16 +56,23 @@ describe('Event Stream Tests', () => { it('should remove listeners when off method is used', async () => { const alice = await DidKeyResolver.generate(); const emitter = new EventEmitter(); - const eventEmitter = new EventStreamEmitter({ emitter, messageStore, didResolver }); + eventStream = new EventStreamEmitter({ emitter, messageStore, didResolver }); + + // initiate a subscription const { message } = await TestDataGenerator.generateRecordsSubscribe(); - const sub = await eventEmitter.subscribe(alice.did, message, []); + const sub = await eventStream.subscribe(alice.did, message, []); const messageCid = await Message.getCid(message); + + // the listener count for the specific subscription should be at zero expect(emitter.listenerCount(`${alice.did}_${messageCid}`)).to.equal(0); const handler = (_:GenericMessage):void => {}; const on1 = sub.on(handler); const on2 = sub.on(handler); + + // after registering two handlers, there should be two listeners expect(emitter.listenerCount(`${alice.did}_${messageCid}`)).to.equal(2); + // un-register the handlers one by one, checking the listener count after each. on1.off(); expect(emitter.listenerCount(`${alice.did}_${messageCid}`)).to.equal(1); on2.off(); diff --git a/tests/scenarios/subscriptions.spec.ts b/tests/scenarios/subscriptions.spec.ts index 0ffda0984..58ac6dfcb 100644 --- a/tests/scenarios/subscriptions.spec.ts +++ b/tests/scenarios/subscriptions.spec.ts @@ -196,7 +196,157 @@ export function testSubscriptionScenarios(): void { }); describe('events subscribe', () => { - xit('filters by protocol', async () => { + it('all events', async () => { + const alice = await DidKeyResolver.generate(); + + // subscribe to all messages + const eventsSubscription = await TestDataGenerator.generateEventsSubscribe({ author: alice }); + const eventsSubscriptionReply = await dwn.processMessage(alice.did, eventsSubscription.message); + expect(eventsSubscriptionReply.status.code).to.equal(200); + expect(eventsSubscriptionReply.subscription?.id).to.equal(await Message.getCid(eventsSubscription.message)); + + // create a handler that adds the messageCid of each message to an array. + const messageCids: string[] = []; + const messageHandler = async (message: GenericMessage): Promise => { + const messageCid = await Message.getCid(message); + messageCids.push(messageCid); + }; + const handler = eventsSubscriptionReply.subscription!.on(messageHandler); + + // generate various messages + const write1 = await TestDataGenerator.generateRecordsWrite({ author: alice }); + const write1MessageCid = await Message.getCid(write1.message); + const write1Reply = await dwn.processMessage(alice.did, write1.message, write1.dataStream); + expect(write1Reply.status.code).to.equal(202); + + const grant1 = await TestDataGenerator.generatePermissionsGrant({ author: alice }); + const grant1MessageCid = await Message.getCid(grant1.message); + const grant1Reply = await dwn.processMessage(alice.did, grant1.message); + expect(grant1Reply.status.code).to.equal(202); + + const protocol1 = await TestDataGenerator.generateProtocolsConfigure({ author: alice }); + const protocol1MessageCid = await Message.getCid(protocol1.message); + const protocol1Reply = await dwn.processMessage(alice.did, protocol1.message); + expect(protocol1Reply.status.code).to.equal(202); + + const deleteWrite1 = await TestDataGenerator.generateRecordsDelete({ author: alice, recordId: write1.message.recordId }); + const delete1MessageCid = await Message.getCid(deleteWrite1.message); + const deleteWrite1Reply = await dwn.processMessage(alice.did, deleteWrite1.message); + expect(deleteWrite1Reply.status.code).to.equal(202); + + // unregister the handler + handler.off(); + + // create a message after + const write2 = await TestDataGenerator.generateRecordsWrite({ author: alice }); + const write2Reply = await dwn.processMessage(alice.did, write2.message, write2.dataStream); + expect(write2Reply.status.code).to.equal(202); + + await Time.minimalSleep(); + + // test the messageCids array for the appropriate messages + expect(messageCids.length).to.equal(4); + expect(messageCids).to.eql([ write1MessageCid, grant1MessageCid, protocol1MessageCid, delete1MessageCid ]); + }); + + it('filters by protocol', async () => { + const alice = await DidKeyResolver.generate(); + + // create a proto1 + const protoConf1 = await TestDataGenerator.generateProtocolsConfigure({ + author : alice, + protocolDefinition : { ...freeForAll, protocol: 'proto1' } + }); + + const postProperties = { + protocolPath : 'post', + schema : freeForAll.types.post.schema, + dataFormat : freeForAll.types.post.dataFormats[0], + }; + + // create a proto1 + const proto1 = protoConf1.message.descriptor.definition.protocol; + const protoConf1Response = await dwn.processMessage(alice.did, protoConf1.message); + expect(protoConf1Response.status.code).equals(202); + + // create a proto2 + const protoConf2 = await TestDataGenerator.generateProtocolsConfigure({ + author : alice, + protocolDefinition : { ...freeForAll, protocol: 'proto2' } + }); + const proto2 = protoConf2.message.descriptor.definition.protocol; + const protoConf2Response = await dwn.processMessage(alice.did, protoConf2.message); + expect(protoConf2Response.status.code).equals(202); + + // we will add messageCids to these arrays as they are received by their handler to check against later + const proto1Messages:string[] = []; + const proto2Messages:string[] = []; + + // subscribe to proto1 messages + const proto1Subscription = await TestDataGenerator.generateEventsSubscribe({ author: alice, filters: [{ protocol: proto1 }] }); + const proto1SubscriptionReply = await dwn.processMessage(alice.did, proto1Subscription.message); + expect(proto1SubscriptionReply.status.code).to.equal(200); + expect(proto1SubscriptionReply.subscription?.id).to.equal(await Message.getCid(proto1Subscription.message)); + + // we add a handler to the subscription and add the messageCid to the appropriate array + const proto1Handler = async (message:GenericMessage):Promise => { + const messageCid = await Message.getCid(message); + proto1Messages.push(messageCid); + }; + const proto1Sub = proto1SubscriptionReply.subscription!.on(proto1Handler); + + // subscribe to proto2 messages + const proto2Subscription = await TestDataGenerator.generateEventsSubscribe({ author: alice, filters: [{ protocol: proto2 }] }); + const proto2SubscriptionReply = await dwn.processMessage(alice.did, proto2Subscription.message); + expect(proto2SubscriptionReply.status.code).to.equal(200); + expect(proto2SubscriptionReply.subscription?.id).to.equal(await Message.getCid(proto2Subscription.message)); + // we add a handler to the subscription and add the messageCid to the appropriate array + const proto2Handler = async (message:GenericMessage):Promise => { + const messageCid = await Message.getCid(message); + proto2Messages.push(messageCid); + }; + proto2SubscriptionReply.subscription!.on(proto2Handler); + + // create some random record, will not show up in records subscription + const write1Random = await TestDataGenerator.generateRecordsWrite({ author: alice }); + const write1RandomResponse = await dwn.processMessage(alice.did, write1Random.message, write1Random.dataStream); + expect(write1RandomResponse.status.code).to.equal(202); + + // create a record for proto1 + const write1proto1 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto1, ...postProperties }); + const write1Response = await dwn.processMessage(alice.did, write1proto1.message, write1proto1.dataStream); + expect(write1Response.status.code).equals(202); + + // create a record for proto2 + const write1proto2 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto2, ...postProperties }); + const write1Proto2Response = await dwn.processMessage(alice.did, write1proto2.message, write1proto2.dataStream); + expect(write1Proto2Response.status.code).equals(202); + + expect(proto1Messages.length).to.equal(1, 'proto1'); + expect(proto1Messages).to.include(await Message.getCid(write1proto1.message)); + expect(proto2Messages.length).to.equal(1, 'proto2'); + expect(proto2Messages).to.include(await Message.getCid(write1proto2.message)); + + // remove listener for proto1 + proto1Sub.off(); + + // create another record for proto1 + const write2proto1 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto1, ...postProperties }); + const write2Response = await dwn.processMessage(alice.did, write2proto1.message, write2proto1.dataStream); + expect(write2Response.status.code).equals(202); + + // create another record for proto2 + const write2proto2 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto2, ...postProperties }); + const write2Proto2Response = await dwn.processMessage(alice.did, write2proto2.message, write2proto2.dataStream); + expect(write2Proto2Response.status.code).equals(202); + + // proto1 messages from handler do not change. + expect(proto1Messages.length).to.equal(1, 'proto1 after subscription.off()'); + expect(proto1Messages).to.include(await Message.getCid(write1proto1.message)); + + //proto2 messages from handler have the new message. + expect(proto2Messages.length).to.equal(2, 'proto2 after subscription.off()'); + expect(proto2Messages).to.have.members([await Message.getCid(write1proto2.message), await Message.getCid(write2proto2.message)]); }); }); }); diff --git a/tests/utils/test-data-generator.ts b/tests/utils/test-data-generator.ts index 8ef996938..9015faf2d 100644 --- a/tests/utils/test-data-generator.ts +++ b/tests/utils/test-data-generator.ts @@ -269,7 +269,7 @@ export type GenerateEventsQueryOutput = { export type GenerateEventsSubscribeInput = { author: Persona; - filters: EventsFilter[]; + filters?: EventsFilter[]; }; export type GenerateEventsSubscribeOutput = { From 51d0a128c5ede9a2472135f9abafdbe102481d17 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Fri, 5 Jan 2024 14:46:16 -0500 Subject: [PATCH 09/44] update events filters --- src/handlers/events-query.ts | 3 +- src/handlers/events-subscribe.ts | 6 ++- src/interfaces/events-query.ts | 86 ++----------------------------- src/utils/events.ts | 87 ++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 84 deletions(-) create mode 100644 src/utils/events.ts diff --git a/src/handlers/events-query.ts b/src/handlers/events-query.ts index 6e7f38eb1..179e9ec35 100644 --- a/src/handlers/events-query.ts +++ b/src/handlers/events-query.ts @@ -3,6 +3,7 @@ import type { EventLog } from '../types/event-log.js'; import type { MethodHandler } from '../types/method-handler.js'; import type { EventsQueryMessage, EventsQueryReply } from '../types/events-types.js'; +import { Events } from '../utils/events.js'; import { EventsQuery } from '../interfaces/events-query.js'; import { messageReplyFromError } from '../core/message-reply.js'; import { authenticate, authorizeOwner } from '../core/auth.js'; @@ -31,7 +32,7 @@ export class EventsQueryHandler implements MethodHandler { return messageReplyFromError(e, 401); } - const logFilters = EventsQuery.convertFilters(message.descriptor.filters); + const logFilters = Events.convertFilters(message.descriptor.filters); const { events, cursor } = await this.eventLog.queryEvents(tenant, logFilters, message.descriptor.cursor); return { diff --git a/src/handlers/events-subscribe.ts b/src/handlers/events-subscribe.ts index 2321df9fa..380ddc309 100644 --- a/src/handlers/events-subscribe.ts +++ b/src/handlers/events-subscribe.ts @@ -6,6 +6,7 @@ import type { MessageStore } from '../types/message-store.js'; import type { MethodHandler } from '../types/method-handler.js'; import type { EventsSubscribeMessage, EventsSubscribeReply } from '../types/events-types.js'; +import { Events } from '../utils/events.js'; import { EventsSubscribe } from '../interfaces/events-subscribe.js'; import { Message } from '../core/message.js'; import { messageReplyFromError } from '../core/message-reply.js'; @@ -40,7 +41,10 @@ export class EventsSubscribeHandler implements MethodHandler { } try { - const subscription = await this.eventStream.subscribe(tenant, message, []); + + const { filters } = message.descriptor; + const eventsFilters = Events.convertFilters(filters); + const subscription = await this.eventStream.subscribe(tenant, message, eventsFilters); const messageReply: EventsSubscribeReply = { status: { code: 200, detail: 'OK' }, subscription, diff --git a/src/interfaces/events-query.ts b/src/interfaces/events-query.ts index 1ee4c30c1..a30389f34 100644 --- a/src/interfaces/events-query.ts +++ b/src/interfaces/events-query.ts @@ -1,13 +1,10 @@ -import type { ProtocolsQueryFilter } from '../types/protocols-types.js'; +import type { PaginationCursor } from '../types/query-types.js'; import type { Signer } from '../types/signer.js'; -import type { EventsFilter, EventsMessageFilter, EventsQueryDescriptor, EventsQueryMessage, EventsRecordsFilter } from '../types/events-types.js'; -import type { Filter, PaginationCursor } from '../types/query-types.js'; +import type { EventsFilter, EventsQueryDescriptor, EventsQueryMessage } from '../types/events-types.js'; import { AbstractMessage } from '../core/abstract-message.js'; -import { FilterUtility } from '../utils/filter.js'; +import { Events } from '../utils/events.js'; import { Message } from '../core/message.js'; -import { ProtocolsQuery } from '../interfaces/protocols-query.js'; -import { Records } from '../utils/records.js'; import { removeUndefinedProperties } from '../utils/object.js'; import { Time } from '../utils/time.js'; import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; @@ -32,7 +29,7 @@ export class EventsQuery extends AbstractMessage{ const descriptor: EventsQueryDescriptor = { interface : DwnInterfaceName.Events, method : DwnMethodName.Query, - filters : this.normalizeFilters(options.filters), + filters : Events.normalizeFilters(options.filters), messageTimestamp : options.messageTimestamp ?? Time.getCurrentTimestamp(), cursor : options.cursor, }; @@ -46,79 +43,4 @@ export class EventsQuery extends AbstractMessage{ return new EventsQuery(message); } - - private static normalizeFilters(filters: EventsFilter[]): EventsFilter[] { - - const eventsQueryFilters: EventsFilter[] = []; - - // normalize each filter individually by the type of filter it is. - for (const filter of filters) { - if (this.isMessagesFilter(filter)) { - eventsQueryFilters.push(filter); - } else if (this.isRecordsFilter(filter)) { - eventsQueryFilters.push(Records.normalizeFilter(filter)); - } else if (this.isProtocolFilter(filter)) { - const protocolFilter = ProtocolsQuery.normalizeFilter(filter); - eventsQueryFilters.push(protocolFilter!); - } - } - - return eventsQueryFilters; - } - - - /** - * Converts an incoming array of EventsFilter into an array of Filter usable by EventLog. - * - * @param filters An array of EventsFilter - * @returns {Filter[]} an array of generic Filter able to be used when querying. - */ - public static convertFilters(filters: EventsFilter[]): Filter[] { - - const eventsQueryFilters: Filter[] = []; - - // normalize each filter individually by the type of filter it is. - for (const filter of filters) { - if (this.isMessagesFilter(filter)) { - eventsQueryFilters.push(this.convertFilter(filter)); - } else if (this.isRecordsFilter(filter)) { - eventsQueryFilters.push(Records.convertFilter(filter)); - } else if (this.isProtocolFilter(filter)) { - eventsQueryFilters.push(filter); - } - } - - return eventsQueryFilters; - } - - private static convertFilter(filter: EventsMessageFilter): Filter { - const filterCopy = { ...filter } as Filter; - - const { dateUpdated } = filter; - const messageTimestampFilter = dateUpdated ? FilterUtility.convertRangeCriterion(dateUpdated) : undefined; - if (messageTimestampFilter) { - filterCopy.messageTimestamp = messageTimestampFilter; - delete filterCopy.dateUpdated; - } - return filterCopy as Filter; - } - - private static isMessagesFilter(filter: EventsFilter): filter is EventsMessageFilter { - return 'method' in filter || 'interface' in filter || 'dateUpdated' in filter || 'author' in filter; - } - - private static isRecordsFilter(filter: EventsFilter): filter is EventsRecordsFilter { - return 'dateCreated' in filter || - 'dataFormat' in filter || - 'dataSize' in filter || - 'parentId' in filter || - 'recordId' in filter || - 'schema' in filter || - ('protocolPath' in filter && 'protocol' in filter) || - 'recipient' in filter; - } - - private static isProtocolFilter(filter: EventsFilter): filter is ProtocolsQueryFilter { - return 'protocol' in filter; - } } \ No newline at end of file diff --git a/src/utils/events.ts b/src/utils/events.ts new file mode 100644 index 000000000..5dc87ae16 --- /dev/null +++ b/src/utils/events.ts @@ -0,0 +1,87 @@ +import type { Filter } from '../types/query-types.js'; +import type { ProtocolsQueryFilter } from '../types/protocols-types.js'; +import type { EventsFilter, EventsMessageFilter, EventsRecordsFilter } from '../types/events-types.js'; + +import { FilterUtility } from '../utils/filter.js'; +import { ProtocolsQuery } from '../interfaces/protocols-query.js'; +import { Records } from '../utils/records.js'; + + +/** + * Class containing Events related utility methods. + */ +export class Events { + public static normalizeFilters(filters: EventsFilter[]): EventsFilter[] { + + const eventsQueryFilters: EventsFilter[] = []; + + // normalize each filter individually by the type of filter it is. + for (const filter of filters) { + if (this.isMessagesFilter(filter)) { + eventsQueryFilters.push(filter); + } else if (this.isRecordsFilter(filter)) { + eventsQueryFilters.push(Records.normalizeFilter(filter)); + } else if (this.isProtocolFilter(filter)) { + const protocolFilter = ProtocolsQuery.normalizeFilter(filter); + eventsQueryFilters.push(protocolFilter!); + } + } + + return eventsQueryFilters; + } + + /** + * Converts an incoming array of EventsFilter into an array of Filter usable by EventLog. + * + * @param filters An array of EventsFilter + * @returns {Filter[]} an array of generic Filter able to be used when querying. + */ + public static convertFilters(filters: EventsFilter[]): Filter[] { + + const eventsQueryFilters: Filter[] = []; + + // normalize each filter individually by the type of filter it is. + for (const filter of filters) { + if (this.isMessagesFilter(filter)) { + eventsQueryFilters.push(this.convertFilter(filter)); + } else if (this.isRecordsFilter(filter)) { + eventsQueryFilters.push(Records.convertFilter(filter)); + } else if (this.isProtocolFilter(filter)) { + eventsQueryFilters.push(filter); + } + } + + return eventsQueryFilters; + } + + private static convertFilter(filter: EventsMessageFilter): Filter { + const filterCopy = { ...filter } as Filter; + + const { dateUpdated } = filter; + const messageTimestampFilter = dateUpdated ? FilterUtility.convertRangeCriterion(dateUpdated) : undefined; + if (messageTimestampFilter) { + filterCopy.messageTimestamp = messageTimestampFilter; + delete filterCopy.dateUpdated; + } + return filterCopy as Filter; + } + + private static isMessagesFilter(filter: EventsFilter): filter is EventsMessageFilter { + return 'method' in filter || 'interface' in filter || 'dateUpdated' in filter || 'author' in filter; + } + + private static isRecordsFilter(filter: EventsFilter): filter is EventsRecordsFilter { + return 'dateCreated' in filter || + 'dataFormat' in filter || + 'dataSize' in filter || + 'parentId' in filter || + 'recordId' in filter || + 'schema' in filter || + ('protocolPath' in filter && 'protocol' in filter) || + 'recipient' in filter; + } + + private static isProtocolFilter(filter: EventsFilter): filter is ProtocolsQueryFilter { + return 'protocol' in filter; + } +} \ No newline at end of file From fd28d09176a74891738f6b20ad9620da31c64038 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Tue, 9 Jan 2024 11:56:20 -0500 Subject: [PATCH 10/44] refactor EventStream interface, added new test scaffolding --- src/core/dwn-error.ts | 1 + src/dwn.ts | 3 +- src/event-log/event-stream.ts | 21 +++---- src/handlers/events-subscribe.ts | 3 +- src/handlers/records-subscribe.ts | 5 +- src/types/events-types.ts | 4 +- src/types/records-types.ts | 2 +- src/types/subscriptions.ts | 7 ++- tests/dwn.spec.ts | 5 +- tests/event-log/event-stream-emitter.spec.ts | 17 ++---- tests/event-log/event-stream.spec.ts | 29 ++++++++++ tests/handlers/events-get.spec.ts | 4 +- tests/handlers/events-query.spec.ts | 4 +- tests/handlers/events-subscribe.spec.ts | 4 +- tests/handlers/messages-get.spec.ts | 5 +- tests/handlers/permissions-grant.spec.ts | 5 +- tests/handlers/permissions-request.spec.ts | 5 +- tests/handlers/permissions-revoke.spec.ts | 33 ++++------- tests/handlers/protocols-configure.spec.ts | 6 +- tests/handlers/protocols-query.spec.ts | 5 +- tests/handlers/records-delete.spec.ts | 5 +- tests/handlers/records-query.spec.ts | 6 +- tests/handlers/records-read.spec.ts | 6 +- tests/handlers/records-subscribe.spec.ts | 6 +- tests/handlers/records-write.spec.ts | 6 +- tests/scenarios/delegated-grant.spec.ts | 6 +- tests/scenarios/end-to-end-tests.spec.ts | 6 +- tests/scenarios/events-query.spec.ts | 5 +- tests/scenarios/subscriptions.spec.ts | 61 +++++++++++++++++--- tests/test-event-stream.ts | 28 +++++++++ 30 files changed, 205 insertions(+), 98 deletions(-) create mode 100644 tests/event-log/event-stream.spec.ts create mode 100644 tests/test-event-stream.ts diff --git a/src/core/dwn-error.ts b/src/core/dwn-error.ts index 8f31464f3..3750c682b 100644 --- a/src/core/dwn-error.ts +++ b/src/core/dwn-error.ts @@ -104,6 +104,7 @@ export enum DwnErrorCode { RecordsReadReturnedMultiple = 'RecordsReadReturnedMultiple', RecordsReadAuthorizationFailed = 'RecordsReadAuthorizationFailed', RecordsSubscribeFilterMissingRequiredProperties = 'RecordsSubscribeFilterMissingRequiredProperties', + RecordsSubscribeUnauthorized = 'RecordsSubscribeUnauthorized', RecordsSchemasDerivationSchemeMissingSchema = 'RecordsSchemasDerivationSchemeMissingSchema', RecordsValidateIntegrityDelegatedGrantAndIdExistenceMismatch = 'RecordsValidateIntegrityDelegatedGrantAndIdExistenceMismatch', RecordsValidateIntegrityGrantedToAndSignerMismatch = 'RecordsValidateIntegrityGrantedToAndSignerMismatch', diff --git a/src/dwn.ts b/src/dwn.ts index c4f63f748..26c26f0f0 100644 --- a/src/dwn.ts +++ b/src/dwn.ts @@ -61,6 +61,7 @@ export class Dwn { ), [DwnInterfaceName.Events+ DwnMethodName.Subscribe]: new EventsSubscribeHandler( this.didResolver, + this.messageStore, this.eventStream, ), [DwnInterfaceName.Messages + DwnMethodName.Get]: new MessagesGetHandler( @@ -135,7 +136,7 @@ export class Dwn { public static async create(config: DwnConfig): Promise { config.didResolver ??= new DidResolver(); config.tenantGate ??= new AllowAllTenantGate(); - config.eventStream ??= new EventStreamEmitter({ messageStore: config.messageStore, didResolver: config.didResolver }); + config.eventStream ??= new EventStreamEmitter(); const dwn = new Dwn(config); await dwn.open(); diff --git a/src/event-log/event-stream.ts b/src/event-log/event-stream.ts index f8d7ee98b..bebb03cb6 100644 --- a/src/event-log/event-stream.ts +++ b/src/event-log/event-stream.ts @@ -1,4 +1,3 @@ -import type { DidResolver } from '../did/did-resolver.js'; import type { MessageStore } from '../types/message-store.js'; import type { EventsSubscribeMessage, EventsSubscription } from '../types/events-types.js'; import type { EventStream, Subscription } from '../types/subscriptions.js'; @@ -18,25 +17,21 @@ const eventChannel = 'events'; type EventStreamConfig = { emitter?: EventEmitter; - messageStore: MessageStore; - didResolver: DidResolver; reauthorizationTTL?: number; }; export class EventStreamEmitter implements EventStream { private eventEmitter: EventEmitter; - private messageStore: MessageStore; private reauthorizationTTL: number; private isOpen: boolean = false; private subscriptions: Map = new Map(); - constructor(config: EventStreamConfig) { - this.messageStore = config.messageStore; - this.reauthorizationTTL = config.reauthorizationTTL ?? 0; // if set to zero it does not reauthorize + constructor(config?: EventStreamConfig) { + this.reauthorizationTTL = config?.reauthorizationTTL || 0; // if set to zero it does not reauthorize // we capture the rejections and currently just log the errors that are produced - this.eventEmitter = config.emitter || new EventEmitter({ captureRejections: true }); + this.eventEmitter = config?.emitter || new EventEmitter({ captureRejections: true }); } private get eventChannel(): string { @@ -47,9 +42,9 @@ export class EventStreamEmitter implements EventStream { console.error('event emitter error', error); }; - async subscribe(tenant: string, message: EventsSubscribeMessage, filters: Filter[]): Promise; - async subscribe(tenant: string, message: RecordsSubscribeMessage, filters: Filter[]): Promise; - async subscribe(tenant: string, message: GenericMessage, filters: Filter[]): Promise { + async subscribe(tenant: string, message: EventsSubscribeMessage, filters: Filter[], messageStore: MessageStore): Promise; + async subscribe(tenant: string, message: RecordsSubscribeMessage, filters: Filter[], messageStore: MessageStore): Promise; + async subscribe(tenant: string, message: GenericMessage, filters: Filter[], messageStore: MessageStore): Promise { const messageCid = await Message.getCid(message); let subscription = this.subscriptions.get(messageCid); if (subscription !== undefined) { @@ -61,9 +56,9 @@ export class EventStreamEmitter implements EventStream { tenant, message, filters, + messageStore, unsubscribe : () => this.unsubscribe(messageCid), eventEmitter : this.eventEmitter, - messageStore : this.messageStore, reauthorizationTTL : this.reauthorizationTTL, }); } else if (EventsSubscribe.isEventsSubscribeMessage(message)) { @@ -71,9 +66,9 @@ export class EventStreamEmitter implements EventStream { tenant, message, filters, + messageStore, unsubscribe : () => this.unsubscribe(messageCid), eventEmitter : this.eventEmitter, - messageStore : this.messageStore }); } else { throw new DwnError(DwnErrorCode.EventStreamSubscriptionNotSupported, 'not a supported subscription message'); diff --git a/src/handlers/events-subscribe.ts b/src/handlers/events-subscribe.ts index 380ddc309..a831fc18e 100644 --- a/src/handlers/events-subscribe.ts +++ b/src/handlers/events-subscribe.ts @@ -16,6 +16,7 @@ import { authenticate, authorizeOwner } from '../core/auth.js'; export class EventsSubscribeHandler implements MethodHandler { constructor( private didResolver: DidResolver, + private messageStore: MessageStore, private eventStream: EventStream ) {} @@ -44,7 +45,7 @@ export class EventsSubscribeHandler implements MethodHandler { const { filters } = message.descriptor; const eventsFilters = Events.convertFilters(filters); - const subscription = await this.eventStream.subscribe(tenant, message, eventsFilters); + const subscription = await this.eventStream.subscribe(tenant, message, eventsFilters, this.messageStore); const messageReply: EventsSubscribeReply = { status: { code: 200, detail: 'OK' }, subscription, diff --git a/src/handlers/records-subscribe.ts b/src/handlers/records-subscribe.ts index c67716a7b..5dde64746 100644 --- a/src/handlers/records-subscribe.ts +++ b/src/handlers/records-subscribe.ts @@ -53,7 +53,7 @@ export class RecordsSubscribeHandler implements MethodHandler { } } - const subscription = await this.eventStream.subscribe(tenant, message, filters); + const subscription = await this.eventStream.subscribe(tenant, message, filters, this.messageStore); return { status: { code: 200, detail: 'OK' }, subscription @@ -289,8 +289,7 @@ export class RecordsSubscriptionHandler extends SubscriptionBase { this.eventEmitter.emit(this.eventChannel, message); } catch (error) { - //todo: check for known authorization errors - // console.log('reauthorize error', error); + //todo: check for known authorization errors, and signal to user there has been an error await this.close(); } } diff --git a/src/types/events-types.ts b/src/types/events-types.ts index d87dcc401..74581eac8 100644 --- a/src/types/events-types.ts +++ b/src/types/events-types.ts @@ -1,7 +1,7 @@ import type { ProtocolsQueryFilter } from './protocols-types.js'; import type { AuthorizationModel, GenericMessage, GenericMessageReply } from './message-types.js'; import type { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; -import type { KeyValues, PaginationCursor, RangeCriterion, RangeFilter } from './query-types.js'; +import type { PaginationCursor, RangeCriterion, RangeFilter } from './query-types.js'; export type EventsMessageFilter = { interface?: string; @@ -47,7 +47,7 @@ export type EventsSubscribeMessage = { descriptor: EventsSubscribeDescriptor; }; -export type EventsHandler = (message: GenericMessage, indexes: KeyValues) => void; +export type EventsHandler = (message: GenericMessage) => void; export type EventsSubscription = { id: string; diff --git a/src/types/records-types.ts b/src/types/records-types.ts index cdd2ed739..1847c85a4 100644 --- a/src/types/records-types.ts +++ b/src/types/records-types.ts @@ -197,6 +197,6 @@ export type RecordsDeleteDescriptor = { export type RecordsSubscription = { id: string; - on: (handler:(message: RecordsWriteMessage | RecordsDeleteMessage) => void, updated?: boolean) => { off: () => void }; + on: (handler:(message: RecordsWriteMessage | RecordsDeleteMessage) => void) => { off: () => void }; close: () => Promise; }; \ No newline at end of file diff --git a/src/types/subscriptions.ts b/src/types/subscriptions.ts index 266b3a584..a6044191e 100644 --- a/src/types/subscriptions.ts +++ b/src/types/subscriptions.ts @@ -1,4 +1,5 @@ import type { GenericMessageReply } from '../types/message-types.js'; +import type { MessageStore } from './message-store.js'; import type { EventsSubscribeMessage, EventsSubscription } from './events-types.js'; import type { Filter, KeyValues } from './query-types.js'; import type { GenericMessage, GenericMessageHandler, GenericMessageSubscription } from './message-types.js'; @@ -7,9 +8,9 @@ import type { RecordsSubscribeMessage, RecordsSubscription } from './records-typ export type EmitFunction = (tenant: string, message: GenericMessage, indexes: KeyValues) => void; export interface EventStream { - subscribe(tenant: string, message: EventsSubscribeMessage, filters: Filter[]): Promise; - subscribe(tenant: string, message: RecordsSubscribeMessage, filters: Filter[]): Promise; - subscribe(tenant: string, message: GenericMessage, filters: Filter[]): Promise; + subscribe(tenant: string, message: EventsSubscribeMessage, filters: Filter[], messageStore: MessageStore): Promise; + subscribe(tenant: string, message: RecordsSubscribeMessage, filters: Filter[], messageStore: MessageStore): Promise; + subscribe(tenant: string, message: GenericMessage, filters: Filter[], messageStore: MessageStore): Promise; emit(tenant: string, message: GenericMessage, indexes: KeyValues): void; open(): Promise; close(): Promise; diff --git a/tests/dwn.spec.ts b/tests/dwn.spec.ts index 6a1b4952c..3ad69d3bc 100644 --- a/tests/dwn.spec.ts +++ b/tests/dwn.spec.ts @@ -11,8 +11,8 @@ import { Dwn } from '../src/dwn.js'; import { Message } from '../src/core/message.js'; import { stubInterface } from 'ts-sinon'; import { TestDataGenerator } from './utils/test-data-generator.js'; +import { TestEventStream } from './test-event-stream.js'; import { TestStores } from './test-stores.js'; -import { DidResolver, EventStreamEmitter } from '../src/index.js'; chai.use(chaiAsPromised); @@ -32,8 +32,7 @@ export function testDwnClass(): void { dataStore = stores.dataStore; eventLog = stores.eventLog; - const didResolver = new DidResolver(); - eventStream = new EventStreamEmitter({ messageStore, didResolver }); + eventStream = TestEventStream.get(); dwn = await Dwn.create({ messageStore, dataStore, eventLog, eventStream }); }); diff --git a/tests/event-log/event-stream-emitter.spec.ts b/tests/event-log/event-stream-emitter.spec.ts index 195f84259..85186e406 100644 --- a/tests/event-log/event-stream-emitter.spec.ts +++ b/tests/event-log/event-stream-emitter.spec.ts @@ -3,8 +3,8 @@ import type { GenericMessage, MessageStore } from '../../src/index.js'; import EventEmitter from 'events'; import { EventStreamEmitter } from '../../src/event-log/event-stream.js'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestStores } from '../test-stores.js'; import { DidKeyResolver, Message } from '../../src/index.js'; -import { DidResolver, MessageStoreLevel } from '../../src/index.js'; import chaiAsPromised from 'chai-as-promised'; import chai, { expect } from 'chai'; @@ -13,15 +13,10 @@ chai.use(chaiAsPromised); describe('EventStreamEmitter', () => { let eventStream: EventStreamEmitter; - let didResolver: DidResolver; let messageStore: MessageStore; before(() => { - didResolver = new DidResolver(); - messageStore = new MessageStoreLevel({ - blockstoreLocation : 'TEST-MESSAGESTORE', - indexLocation : 'TEST-INDEX' - }); + ({ messageStore } = TestStores.get()); }); beforeEach(async () => { @@ -38,14 +33,14 @@ describe('EventStreamEmitter', () => { const alice = await DidKeyResolver.generate(); const emitter = new EventEmitter(); - eventStream = new EventStreamEmitter({ emitter, messageStore, didResolver }); + eventStream = new EventStreamEmitter({ emitter }); // count the `events_bus` listeners, which represents all listeners expect(emitter.listenerCount('events_bus')).to.equal(0); // initiate a subscription, which should add a listener const { message } = await TestDataGenerator.generateRecordsSubscribe({ author: alice }); - const sub = await eventStream.subscribe(alice.did, message, []); + const sub = await eventStream.subscribe(alice.did, message, [], messageStore); expect(emitter.listenerCount('events_bus')).to.equal(1); // close the subscription, which should remove the listener @@ -56,11 +51,11 @@ describe('EventStreamEmitter', () => { it('should remove listeners when off method is used', async () => { const alice = await DidKeyResolver.generate(); const emitter = new EventEmitter(); - eventStream = new EventStreamEmitter({ emitter, messageStore, didResolver }); + eventStream = new EventStreamEmitter({ emitter }); // initiate a subscription const { message } = await TestDataGenerator.generateRecordsSubscribe(); - const sub = await eventStream.subscribe(alice.did, message, []); + const sub = await eventStream.subscribe(alice.did, message, [], messageStore); const messageCid = await Message.getCid(message); // the listener count for the specific subscription should be at zero diff --git a/tests/event-log/event-stream.spec.ts b/tests/event-log/event-stream.spec.ts new file mode 100644 index 000000000..9e2c23942 --- /dev/null +++ b/tests/event-log/event-stream.spec.ts @@ -0,0 +1,29 @@ +import type { EventStream, MessageStore } from '../../src/index.js'; + +import { TestEventStream } from '../test-event-stream.js'; +import { TestStores } from '../test-stores.js'; + +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +chai.use(chaiAsPromised); + +describe('EventStream', () => { + let eventStream: EventStream; + let messageStore: MessageStore; + + before(() => { + ({ messageStore } = TestStores.get()); + eventStream = TestEventStream.get(); + }); + + beforeEach(async () => { + messageStore.clear(); + }); + + after(async () => { + // Clean up after each test by closing and clearing the event stream + await messageStore.close(); + await eventStream.close(); + }); +}); diff --git a/tests/handlers/events-get.spec.ts b/tests/handlers/events-get.spec.ts index 0a4aeca24..eef10ce8b 100644 --- a/tests/handlers/events-get.spec.ts +++ b/tests/handlers/events-get.spec.ts @@ -13,10 +13,10 @@ import { DidKeyResolver, DidResolver, Dwn, - EventStreamEmitter } from '../../src/index.js'; import { Message } from '../../src/core/message.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; export function testEventsGetHandler(): void { @@ -37,7 +37,7 @@ export function testEventsGetHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; - eventStream = new EventStreamEmitter({ messageStore, didResolver }); + eventStream = TestEventStream.get(); dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); diff --git a/tests/handlers/events-query.spec.ts b/tests/handlers/events-query.spec.ts index 85500595a..e1a967d3e 100644 --- a/tests/handlers/events-query.spec.ts +++ b/tests/handlers/events-query.spec.ts @@ -8,12 +8,12 @@ import type { import { EventsQueryHandler } from '../../src/handlers/events-query.js'; import { expect } from 'chai'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { DidKeyResolver, DidResolver, Dwn, - EventStreamEmitter, } from '../../src/index.js'; @@ -35,7 +35,7 @@ export function testEventsQueryHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; - eventStream = new EventStreamEmitter({ messageStore, didResolver }); + eventStream = TestEventStream.get(); dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); diff --git a/tests/handlers/events-subscribe.spec.ts b/tests/handlers/events-subscribe.spec.ts index e09e699c1..cec294dbb 100644 --- a/tests/handlers/events-subscribe.spec.ts +++ b/tests/handlers/events-subscribe.spec.ts @@ -5,10 +5,10 @@ import { DidKeyResolver } from '../../src/did/did-key-resolver.js'; import { DidResolver } from '../../src/did/did-resolver.js'; import { Dwn } from '../../src/dwn.js'; import { EventsSubscribe } from '../../src/interfaces/events-subscribe.js'; -import { EventStreamEmitter } from '../../src/event-log/event-stream.js'; import { Jws } from '../../src/utils/jws.js'; import { Message } from '../../src/core/message.js'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import sinon from 'sinon'; @@ -36,7 +36,7 @@ export function testEventsSubscribeHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; - eventStream = new EventStreamEmitter({ messageStore, didResolver }); + eventStream = TestEventStream.get(); dwn = await Dwn.create({ didResolver, diff --git a/tests/handlers/messages-get.spec.ts b/tests/handlers/messages-get.spec.ts index d458fee03..7c98ea1a3 100644 --- a/tests/handlers/messages-get.spec.ts +++ b/tests/handlers/messages-get.spec.ts @@ -11,8 +11,9 @@ import { Message } from '../../src/core/message.js'; import { MessagesGetHandler } from '../../src/handlers/messages-get.js'; import { stubInterface } from 'ts-sinon'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; -import { DidKeyResolver, DidResolver, Dwn, DwnConstant, EventStreamEmitter } from '../../src/index.js'; +import { DidKeyResolver, DidResolver, Dwn, DwnConstant } from '../../src/index.js'; import sinon from 'sinon'; @@ -34,7 +35,7 @@ export function testMessagesGetHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; - eventStream = new EventStreamEmitter({ messageStore, didResolver }); + eventStream = TestEventStream.get(); dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); diff --git a/tests/handlers/permissions-grant.spec.ts b/tests/handlers/permissions-grant.spec.ts index 459e6dde8..b2c9c7328 100644 --- a/tests/handlers/permissions-grant.spec.ts +++ b/tests/handlers/permissions-grant.spec.ts @@ -12,14 +12,15 @@ import { DidResolver } from '../../src/did/did-resolver.js'; import { Dwn } from '../../src/dwn.js'; import { DwnErrorCode } from '../../src/core/dwn-error.js'; import { expect } from 'chai'; +import { Jws } from '../../src/index.js'; import { Message } from '../../src/core/message.js'; import { PermissionsGrant } from '../../src/interfaces/permissions-grant.js'; import { PermissionsGrantHandler } from '../../src/handlers/permissions-grant.js'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { Time } from '../../src/utils/time.js'; import { DwnInterfaceName, DwnMethodName } from '../../src/enums/dwn-interface-method.js'; -import { EventStreamEmitter, Jws } from '../../src/index.js'; export function testPermissionsGrantHandler(): void { describe('PermissionsGrantHandler.handle()', () => { @@ -41,7 +42,7 @@ export function testPermissionsGrantHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; - eventStream = new EventStreamEmitter({ messageStore, didResolver }); + eventStream = TestEventStream.get(); dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); diff --git a/tests/handlers/permissions-request.spec.ts b/tests/handlers/permissions-request.spec.ts index 0bfe88030..4dbf59ba2 100644 --- a/tests/handlers/permissions-request.spec.ts +++ b/tests/handlers/permissions-request.spec.ts @@ -15,8 +15,9 @@ import { Message } from '../../src/core/message.js'; import { PermissionsRequest } from '../../src/interfaces/permissions-request.js'; import { PermissionsRequestHandler } from '../../src/handlers/permissions-request.js'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; -import { DwnInterfaceName, DwnMethodName, EventStreamEmitter } from '../../src/index.js'; +import { DwnInterfaceName, DwnMethodName } from '../../src/index.js'; export function testPermissionsRequestHandler(): void { describe('PermissionsRequestHandler.handle()', () => { @@ -38,7 +39,7 @@ export function testPermissionsRequestHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; - eventStream = new EventStreamEmitter({ messageStore, didResolver }); + eventStream = TestEventStream.get(); dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); diff --git a/tests/handlers/permissions-revoke.spec.ts b/tests/handlers/permissions-revoke.spec.ts index 94c2cb6aa..73be4efe1 100644 --- a/tests/handlers/permissions-revoke.spec.ts +++ b/tests/handlers/permissions-revoke.spec.ts @@ -1,26 +1,25 @@ import { expect } from 'chai'; import sinon from 'sinon'; +import type { DataStore, EventLog, EventStream, MessageStore } from '../../src/index.js'; -import { DataStoreLevel } from '../../src/store/data-store-level.js'; import { DidKeyResolver } from '../../src/did/did-key-resolver.js'; import { DidResolver } from '../../src/did/did-resolver.js'; import { Dwn } from '../../src/dwn.js'; import { DwnErrorCode } from '../../src/core/dwn-error.js'; -import { EventLogLevel } from '../../src/event-log/event-log-level.js'; -import { EventStreamEmitter } from '../../src/event-log/event-stream.js'; import { Message } from '../../src/core/message.js'; -import { MessageStoreLevel } from '../../src/store/message-store-level.js'; import { PermissionsRevoke } from '../../src/interfaces/permissions-revoke.js'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; +import { TestStores } from '../test-stores.js'; import { Time } from '../../src/utils/time.js'; describe('PermissionsRevokeHandler.handle()', () => { let didResolver: DidResolver; - let messageStore: MessageStoreLevel; - let dataStore: DataStoreLevel; - let eventLog: EventLogLevel; - let eventStream: EventStreamEmitter; + let messageStore: MessageStore; + let dataStore: DataStore; + let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; describe('functional tests', () => { @@ -29,20 +28,12 @@ describe('PermissionsRevokeHandler.handle()', () => { // important to follow this pattern to initialize and clean the message and data store in tests // so that different suites can reuse the same block store and index location for testing - messageStore = new MessageStoreLevel({ - blockstoreLocation : 'TEST-MESSAGESTORE', - indexLocation : 'TEST-INDEX' - }); - - dataStore = new DataStoreLevel({ - blockstoreLocation: 'TEST-DATASTORE' - }); - - eventLog = new EventLogLevel({ - location: 'TEST-EVENTLOG' - }); + const stores = TestStores.get(); + messageStore = stores.messageStore; + dataStore = stores.dataStore; + eventLog = stores.eventLog; - eventStream = new EventStreamEmitter({ didResolver, messageStore }); + eventStream = TestEventStream.get(); dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); diff --git a/tests/handlers/protocols-configure.spec.ts b/tests/handlers/protocols-configure.spec.ts index bbd5ee06c..7d56d2dc8 100644 --- a/tests/handlers/protocols-configure.spec.ts +++ b/tests/handlers/protocols-configure.spec.ts @@ -19,11 +19,12 @@ import { GeneralJwsBuilder } from '../../src/jose/jws/general/builder.js'; import { lexicographicalCompare } from '../../src/utils/string.js'; import { Message } from '../../src/core/message.js'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { TestStubGenerator } from '../utils/test-stub-generator.js'; import { Time } from '../../src/utils/time.js'; -import { DidResolver, Dwn, DwnErrorCode, Encoder, EventStreamEmitter, Jws } from '../../src/index.js'; +import { DidResolver, Dwn, DwnErrorCode, Encoder, Jws } from '../../src/index.js'; chai.use(chaiAsPromised); @@ -47,7 +48,8 @@ export function testProtocolsConfigureHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; - eventStream = new EventStreamEmitter({ messageStore, didResolver }); + + eventStream = TestEventStream.get(); dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); diff --git a/tests/handlers/protocols-query.spec.ts b/tests/handlers/protocols-query.spec.ts index 3be0ac829..f654fa441 100644 --- a/tests/handlers/protocols-query.spec.ts +++ b/tests/handlers/protocols-query.spec.ts @@ -14,10 +14,11 @@ import { DidKeyResolver } from '../../src/did/did-key-resolver.js'; import { GeneralJwsBuilder } from '../../src/jose/jws/general/builder.js'; import { Message } from '../../src/core/message.js'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { TestStubGenerator } from '../utils/test-stub-generator.js'; import { Time } from '../../src/utils/time.js'; -import { DidResolver, Dwn, DwnErrorCode, DwnInterfaceName, DwnMethodName, Encoder, EventStreamEmitter, Jws, ProtocolsQuery } from '../../src/index.js'; +import { DidResolver, Dwn, DwnErrorCode, DwnInterfaceName, DwnMethodName, Encoder, Jws, ProtocolsQuery } from '../../src/index.js'; chai.use(chaiAsPromised); @@ -41,7 +42,7 @@ export function testProtocolsQueryHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; - eventStream = new EventStreamEmitter({ messageStore, didResolver }); + eventStream = TestEventStream.get(); dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); diff --git a/tests/handlers/records-delete.spec.ts b/tests/handlers/records-delete.spec.ts index 3b89acd58..d5d192310 100644 --- a/tests/handlers/records-delete.spec.ts +++ b/tests/handlers/records-delete.spec.ts @@ -18,17 +18,18 @@ import threadRoleProtocolDefinition from '../vectors/protocol-definitions/thread import { ArrayUtility } from '../../src/utils/array.js'; import { DidKeyResolver } from '../../src/did/did-key-resolver.js'; +import { DwnErrorCode } from '../../src/core/dwn-error.js'; import { DwnMethodName } from '../../src/enums/dwn-interface-method.js'; import { Message } from '../../src/core/message.js'; import { normalizeSchemaUrl } from '../../src/utils/url.js'; import { RecordsDeleteHandler } from '../../src/handlers/records-delete.js'; import { stubInterface } from 'ts-sinon'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { TestStubGenerator } from '../utils/test-stub-generator.js'; import { Time } from '../../src/utils/time.js'; import { DataStream, DidResolver, Dwn, Encoder, Jws, RecordsDelete, RecordsRead, RecordsWrite } from '../../src/index.js'; -import { DwnErrorCode, EventStreamEmitter } from '../../src/index.js'; chai.use(chaiAsPromised); @@ -52,7 +53,7 @@ export function testRecordsDeleteHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; - eventStream = new EventStreamEmitter({ messageStore, didResolver }); + eventStream = TestEventStream.get(); dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); diff --git a/tests/handlers/records-query.spec.ts b/tests/handlers/records-query.spec.ts index dbf9a5ee5..0be0ead9d 100644 --- a/tests/handlers/records-query.spec.ts +++ b/tests/handlers/records-query.spec.ts @@ -15,6 +15,7 @@ import { ArrayUtility } from '../../src/utils/array.js'; import { DateSort } from '../../src/types/records-types.js'; import { DidKeyResolver } from '../../src/did/did-key-resolver.js'; import { DwnConstant } from '../../src/core/dwn-constant.js'; +import { DwnErrorCode } from '../../src/core/dwn-error.js'; import { Encoder } from '../../src/utils/encoder.js'; import { Jws } from '../../src/utils/jws.js'; import { Message } from '../../src/core/message.js'; @@ -23,10 +24,10 @@ import { RecordsQueryHandler } from '../../src/handlers/records-query.js'; import { RecordsWriteHandler } from '../../src/handlers/records-write.js'; import { stubInterface } from 'ts-sinon'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { TestStubGenerator } from '../utils/test-stub-generator.js'; import { DidResolver, Dwn, RecordsWrite, Time } from '../../src/index.js'; -import { DwnErrorCode, EventStreamEmitter } from '../../src/index.js'; chai.use(chaiAsPromised); @@ -49,7 +50,8 @@ export function testRecordsQueryHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; - eventStream = new EventStreamEmitter({ messageStore, didResolver }); + + eventStream = TestEventStream.get(); dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); diff --git a/tests/handlers/records-read.spec.ts b/tests/handlers/records-read.spec.ts index be5bdf079..5ecb04137 100644 --- a/tests/handlers/records-read.spec.ts +++ b/tests/handlers/records-read.spec.ts @@ -3,7 +3,7 @@ import type { EncryptionInput } from '../../src/interfaces/records-write.js'; import type { EventStream } from '../../src/types/subscriptions.js'; import type { DataStore, EventLog, MessageStore, ProtocolDefinition, ProtocolsConfigureMessage } from '../../src/index.js'; -import { DwnConstant, EventStreamEmitter, Message } from '../../src/index.js'; +import { DwnConstant, Message } from '../../src/index.js'; import { DwnInterfaceName, DwnMethodName } from '../../src/index.js'; import chaiAsPromised from 'chai-as-promised'; @@ -28,6 +28,7 @@ import { KeyDerivationScheme } from '../../src/utils/hd-key.js'; import { RecordsReadHandler } from '../../src/handlers/records-read.js'; import { stubInterface } from 'ts-sinon'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { TestStubGenerator } from '../utils/test-stub-generator.js'; @@ -56,7 +57,8 @@ export function testRecordsReadHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; - eventStream = new EventStreamEmitter({ messageStore, didResolver }); + + eventStream = TestEventStream.get(); dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); diff --git a/tests/handlers/records-subscribe.spec.ts b/tests/handlers/records-subscribe.spec.ts index d6386e031..0722b052a 100644 --- a/tests/handlers/records-subscribe.spec.ts +++ b/tests/handlers/records-subscribe.spec.ts @@ -17,9 +17,10 @@ import { RecordsSubscribe } from '../../src/interfaces/records-subscribe.js'; import { RecordsSubscribeHandler } from '../../src/handlers/records-subscribe.js'; import { stubInterface } from 'ts-sinon'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { TestStubGenerator } from '../utils/test-stub-generator.js'; -import { DidResolver, Dwn, EventStreamEmitter, Time } from '../../src/index.js'; +import { DidResolver, Dwn, Time } from '../../src/index.js'; import { DwnErrorCode, DwnInterfaceName, DwnMethodName } from '../../src/index.js'; chai.use(chaiAsPromised); @@ -43,7 +44,8 @@ export function testRecordsSubscribeHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; - eventStream = new EventStreamEmitter({ messageStore, didResolver }); + + eventStream = TestEventStream.get(); dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); diff --git a/tests/handlers/records-write.spec.ts b/tests/handlers/records-write.spec.ts index 34bd472a9..b5d7509a6 100644 --- a/tests/handlers/records-write.spec.ts +++ b/tests/handlers/records-write.spec.ts @@ -39,11 +39,12 @@ import { RecordsWrite } from '../../src/interfaces/records-write.js'; import { RecordsWriteHandler } from '../../src/handlers/records-write.js'; import { stubInterface } from 'ts-sinon'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { TestStubGenerator } from '../utils/test-stub-generator.js'; import { Time } from '../../src/utils/time.js'; -import { DwnConstant, DwnInterfaceName, DwnMethodName, EventStreamEmitter, KeyDerivationScheme, RecordsDelete, RecordsQuery } from '../../src/index.js'; +import { DwnConstant, DwnInterfaceName, DwnMethodName, KeyDerivationScheme, RecordsDelete, RecordsQuery } from '../../src/index.js'; import { Encryption, EncryptionAlgorithm } from '../../src/utils/encryption.js'; chai.use(chaiAsPromised); @@ -68,7 +69,8 @@ export function testRecordsWriteHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; - eventStream = new EventStreamEmitter({ messageStore, didResolver }); + + eventStream = TestEventStream.get(); dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); diff --git a/tests/scenarios/delegated-grant.spec.ts b/tests/scenarios/delegated-grant.spec.ts index 1e4951ea6..50fa3ee44 100644 --- a/tests/scenarios/delegated-grant.spec.ts +++ b/tests/scenarios/delegated-grant.spec.ts @@ -17,10 +17,11 @@ import { DwnErrorCode } from '../../src/core/dwn-error.js'; import { Jws } from '../../src/utils/jws.js'; import { RecordsWrite } from '../../src/interfaces/records-write.js'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { Time } from '../../src/utils/time.js'; -import { DwnInterfaceName, DwnMethodName, EventStreamEmitter, PermissionsGrant, RecordsDelete, RecordsQuery, RecordsRead, RecordsSubscribe } from '../../src/index.js'; +import { DwnInterfaceName, DwnMethodName, PermissionsGrant, RecordsDelete, RecordsQuery, RecordsRead, RecordsSubscribe } from '../../src/index.js'; chai.use(chaiAsPromised); @@ -42,7 +43,8 @@ export function testDelegatedGrantScenarios(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; - eventStream = new EventStreamEmitter({ messageStore, didResolver }); + + eventStream = TestEventStream.get(); dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); diff --git a/tests/scenarios/end-to-end-tests.spec.ts b/tests/scenarios/end-to-end-tests.spec.ts index 504b8aa84..7d2dd27f8 100644 --- a/tests/scenarios/end-to-end-tests.spec.ts +++ b/tests/scenarios/end-to-end-tests.spec.ts @@ -8,12 +8,13 @@ import threadRoleProtocolDefinition from '../vectors/protocol-definitions/thread import { authenticate } from '../../src/core/auth.js'; import { DidKeyResolver } from '../../src/did/did-key-resolver.js'; +import { Encoder } from '../../src/index.js'; import { HdKey } from '../../src/utils/hd-key.js'; import { KeyDerivationScheme } from '../../src/utils/hd-key.js'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { TestStubGenerator } from '../utils/test-stub-generator.js'; -import { Encoder, EventStreamEmitter } from '../../src/index.js'; import chai, { expect } from 'chai'; import { DataStream, DidResolver, Dwn, Jws, Protocols, ProtocolsConfigure, ProtocolsQuery, Records, RecordsRead } from '../../src/index.js'; @@ -38,7 +39,8 @@ export function testEndToEndScenarios(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; - eventStream = new EventStreamEmitter({ messageStore, didResolver }); + + eventStream = TestEventStream.get(); dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); diff --git a/tests/scenarios/events-query.spec.ts b/tests/scenarios/events-query.spec.ts index 0ba31fbd8..2532fe72f 100644 --- a/tests/scenarios/events-query.spec.ts +++ b/tests/scenarios/events-query.spec.ts @@ -9,10 +9,11 @@ import freeForAll from '../vectors/protocol-definitions/free-for-all.json' asser import threadProtocol from '../vectors/protocol-definitions/thread-role.json' assert { type: 'json' }; import { TestStores } from '../test-stores.js'; -import { DidKeyResolver, DidResolver, Dwn, DwnConstant, DwnInterfaceName, DwnMethodName, EventStreamEmitter, Message, Time } from '../../src/index.js'; +import { DidKeyResolver, DidResolver, Dwn, DwnConstant, DwnInterfaceName, DwnMethodName, Message, Time } from '../../src/index.js'; import { expect } from 'chai'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; export function testEventsQueryScenarios(): void { describe('events query tests', () => { @@ -32,7 +33,7 @@ export function testEventsQueryScenarios(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; - eventStream = new EventStreamEmitter({ messageStore, didResolver }); + eventStream = TestEventStream.get(); dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); diff --git a/tests/scenarios/subscriptions.spec.ts b/tests/scenarios/subscriptions.spec.ts index 58ac6dfcb..4edb20539 100644 --- a/tests/scenarios/subscriptions.spec.ts +++ b/tests/scenarios/subscriptions.spec.ts @@ -13,6 +13,7 @@ import friendRole from '../vectors/protocol-definitions/friend-role.json' assert import { RecordsSubscriptionHandler } from '../../src/handlers/records-subscribe.js'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { Time } from '../../src/utils/time.js'; import { DidKeyResolver, DidResolver, Dwn, EventStreamEmitter, Message } from '../../src/index.js'; @@ -39,7 +40,7 @@ export function testSubscriptionScenarios(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; - eventStream = new EventStreamEmitter({ messageStore, didResolver }); + eventStream = TestEventStream.get(); dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); @@ -190,6 +191,9 @@ export function testSubscriptionScenarios(): void { const record2Reply = await dwn.processMessage(alice.did, record2.message, record2.dataStream); expect(record2Reply.status.code).to.equal(202); + // sleep to make sure events have some time to emit. + await Time.minimalSleep(); + expect(schema1Messages.length).to.equal(1); // same as before expect(schema1Messages).to.eql([ record1MessageCid ]); }); @@ -348,6 +352,48 @@ export function testSubscriptionScenarios(): void { expect(proto2Messages.length).to.equal(2, 'proto2 after subscription.off()'); expect(proto2Messages).to.have.members([await Message.getCid(write1proto2.message), await Message.getCid(write2proto2.message)]); }); + + it('unsubscribes', async () => { + const alice = await DidKeyResolver.generate(); + + // subscribe to all events + const eventsSubscription = await TestDataGenerator.generateEventsSubscribe({ author: alice }); + const eventsSubscriptionReply = await dwn.processMessage(alice.did, eventsSubscription.message); + expect(eventsSubscriptionReply.status.code).to.equal(200); + + // messageCids of events + const messageCids:string[] = []; + + const eventsHandler = async (message: GenericMessage): Promise => { + const messageCid = await Message.getCid(message); + messageCids.push(messageCid); + }; + eventsSubscriptionReply.subscription!.on(eventsHandler); + + expect(messageCids.length).to.equal(0); // no events exist yet + + const record1 = await TestDataGenerator.generateRecordsWrite({ author: alice }); + const record1Reply = await dwn.processMessage(alice.did, record1.message, record1.dataStream); + expect(record1Reply.status.code).to.equal(202); + const record1MessageCid = await Message.getCid(record1.message); + + expect(messageCids.length).to.equal(1); // message exists + expect(messageCids).to.eql([ record1MessageCid ]); + + // unsubscribe, this should be used as clean up. + await eventsSubscriptionReply.subscription!.close(); + + // write another message. + const record2 = await TestDataGenerator.generateRecordsWrite({ author: alice }); + const record2Reply = await dwn.processMessage(alice.did, record2.message, record2.dataStream); + expect(record2Reply.status.code).to.equal(202); + + // sleep to make sure events have some time to emit. + await Time.minimalSleep(); + + expect(messageCids.length).to.equal(1); // same as before + expect(messageCids).to.eql([ record1MessageCid ]); + }); }); }); @@ -360,13 +406,14 @@ export function testSubscriptionScenarios(): void { let dwn: Dwn; before(async () => { + didResolver = new DidResolver([new DidKeyResolver()]); + const stores = TestStores.get(); messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; - didResolver = new DidResolver([new DidKeyResolver()]); - eventStream = new EventStreamEmitter({ messageStore, didResolver }); + eventStream = TestEventStream.get(); dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); @@ -384,7 +431,7 @@ export function testSubscriptionScenarios(): void { }); it('does not reauthorize if TTL is set to zero', async () => { - const eventStream = new EventStreamEmitter({ messageStore, didResolver, reauthorizationTTL: 0 }); + const eventStream = new EventStreamEmitter({ reauthorizationTTL: 0 }); dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); const authorizeSpy = sinon.spy(RecordsSubscriptionHandler.prototype as any, 'reauthorize'); @@ -462,7 +509,7 @@ export function testSubscriptionScenarios(): void { }); it('reauthorize on every event emitted if TTL is less than zero', async () => { - const eventStream = new EventStreamEmitter({ messageStore, didResolver, reauthorizationTTL: -1 }); + const eventStream = new EventStreamEmitter({ reauthorizationTTL: -1 }); dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); const authorizeSpy = sinon.spy(RecordsSubscriptionHandler.prototype as any, 'reauthorize'); @@ -542,7 +589,7 @@ export function testSubscriptionScenarios(): void { it('reauthorizes after the ttl', async () => { const clock = sinon.useFakeTimers(); - const eventStream = new EventStreamEmitter({ messageStore, didResolver, reauthorizationTTL: 1 }); // every second + const eventStream = new EventStreamEmitter({ reauthorizationTTL: 1 }); // every second dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); const authorizeSpy = sinon.spy(RecordsSubscriptionHandler.prototype as any, 'reauthorize'); @@ -640,7 +687,7 @@ export function testSubscriptionScenarios(): void { }); it('no longer sends to subscription handler if subscription becomes un-authorized', async () => { - const eventStream = new EventStreamEmitter({ messageStore, didResolver, reauthorizationTTL: -1 }); // reauthorize with each event + const eventStream = new EventStreamEmitter({ reauthorizationTTL: -1 }); // reauthorize with each event dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); const alice = await DidKeyResolver.generate(); diff --git a/tests/test-event-stream.ts b/tests/test-event-stream.ts new file mode 100644 index 000000000..830c9dab2 --- /dev/null +++ b/tests/test-event-stream.ts @@ -0,0 +1,28 @@ +import type { EventStream } from '../src/index.js'; +import { EventStreamEmitter } from '../src/index.js'; + +/** + * Class that manages store implementations for testing. + * This is intended to be extended as the single point of configuration + * that allows different store implementations to be swapped in + * to test compatibility with default/built-in store implementations. + */ +export class TestEventStream { + private static eventStream?: EventStream; + + /** + * Overrides test stores with given implementation. + * If not given, default implementation will be used. + */ + public static override(overrides?: { eventStream?: EventStream }): void { + TestEventStream.eventStream = overrides?.eventStream; + } + + /** + * Initializes and return the stores used for running the test suite. + */ + public static get(): EventStream { + TestEventStream.eventStream ??= new EventStreamEmitter(); + return TestEventStream.eventStream; + } +} \ No newline at end of file From a09504a1d60fedea7428eb2d2e6e7e179697ba92 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Tue, 9 Jan 2024 12:14:44 -0500 Subject: [PATCH 11/44] add error handler to records subscription --- src/event-log/subscription.ts | 10 ++++++++++ src/handlers/records-subscribe.ts | 2 ++ src/types/records-types.ts | 2 ++ src/types/subscriptions.ts | 2 ++ tests/scenarios/subscriptions.spec.ts | 18 +++++++++++++----- 5 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/event-log/subscription.ts b/src/event-log/subscription.ts index 43eb7182f..d195537e9 100644 --- a/src/event-log/subscription.ts +++ b/src/event-log/subscription.ts @@ -1,3 +1,4 @@ +import type { DwnError } from '../index.js'; import type { EventEmitter } from 'events'; import type { MessageStore } from '../types/message-store.js'; import type { EmitFunction, Subscription } from '../types/subscriptions.js'; @@ -41,6 +42,10 @@ export class SubscriptionBase implements Subscription { return `${this.tenant}_${this.#id}`; } + get errorEventChannel(): string { + return `${this.tenant}_${this.#id}_error`; + } + get id(): string { return this.#id; } @@ -64,8 +69,13 @@ export class SubscriptionBase implements Subscription { }; } + onError(handler: (error: DwnError) => void): void { + this.eventEmitter.on(this.errorEventChannel, handler); + } + async close(): Promise { this.eventEmitter.removeAllListeners(this.eventChannel); + this.eventEmitter.removeAllListeners(this.errorEventChannel); await this.#unsubscribe(); } } \ No newline at end of file diff --git a/src/handlers/records-subscribe.ts b/src/handlers/records-subscribe.ts index 5dde64746..467c82f4d 100644 --- a/src/handlers/records-subscribe.ts +++ b/src/handlers/records-subscribe.ts @@ -16,6 +16,7 @@ import { RecordsSubscribe } from '../interfaces/records-subscribe.js'; import { RecordsWrite } from '../interfaces/records-write.js'; import { SubscriptionBase } from '../event-log/subscription.js'; import { Time } from '../utils/time.js'; +import { DwnError, DwnErrorCode } from '../index.js'; import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; export class RecordsSubscribeHandler implements MethodHandler { @@ -290,6 +291,7 @@ export class RecordsSubscriptionHandler extends SubscriptionBase { this.eventEmitter.emit(this.eventChannel, message); } catch (error) { //todo: check for known authorization errors, and signal to user there has been an error + this.eventEmitter.emit(this.errorEventChannel, new DwnError(DwnErrorCode.RecordsSubscribeUnauthorized, 'this subscription has become unauthorized')); await this.close(); } } diff --git a/src/types/records-types.ts b/src/types/records-types.ts index 1847c85a4..53a789509 100644 --- a/src/types/records-types.ts +++ b/src/types/records-types.ts @@ -1,3 +1,4 @@ +import type { DwnError } from '../index.js'; import type { EncryptionAlgorithm } from '../utils/encryption.js'; import type { GeneralJws } from './jws-types.js'; import type { KeyDerivationScheme } from '../utils/hd-key.js'; @@ -198,5 +199,6 @@ export type RecordsDeleteDescriptor = { export type RecordsSubscription = { id: string; on: (handler:(message: RecordsWriteMessage | RecordsDeleteMessage) => void) => { off: () => void }; + onError: (handler:(error: DwnError) => void) => void; close: () => Promise; }; \ No newline at end of file diff --git a/src/types/subscriptions.ts b/src/types/subscriptions.ts index a6044191e..7159cf3a3 100644 --- a/src/types/subscriptions.ts +++ b/src/types/subscriptions.ts @@ -1,3 +1,4 @@ +import type { DwnError } from '../index.js'; import type { GenericMessageReply } from '../types/message-types.js'; import type { MessageStore } from './message-store.js'; import type { EventsSubscribeMessage, EventsSubscription } from './events-types.js'; @@ -20,6 +21,7 @@ export interface Subscription { id: string; listener: EmitFunction; on: (handler: GenericMessageHandler) => { off: () => void }; + onError: (handler: (error: DwnError) => void) => void; close: () => Promise; } diff --git a/tests/scenarios/subscriptions.spec.ts b/tests/scenarios/subscriptions.spec.ts index 4edb20539..c74032b45 100644 --- a/tests/scenarios/subscriptions.spec.ts +++ b/tests/scenarios/subscriptions.spec.ts @@ -1,5 +1,6 @@ import type { DataStore, + DwnError, EventLog, EventStream, GenericMessage, @@ -16,7 +17,7 @@ import { TestDataGenerator } from '../utils/test-data-generator.js'; import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { Time } from '../../src/utils/time.js'; -import { DidKeyResolver, DidResolver, Dwn, EventStreamEmitter, Message } from '../../src/index.js'; +import { DidKeyResolver, DidResolver, Dwn, DwnErrorCode, EventStreamEmitter, Message } from '../../src/index.js'; import { expect } from 'chai'; import sinon from 'sinon'; @@ -162,8 +163,8 @@ export function testSubscriptionScenarios(): void { // subscribe to schema1 const schema1Subscription = await TestDataGenerator.generateRecordsSubscribe({ author: alice, filter: { schema: 'schema1' } }); - const schema1SubscriptionRepl = await dwn.processMessage(alice.did, schema1Subscription.message); - expect(schema1SubscriptionRepl.status.code).to.equal(200); + const schema1SubscriptionReply = await dwn.processMessage(alice.did, schema1Subscription.message); + expect(schema1SubscriptionReply.status.code).to.equal(200); // messageCids of schema1 const schema1Messages:string[] = []; @@ -172,7 +173,7 @@ export function testSubscriptionScenarios(): void { const messageCid = await Message.getCid(message); schema1Messages.push(messageCid); }; - schema1SubscriptionRepl.subscription!.on(schema1Handler); + schema1SubscriptionReply.subscription!.on(schema1Handler); expect(schema1Messages.length).to.equal(0); // no messages exist; const record1 = await TestDataGenerator.generateRecordsWrite({ author: alice, schema: 'schema1' }); @@ -184,7 +185,7 @@ export function testSubscriptionScenarios(): void { expect(schema1Messages).to.eql([ record1MessageCid ]); // unsubscribe, this should be used as clean up. - await schema1SubscriptionRepl.subscription!.close(); + await schema1SubscriptionReply.subscription!.close(); // write another message. const record2 = await TestDataGenerator.generateRecordsWrite({ author: alice, schema: 'schema1' }); @@ -196,6 +197,7 @@ export function testSubscriptionScenarios(): void { expect(schema1Messages.length).to.equal(1); // same as before expect(schema1Messages).to.eql([ record1MessageCid ]); + }); }); @@ -729,6 +731,11 @@ export function testSubscriptionScenarios(): void { expect(bobSubscribeReply.status.code).to.equal(200); + const errorHandlerPromise = new Promise((_,reject) => { + const errorHandler = (error: DwnError): void => { reject(error); }; + bobSubscribeReply.subscription!.onError(errorHandler); + }); + // capture the messageCids from the subscription const messageCids: string[] = []; const captureFunction = async (message: RecordsWriteMessage | RecordsDeleteMessage):Promise => { @@ -776,6 +783,7 @@ export function testSubscriptionScenarios(): void { expect(messageCids.length).to.equal(1, 'messageCids'); expect(messageCids).to.have.members([ aliceMessage1Cid ]); expect(subscriptionCloseSpy.called).to.be.true; + await expect(errorHandlerPromise).to.eventually.be.rejectedWith(DwnErrorCode.RecordsSubscribeUnauthorized); }); }); }); From 9d891cd6cb742a51585f04a6d7f1037fde649ee9 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Tue, 9 Jan 2024 12:21:26 -0500 Subject: [PATCH 12/44] remove uneeded event subsribe testing until delegating eEventsQuery/Get becomes a thing --- tests/handlers/events-subscribe.spec.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/handlers/events-subscribe.spec.ts b/tests/handlers/events-subscribe.spec.ts index cec294dbb..4f7b88077 100644 --- a/tests/handlers/events-subscribe.spec.ts +++ b/tests/handlers/events-subscribe.spec.ts @@ -117,11 +117,5 @@ export function testEventsSubscribeHandler(): void { expect(subscriptionReply.status.code).to.equal(401); expect(subscriptionReply.subscription).to.be.undefined; }); - - xit('should allow a non-tenant to subscribe to an event stream they are authorized for'); - - xit('should not allow to subscribe after a grant as been revoked'); - - xit('should not continue streaming messages after grant has been revoked'); }); } \ No newline at end of file From 0a193e7f43f1d062af62aaa9e20e9b2ebfa29003 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 10 Jan 2024 10:01:58 -0500 Subject: [PATCH 13/44] add error handling to general subscriptions --- src/event-log/event-stream.ts | 5 +++-- src/handlers/records-subscribe.ts | 5 ++++- src/types/events-types.ts | 2 ++ src/types/message-types.ts | 2 ++ 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/event-log/event-stream.ts b/src/event-log/event-stream.ts index bebb03cb6..606ea5e3b 100644 --- a/src/event-log/event-stream.ts +++ b/src/event-log/event-stream.ts @@ -38,6 +38,8 @@ export class EventStreamEmitter implements EventStream { return `${eventChannel}_bus`; } + // we subscribe to the general `EventEmitter` error events with this handler. + // this handler is also called when there is a caught error upon emitting an event from a handler. private eventError = (error: any): void => { console.error('event emitter error', error); }; @@ -106,8 +108,7 @@ export class EventStreamEmitter implements EventStream { try { this.eventEmitter.emit(this.eventChannel, tenant, message, indexes); } catch (error) { - //todo: dwn catch error; - throw error; // You can choose to handle or propagate the error as needed. + this.eventError(error); } } } \ No newline at end of file diff --git a/src/handlers/records-subscribe.ts b/src/handlers/records-subscribe.ts index 467c82f4d..89ccc916d 100644 --- a/src/handlers/records-subscribe.ts +++ b/src/handlers/records-subscribe.ts @@ -33,10 +33,11 @@ export class RecordsSubscribeHandler implements MethodHandler { } catch (e) { return messageReplyFromError(e, 400); } + let filters:Filter[] = []; // if this is an anonymous subscribe and the filter supports published records, subscribe to only published records if (RecordsSubscribeHandler.filterIncludesPublishedRecords(recordsSubscribe) && recordsSubscribe.author === undefined) { - // return a stream + // build filters for a stream of published records filters = await RecordsSubscribeHandler.subscribePublishedRecords(recordsSubscribe); } else { // authentication and authorization @@ -48,8 +49,10 @@ export class RecordsSubscribeHandler implements MethodHandler { } if (recordsSubscribe.author === tenant) { + // if the subscribe author is the tenant, filter as owner. filters = await RecordsSubscribeHandler.subscribeAsOwner(recordsSubscribe); } else { + // otherwise build filters based on published records, permissions, or protocol rules filters = await RecordsSubscribeHandler.subscribeAsNonOwner(recordsSubscribe); } } diff --git a/src/types/events-types.ts b/src/types/events-types.ts index 74581eac8..77f330ec3 100644 --- a/src/types/events-types.ts +++ b/src/types/events-types.ts @@ -1,3 +1,4 @@ +import type { DwnError } from '../index.js'; import type { ProtocolsQueryFilter } from './protocols-types.js'; import type { AuthorizationModel, GenericMessage, GenericMessageReply } from './message-types.js'; import type { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; @@ -52,6 +53,7 @@ export type EventsHandler = (message: GenericMessage) => void; export type EventsSubscription = { id: string; on: (handler: EventsHandler) => { off: () => void }; + onError: (handler: (error: DwnError) => void) => void; close: () => Promise; }; diff --git a/src/types/message-types.ts b/src/types/message-types.ts index 1b0c18722..33e0f7cce 100644 --- a/src/types/message-types.ts +++ b/src/types/message-types.ts @@ -1,4 +1,5 @@ import type { DelegatedGrantMessage } from '../types/delegated-grant-message.js'; +import type { DwnError } from '../index.js'; import type { GeneralJws } from './jws-types.js'; import type { Readable } from 'readable-stream'; import type { PaginationCursor, SortDirection } from './query-types.js'; @@ -80,6 +81,7 @@ export type GenericMessageHandler = (message: GenericMessage) => void; export type GenericMessageSubscription = { id: string; on: (handler: GenericMessageHandler) => { off: () => void }; + onError: (handler: (error: DwnError) => void) => void; close: () => Promise; }; From edff0d297e24ad51dfae42d5cf6e8acc82e522e2 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 10 Jan 2024 10:10:57 -0500 Subject: [PATCH 14/44] emit unknown error --- src/core/dwn-error.ts | 1 + src/handlers/records-subscribe.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/core/dwn-error.ts b/src/core/dwn-error.ts index 3750c682b..47647700a 100644 --- a/src/core/dwn-error.ts +++ b/src/core/dwn-error.ts @@ -105,6 +105,7 @@ export enum DwnErrorCode { RecordsReadAuthorizationFailed = 'RecordsReadAuthorizationFailed', RecordsSubscribeFilterMissingRequiredProperties = 'RecordsSubscribeFilterMissingRequiredProperties', RecordsSubscribeUnauthorized = 'RecordsSubscribeUnauthorized', + RecordsSubscribeUnknownError = 'RecordsSubscribeUnknownError', RecordsSchemasDerivationSchemeMissingSchema = 'RecordsSchemasDerivationSchemeMissingSchema', RecordsValidateIntegrityDelegatedGrantAndIdExistenceMismatch = 'RecordsValidateIntegrityDelegatedGrantAndIdExistenceMismatch', RecordsValidateIntegrityGrantedToAndSignerMismatch = 'RecordsValidateIntegrityGrantedToAndSignerMismatch', diff --git a/src/handlers/records-subscribe.ts b/src/handlers/records-subscribe.ts index 89ccc916d..488ddeda5 100644 --- a/src/handlers/records-subscribe.ts +++ b/src/handlers/records-subscribe.ts @@ -293,8 +293,14 @@ export class RecordsSubscriptionHandler extends SubscriptionBase { this.eventEmitter.emit(this.eventChannel, message); } catch (error) { - //todo: check for known authorization errors, and signal to user there has been an error - this.eventEmitter.emit(this.errorEventChannel, new DwnError(DwnErrorCode.RecordsSubscribeUnauthorized, 'this subscription has become unauthorized')); + const e = error as any; + //todo: add tests and error checking for other authorization errors + if (e.code === DwnErrorCode.ProtocolAuthorizationMissingRole) { + this.eventEmitter.emit(this.errorEventChannel, new DwnError(DwnErrorCode.RecordsSubscribeUnauthorized, 'this subscription has become unauthorized')); + } else { + this.eventEmitter.emit(this.errorEventChannel, new DwnError(DwnErrorCode.RecordsSubscribeUnknownError, 'unknown error occurred, the subscription has been closed')); + } + await this.close(); } } From e2cf628899e84f4a65debea01f934a831a65089c Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 10 Jan 2024 10:15:43 -0500 Subject: [PATCH 15/44] fix circular deps --- src/event-log/subscription.ts | 2 +- src/handlers/records-subscribe.ts | 2 +- src/types/events-types.ts | 2 +- src/types/message-types.ts | 2 +- src/types/records-types.ts | 2 +- src/types/subscriptions.ts | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/event-log/subscription.ts b/src/event-log/subscription.ts index d195537e9..cfc3d3627 100644 --- a/src/event-log/subscription.ts +++ b/src/event-log/subscription.ts @@ -1,4 +1,4 @@ -import type { DwnError } from '../index.js'; +import type { DwnError } from '../core/dwn-error.js'; import type { EventEmitter } from 'events'; import type { MessageStore } from '../types/message-store.js'; import type { EmitFunction, Subscription } from '../types/subscriptions.js'; diff --git a/src/handlers/records-subscribe.ts b/src/handlers/records-subscribe.ts index 488ddeda5..28ab59845 100644 --- a/src/handlers/records-subscribe.ts +++ b/src/handlers/records-subscribe.ts @@ -16,7 +16,7 @@ import { RecordsSubscribe } from '../interfaces/records-subscribe.js'; import { RecordsWrite } from '../interfaces/records-write.js'; import { SubscriptionBase } from '../event-log/subscription.js'; import { Time } from '../utils/time.js'; -import { DwnError, DwnErrorCode } from '../index.js'; +import { DwnError, DwnErrorCode } from '../core/dwn-error.js'; import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; export class RecordsSubscribeHandler implements MethodHandler { diff --git a/src/types/events-types.ts b/src/types/events-types.ts index 77f330ec3..7db6f7cc3 100644 --- a/src/types/events-types.ts +++ b/src/types/events-types.ts @@ -1,4 +1,4 @@ -import type { DwnError } from '../index.js'; +import type { DwnError } from '../core/dwn-error.js'; import type { ProtocolsQueryFilter } from './protocols-types.js'; import type { AuthorizationModel, GenericMessage, GenericMessageReply } from './message-types.js'; import type { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; diff --git a/src/types/message-types.ts b/src/types/message-types.ts index 33e0f7cce..3dfb461c2 100644 --- a/src/types/message-types.ts +++ b/src/types/message-types.ts @@ -1,5 +1,5 @@ import type { DelegatedGrantMessage } from '../types/delegated-grant-message.js'; -import type { DwnError } from '../index.js'; +import type { DwnError } from '../core/dwn-error.js'; import type { GeneralJws } from './jws-types.js'; import type { Readable } from 'readable-stream'; import type { PaginationCursor, SortDirection } from './query-types.js'; diff --git a/src/types/records-types.ts b/src/types/records-types.ts index 53a789509..358ab8674 100644 --- a/src/types/records-types.ts +++ b/src/types/records-types.ts @@ -1,4 +1,4 @@ -import type { DwnError } from '../index.js'; +import type { DwnError } from '../core/dwn-error.js'; import type { EncryptionAlgorithm } from '../utils/encryption.js'; import type { GeneralJws } from './jws-types.js'; import type { KeyDerivationScheme } from '../utils/hd-key.js'; diff --git a/src/types/subscriptions.ts b/src/types/subscriptions.ts index 7159cf3a3..38669c395 100644 --- a/src/types/subscriptions.ts +++ b/src/types/subscriptions.ts @@ -1,4 +1,4 @@ -import type { DwnError } from '../index.js'; +import type { DwnError } from '../core/dwn-error.js'; import type { GenericMessageReply } from '../types/message-types.js'; import type { MessageStore } from './message-store.js'; import type { EventsSubscribeMessage, EventsSubscription } from './events-types.js'; From 5c73cbace7ac248e7225a85ae69e0baaf54b1820 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 10 Jan 2024 10:33:55 -0500 Subject: [PATCH 16/44] update interface naming and add comments --- src/event-log/event-stream.ts | 4 ++-- src/event-log/subscription.ts | 9 +++++++-- src/handlers/events-subscribe.ts | 4 ++-- src/handlers/records-subscribe.ts | 4 ++-- src/types/subscriptions.ts | 10 +++++++++- 5 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/event-log/event-stream.ts b/src/event-log/event-stream.ts index 606ea5e3b..dc783aa79 100644 --- a/src/event-log/event-stream.ts +++ b/src/event-log/event-stream.ts @@ -1,6 +1,6 @@ import type { MessageStore } from '../types/message-store.js'; import type { EventsSubscribeMessage, EventsSubscription } from '../types/events-types.js'; -import type { EventStream, Subscription } from '../types/subscriptions.js'; +import type { EventStream, SubscriptionHandler } from '../types/subscriptions.js'; import type { Filter, KeyValues } from '../types/query-types.js'; import type { GenericMessage, GenericMessageSubscription } from '../types/message-types.js'; import type { RecordsSubscribeMessage, RecordsSubscription } from '../types/records-types.js'; @@ -25,7 +25,7 @@ export class EventStreamEmitter implements EventStream { private reauthorizationTTL: number; private isOpen: boolean = false; - private subscriptions: Map = new Map(); + private subscriptions: Map = new Map(); constructor(config?: EventStreamConfig) { this.reauthorizationTTL = config?.reauthorizationTTL || 0; // if set to zero it does not reauthorize diff --git a/src/event-log/subscription.ts b/src/event-log/subscription.ts index cfc3d3627..1b298c9ad 100644 --- a/src/event-log/subscription.ts +++ b/src/event-log/subscription.ts @@ -1,13 +1,18 @@ import type { DwnError } from '../core/dwn-error.js'; import type { EventEmitter } from 'events'; import type { MessageStore } from '../types/message-store.js'; -import type { EmitFunction, Subscription } from '../types/subscriptions.js'; +import type { EmitFunction, SubscriptionHandler } from '../types/subscriptions.js'; import type { Filter, KeyValues } from '../types/query-types.js'; import type { GenericMessage, GenericMessageHandler } from '../types/message-types.js'; import { FilterUtility } from '../utils/filter.js'; -export class SubscriptionBase implements Subscription { +/** + * Base class to extend default subscription behavior. + * + * ie. `RecordsSubscriptionHandler` has different rules for authorization and only matches specific message types. + */ +export class SubscriptionHandlerBase implements SubscriptionHandler { protected eventEmitter: EventEmitter; protected messageStore: MessageStore; protected filters: Filter[]; diff --git a/src/handlers/events-subscribe.ts b/src/handlers/events-subscribe.ts index a831fc18e..33dc345cc 100644 --- a/src/handlers/events-subscribe.ts +++ b/src/handlers/events-subscribe.ts @@ -10,7 +10,7 @@ import { Events } from '../utils/events.js'; import { EventsSubscribe } from '../interfaces/events-subscribe.js'; import { Message } from '../core/message.js'; import { messageReplyFromError } from '../core/message-reply.js'; -import { SubscriptionBase } from '../event-log/subscription.js'; +import { SubscriptionHandlerBase } from '../event-log/subscription.js'; import { authenticate, authorizeOwner } from '../core/auth.js'; export class EventsSubscribeHandler implements MethodHandler { @@ -57,7 +57,7 @@ export class EventsSubscribeHandler implements MethodHandler { } } -export class EventsSubscriptionHandler extends SubscriptionBase { +export class EventsSubscriptionHandler extends SubscriptionHandlerBase { public static async create(input: { tenant: string, message: EventsSubscribeMessage, diff --git a/src/handlers/records-subscribe.ts b/src/handlers/records-subscribe.ts index 28ab59845..6c8f3fa3e 100644 --- a/src/handlers/records-subscribe.ts +++ b/src/handlers/records-subscribe.ts @@ -14,7 +14,7 @@ import { Records } from '../utils/records.js'; import { RecordsDelete } from '../interfaces/records-delete.js'; import { RecordsSubscribe } from '../interfaces/records-subscribe.js'; import { RecordsWrite } from '../interfaces/records-write.js'; -import { SubscriptionBase } from '../event-log/subscription.js'; +import { SubscriptionHandlerBase } from '../event-log/subscription.js'; import { Time } from '../utils/time.js'; import { DwnError, DwnErrorCode } from '../core/dwn-error.js'; import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; @@ -231,7 +231,7 @@ export class RecordsSubscribeHandler implements MethodHandler { } } -export class RecordsSubscriptionHandler extends SubscriptionBase { +export class RecordsSubscriptionHandler extends SubscriptionHandlerBase { private recordsSubscribe: RecordsSubscribe; private reauthorizationTTL: number; diff --git a/src/types/subscriptions.ts b/src/types/subscriptions.ts index 38669c395..c5ae6ae1c 100644 --- a/src/types/subscriptions.ts +++ b/src/types/subscriptions.ts @@ -8,6 +8,9 @@ import type { RecordsSubscribeMessage, RecordsSubscription } from './records-typ export type EmitFunction = (tenant: string, message: GenericMessage, indexes: KeyValues) => void; +/** + * The EventStream interface implements a pub/sub system based on Message filters. + */ export interface EventStream { subscribe(tenant: string, message: EventsSubscribeMessage, filters: Filter[], messageStore: MessageStore): Promise; subscribe(tenant: string, message: RecordsSubscribeMessage, filters: Filter[], messageStore: MessageStore): Promise; @@ -17,7 +20,12 @@ export interface EventStream { close(): Promise; } -export interface Subscription { +/** + * The SubscriptionHandler interface is implemented by specific types of Subscription Handlers. + * + * ie. `RecordsSubscriptionHandler` has behavior to re-authorize subscriptions. + */ +export interface SubscriptionHandler { id: string; listener: EmitFunction; on: (handler: GenericMessageHandler) => { off: () => void }; From 786586e4d6e3d0af41f05dc17ce4ed8d9e48ce16 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 10 Jan 2024 14:08:21 -0500 Subject: [PATCH 17/44] rip out records subscribe --- build/compile-validators.js | 2 - .../interface-methods/protocol-rule-set.json | 1 - .../interface-methods/records-subscribe.json | 44 -- .../permissions/permissions-definitions.json | 3 - json-schemas/permissions/scopes.json | 18 - src/core/dwn-error.ts | 4 - src/core/protocol-authorization.ts | 51 +- src/core/records-grant-authorization.ts | 36 +- src/dwn.ts | 9 +- src/event-log/event-stream.ts | 16 +- src/event-log/subscription.ts | 2 - src/handlers/records-subscribe.ts | 317 -------- src/index.ts | 3 +- src/interfaces/records-subscribe.ts | 109 --- src/types/records-types.ts | 23 - src/types/subscriptions.ts | 4 - src/utils/records.ts | 4 +- tests/event-log/event-stream-emitter.spec.ts | 4 +- tests/handlers/records-subscribe.spec.ts | 714 ------------------ tests/interfaces/records-subscribe.spec.ts | 79 -- tests/scenarios/delegated-grant.spec.ts | 66 +- tests/scenarios/subscriptions.spec.ts | 539 +------------ tests/test-suite.ts | 2 - tests/utils/test-data-generator.ts | 58 +- .../protocol-definitions/friend-role.json | 4 - .../protocol-definitions/thread-role.json | 4 - 26 files changed, 16 insertions(+), 2100 deletions(-) delete mode 100644 json-schemas/interface-methods/records-subscribe.json delete mode 100644 src/handlers/records-subscribe.ts delete mode 100644 src/interfaces/records-subscribe.ts delete mode 100644 tests/handlers/records-subscribe.spec.ts delete mode 100644 tests/interfaces/records-subscribe.spec.ts diff --git a/build/compile-validators.js b/build/compile-validators.js index b69f572fa..5f84b7a5c 100644 --- a/build/compile-validators.js +++ b/build/compile-validators.js @@ -46,7 +46,6 @@ import RecordsDelete from '../json-schemas/interface-methods/records-delete.json import RecordsFilter from '../json-schemas/interface-methods/records-filter.json' assert { type: 'json' }; import RecordsQuery from '../json-schemas/interface-methods/records-query.json' assert { type: 'json' }; import RecordsRead from '../json-schemas/interface-methods/records-read.json' assert { type: 'json' }; -import RecordsSubscribe from '../json-schemas/interface-methods/records-subscribe.json' assert { type: 'json' }; import RecordsWrite from '../json-schemas/interface-methods/records-write.json' assert { type: 'json' }; import RecordsWriteSignaturePayload from '../json-schemas/signature-payloads/records-write-signature-payload.json' assert { type: 'json' }; import RecordsWriteUnidentified from '../json-schemas/interface-methods/records-write-unidentified.json' assert { type: 'json' }; @@ -57,7 +56,6 @@ const schemas = { AuthorizationOwner, RecordsDelete, RecordsQuery, - RecordsSubscribe, RecordsWrite, RecordsWriteUnidentified, EventsFilter, diff --git a/json-schemas/interface-methods/protocol-rule-set.json b/json-schemas/interface-methods/protocol-rule-set.json index db04fd141..72eec5034 100644 --- a/json-schemas/interface-methods/protocol-rule-set.json +++ b/json-schemas/interface-methods/protocol-rule-set.json @@ -66,7 +66,6 @@ "enum": [ "delete", "query", - "subscribe", "read", "update", "write" diff --git a/json-schemas/interface-methods/records-subscribe.json b/json-schemas/interface-methods/records-subscribe.json deleted file mode 100644 index 92c7939b0..000000000 --- a/json-schemas/interface-methods/records-subscribe.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://identity.foundation/dwn/json-schemas/records-subscribe.json", - "type": "object", - "additionalProperties": false, - "required": [ - "descriptor" - ], - "properties": { - "authorization": { - "$ref": "https://identity.foundation/dwn/json-schemas/authorization-delegated-grant.json" - }, - "descriptor": { - "type": "object", - "additionalProperties": false, - "required": [ - "interface", - "method", - "messageTimestamp", - "filter" - ], - "properties": { - "interface": { - "enum": [ - "Records" - ], - "type": "string" - }, - "method": { - "enum": [ - "Subscribe" - ], - "type": "string" - }, - "messageTimestamp": { - "$ref": "https://identity.foundation/dwn/json-schemas/defs.json#/definitions/date-time" - }, - "filter": { - "$ref": "https://identity.foundation/dwn/json-schemas/records-filter.json" - } - } - } - } -} \ No newline at end of file diff --git a/json-schemas/permissions/permissions-definitions.json b/json-schemas/permissions/permissions-definitions.json index 4e19ef0ab..985167cff 100644 --- a/json-schemas/permissions/permissions-definitions.json +++ b/json-schemas/permissions/permissions-definitions.json @@ -28,9 +28,6 @@ }, { "$ref": "https://identity.foundation/dwn/json-schemas/permissions/scopes.json#/definitions/records-query-scope" - }, - { - "$ref": "https://identity.foundation/dwn/json-schemas/permissions/scopes.json#/definitions/records-subscribe-scope" } ] }, diff --git a/json-schemas/permissions/scopes.json b/json-schemas/permissions/scopes.json index af83b84cd..5bd34e0ca 100644 --- a/json-schemas/permissions/scopes.json +++ b/json-schemas/permissions/scopes.json @@ -106,24 +106,6 @@ "type": "string" } } - }, - "records-subscribe-scope": { - "type": "object", - "required": [ - "interface", - "method" - ], - "properties": { - "interface": { - "const": "Records" - }, - "method": { - "const": "Subscribe" - }, - "protocol": { - "type": "string" - } - } } } } \ No newline at end of file diff --git a/src/core/dwn-error.ts b/src/core/dwn-error.ts index 47647700a..d06ab77b0 100644 --- a/src/core/dwn-error.ts +++ b/src/core/dwn-error.ts @@ -91,7 +91,6 @@ export enum DwnErrorCode { RecordsGrantAuthorizationDeleteProtocolScopeMismatch = 'RecordsGrantAuthorizationDeleteProtocolScopeMismatch', RecordsGrantAuthorizationQueryProtocolScopeMismatch = 'RecordsGrantAuthorizationQueryProtocolScopeMismatch', RecordsGrantAuthorizationScopeContextIdMismatch = 'RecordsGrantAuthorizationScopeContextIdMismatch', - RecordsGrantAuthorizationSubscribeProtocolScopeMismatch = 'RecordsGrantAuthorizationSubscribeProtocolScopeMismatch', RecordsGrantAuthorizationScopeNotProtocol = 'RecordsGrantAuthorizationScopeNotProtocol', RecordsGrantAuthorizationScopeProtocolMismatch = 'RecordsGrantAuthorizationScopeProtocolMismatch', RecordsGrantAuthorizationScopeProtocolPathMismatch = 'RecordsGrantAuthorizationScopeProtocolPathMismatch', @@ -103,9 +102,6 @@ export enum DwnErrorCode { RecordsQueryFilterMissingRequiredProperties = 'RecordsQueryFilterMissingRequiredProperties', RecordsReadReturnedMultiple = 'RecordsReadReturnedMultiple', RecordsReadAuthorizationFailed = 'RecordsReadAuthorizationFailed', - RecordsSubscribeFilterMissingRequiredProperties = 'RecordsSubscribeFilterMissingRequiredProperties', - RecordsSubscribeUnauthorized = 'RecordsSubscribeUnauthorized', - RecordsSubscribeUnknownError = 'RecordsSubscribeUnknownError', RecordsSchemasDerivationSchemeMissingSchema = 'RecordsSchemasDerivationSchemeMissingSchema', RecordsValidateIntegrityDelegatedGrantAndIdExistenceMismatch = 'RecordsValidateIntegrityDelegatedGrantAndIdExistenceMismatch', RecordsValidateIntegrityGrantedToAndSignerMismatch = 'RecordsValidateIntegrityGrantedToAndSignerMismatch', diff --git a/src/core/protocol-authorization.ts b/src/core/protocol-authorization.ts index 957b604f9..5ad04fd92 100644 --- a/src/core/protocol-authorization.ts +++ b/src/core/protocol-authorization.ts @@ -3,7 +3,6 @@ import type { MessageStore } from '../types/message-store.js'; import type { RecordsDelete } from '../interfaces/records-delete.js'; import type { RecordsQuery } from '../interfaces/records-query.js'; import type { RecordsRead } from '../interfaces/records-read.js'; -import type { RecordsSubscribe } from '../interfaces/records-subscribe.js'; import type { RecordsWriteMessage } from '../types/records-types.js'; import type { ProtocolActionRule, ProtocolDefinition, ProtocolRuleSet, ProtocolsConfigureMessage, ProtocolType, ProtocolTypes } from '../types/protocols-types.js'; @@ -153,47 +152,6 @@ export class ProtocolAuthorization { ); } - public static async authorizeSubscription( - tenant: string, - incomingMessage: RecordsSubscribe, - messageStore: MessageStore, - ): Promise { - // validate that required properties exist in subscription filter - const { protocol, protocolPath, contextId } = incomingMessage.message.descriptor.filter; - - // fetch the protocol definition - const protocolDefinition = await ProtocolAuthorization.fetchProtocolDefinition( - tenant, - protocol!, // `authorizeSubscription` is only called if `protocol` is present - messageStore, - ); - - // get the rule set for the inbound message - const inboundMessageRuleSet = ProtocolAuthorization.getRuleSet( - protocolPath!, // presence of `protocolPath` is verified in `parse()` - protocolDefinition, - ); - - // If the incoming message has `protocolRole` in the descriptor, validate the invoked role - await ProtocolAuthorization.verifyInvokedRole( - tenant, - incomingMessage, - protocol!, - contextId, - protocolDefinition, - messageStore, - ); - - // verify method invoked against the allowed actions - await ProtocolAuthorization.verifyAllowedActions( - tenant, - incomingMessage, - inboundMessageRuleSet, - [], // ancestor chain is not relevant to subscriptions - messageStore, - ); - } - /** * Performs protocol-based authorization against the incoming RecordsQuery message. * @throws {Error} if authorization fails. @@ -465,7 +423,7 @@ export class ProtocolAuthorization { */ private static async verifyInvokedRole( tenant: string, - incomingMessage: RecordsDelete | RecordsQuery | RecordsRead | RecordsSubscribe | RecordsWrite, + incomingMessage: RecordsDelete | RecordsQuery | RecordsRead | RecordsWrite, protocolUri: string, contextId: string | undefined, protocolDefinition: ProtocolDefinition, @@ -523,7 +481,7 @@ export class ProtocolAuthorization { */ private static async getActionsSeekingARuleMatch( tenant: string, - incomingMessage: RecordsDelete | RecordsQuery | RecordsRead | RecordsSubscribe | RecordsWrite, + incomingMessage: RecordsDelete | RecordsQuery | RecordsRead | RecordsWrite, messageStore: MessageStore, ): Promise { @@ -537,9 +495,6 @@ export class ProtocolAuthorization { case DwnMethodName.Read: return [ProtocolAction.Read]; - case DwnMethodName.Subscribe: - return [ProtocolAction.Subscribe]; - case DwnMethodName.Write: const incomingRecordsWrite = incomingMessage as RecordsWrite; if (await incomingRecordsWrite.isInitialWrite()) { @@ -564,7 +519,7 @@ export class ProtocolAuthorization { */ private static async verifyAllowedActions( tenant: string, - incomingMessage: RecordsDelete | RecordsQuery | RecordsRead | RecordsSubscribe | RecordsWrite, + incomingMessage: RecordsDelete | RecordsQuery | RecordsRead | RecordsWrite, inboundMessageRuleSet: ProtocolRuleSet, ancestorMessageChain: RecordsWriteMessage[], messageStore: MessageStore, diff --git a/src/core/records-grant-authorization.ts b/src/core/records-grant-authorization.ts index 7062d40da..6b3259d55 100644 --- a/src/core/records-grant-authorization.ts +++ b/src/core/records-grant-authorization.ts @@ -1,7 +1,7 @@ import type { MessageStore } from '../types/message-store.js'; import type { RecordsPermissionScope } from '../types/permissions-grant-descriptor.js'; import type { PermissionsGrantMessage, RecordsPermissionsGrantMessage } from '../types/permissions-types.js'; -import type { RecordsDeleteMessage, RecordsQueryMessage, RecordsReadMessage, RecordsSubscribeMessage, RecordsWriteMessage } from '../types/records-types.js'; +import type { RecordsDeleteMessage, RecordsQueryMessage, RecordsReadMessage, RecordsWriteMessage } from '../types/records-types.js'; import { GrantAuthorization } from './grant-authorization.js'; import { PermissionsConditionPublication } from '../types/permissions-grant-descriptor.js'; @@ -96,40 +96,6 @@ export class RecordsGrantAuthorization { } } - /** - * Authorizes the scope of a PermissionsGrant for RecordsSubscribe. - * @param messageStore Used to check if the grant has been revoked. - */ - public static async authorizeSubscribe(input: { - recordsSubscribeMessage: RecordsSubscribeMessage, - expectedGrantedToInGrant: string, - expectedGrantedForInGrant: string, - permissionsGrantMessage: PermissionsGrantMessage, - messageStore: MessageStore, - }): Promise { - const { - recordsSubscribeMessage, expectedGrantedToInGrant, expectedGrantedForInGrant, permissionsGrantMessage, messageStore - } = input; - - await GrantAuthorization.performBaseValidation({ - incomingMessage: recordsSubscribeMessage, - expectedGrantedToInGrant, - expectedGrantedForInGrant, - permissionsGrantMessage, - messageStore - }); - - // If the grant specifies a protocol, the query must specify the same protocol. - const protocolInGrant = (permissionsGrantMessage.descriptor.scope as RecordsPermissionScope).protocol; - const protocolInSubscribe = recordsSubscribeMessage.descriptor.filter.protocol; - if (protocolInGrant !== undefined && protocolInSubscribe !== protocolInGrant) { - throw new DwnError( - DwnErrorCode.RecordsGrantAuthorizationSubscribeProtocolScopeMismatch, - `Grant protocol scope ${protocolInGrant} does not match protocol in subscribe ${protocolInSubscribe}` - ); - } - } - /** * Authorizes the scope of a PermissionsGrant for RecordsDelete. * @param messageStore Used to check if the grant has been revoked. diff --git a/src/dwn.ts b/src/dwn.ts index 26c26f0f0..3e379d2ca 100644 --- a/src/dwn.ts +++ b/src/dwn.ts @@ -10,7 +10,7 @@ import type { GenericMessage, GenericMessageReply, MessageOptions } from './type import type { MessagesGetMessage, MessagesGetReply } from './types/messages-types.js'; import type { PermissionsGrantMessage, PermissionsRequestMessage, PermissionsRevokeMessage } from './types/permissions-types.js'; import type { ProtocolsConfigureMessage, ProtocolsQueryMessage, ProtocolsQueryReply } from './types/protocols-types.js'; -import type { RecordsDeleteMessage, RecordsQueryMessage, RecordsQueryReply, RecordsReadMessage, RecordsReadReply, RecordsSubscribeMessage, RecordsSubscribeReply, RecordsWriteMessage, RecordsWriteMessageOptions } from './types/records-types.js'; +import type { RecordsDeleteMessage, RecordsQueryMessage, RecordsQueryReply, RecordsReadMessage, RecordsReadReply, RecordsWriteMessage, RecordsWriteMessageOptions } from './types/records-types.js'; import { AllowAllTenantGate } from './core/tenant-gate.js'; import { DidResolver } from './did/did-resolver.js'; @@ -29,7 +29,6 @@ import { ProtocolsQueryHandler } from './handlers/protocols-query.js'; import { RecordsDeleteHandler } from './handlers/records-delete.js'; import { RecordsQueryHandler } from './handlers/records-query.js'; import { RecordsReadHandler } from './handlers/records-read.js'; -import { RecordsSubscribeHandler } from './handlers/records-subscribe.js'; import { RecordsWriteHandler } from './handlers/records-write.js'; import { DwnInterfaceName, DwnMethodName } from './enums/dwn-interface-method.js'; @@ -115,11 +114,6 @@ export class Dwn { this.messageStore, this.dataStore ), - [DwnInterfaceName.Records + DwnMethodName.Subscribe]: new RecordsSubscribeHandler( - this.didResolver, - this.messageStore, - this.eventStream - ), [DwnInterfaceName.Records + DwnMethodName.Write]: new RecordsWriteHandler( this.didResolver, this.messageStore, @@ -173,7 +167,6 @@ export class Dwn { public async processMessage(tenant: string, rawMessage: PermissionsRevokeMessage): Promise; public async processMessage(tenant: string, rawMessage: RecordsDeleteMessage): Promise; public async processMessage(tenant: string, rawMessage: RecordsQueryMessage): Promise; - public async processMessage(tenant: string, rawMessage: RecordsSubscribeMessage): Promise; public async processMessage(tenant: string, rawMessage: RecordsReadMessage): Promise; public async processMessage(tenant: string, rawMessage: RecordsWriteMessage, options?: RecordsWriteMessageOptions): Promise; public async processMessage(tenant: string, rawMessage: unknown, options?: MessageOptions): Promise; diff --git a/src/event-log/event-stream.ts b/src/event-log/event-stream.ts index dc783aa79..7b78f6eaa 100644 --- a/src/event-log/event-stream.ts +++ b/src/event-log/event-stream.ts @@ -3,14 +3,11 @@ import type { EventsSubscribeMessage, EventsSubscription } from '../types/events import type { EventStream, SubscriptionHandler } from '../types/subscriptions.js'; import type { Filter, KeyValues } from '../types/query-types.js'; import type { GenericMessage, GenericMessageSubscription } from '../types/message-types.js'; -import type { RecordsSubscribeMessage, RecordsSubscription } from '../types/records-types.js'; import { EventEmitter } from 'events'; import { EventsSubscribe } from '../interfaces/events-subscribe.js'; import { EventsSubscriptionHandler } from '../handlers/events-subscribe.js'; import { Message } from '../core/message.js'; -import { RecordsSubscribe } from '../interfaces/records-subscribe.js'; -import { RecordsSubscriptionHandler } from '../handlers/records-subscribe.js'; import { DwnError, DwnErrorCode } from '../core/dwn-error.js'; const eventChannel = 'events'; @@ -45,7 +42,6 @@ export class EventStreamEmitter implements EventStream { }; async subscribe(tenant: string, message: EventsSubscribeMessage, filters: Filter[], messageStore: MessageStore): Promise; - async subscribe(tenant: string, message: RecordsSubscribeMessage, filters: Filter[], messageStore: MessageStore): Promise; async subscribe(tenant: string, message: GenericMessage, filters: Filter[], messageStore: MessageStore): Promise { const messageCid = await Message.getCid(message); let subscription = this.subscriptions.get(messageCid); @@ -53,17 +49,7 @@ export class EventStreamEmitter implements EventStream { return subscription; } - if (RecordsSubscribe.isRecordsSubscribeMessage(message)) { - subscription = await RecordsSubscriptionHandler.create({ - tenant, - message, - filters, - messageStore, - unsubscribe : () => this.unsubscribe(messageCid), - eventEmitter : this.eventEmitter, - reauthorizationTTL : this.reauthorizationTTL, - }); - } else if (EventsSubscribe.isEventsSubscribeMessage(message)) { + if (EventsSubscribe.isEventsSubscribeMessage(message)) { subscription = await EventsSubscriptionHandler.create({ tenant, message, diff --git a/src/event-log/subscription.ts b/src/event-log/subscription.ts index 1b298c9ad..8fb337812 100644 --- a/src/event-log/subscription.ts +++ b/src/event-log/subscription.ts @@ -9,8 +9,6 @@ import { FilterUtility } from '../utils/filter.js'; /** * Base class to extend default subscription behavior. - * - * ie. `RecordsSubscriptionHandler` has different rules for authorization and only matches specific message types. */ export class SubscriptionHandlerBase implements SubscriptionHandler { protected eventEmitter: EventEmitter; diff --git a/src/handlers/records-subscribe.ts b/src/handlers/records-subscribe.ts deleted file mode 100644 index 6c8f3fa3e..000000000 --- a/src/handlers/records-subscribe.ts +++ /dev/null @@ -1,317 +0,0 @@ -import type { DidResolver } from '../did/did-resolver.js'; -import type { EventEmitter } from 'events'; -import type { Filter } from '../types/query-types.js'; -import type { MessageStore } from '../types//message-store.js'; -import type { MethodHandler } from '../types/method-handler.js'; -import type { EmitFunction, EventStream } from '../types/subscriptions.js'; -import type { RecordsDeleteMessage, RecordsSubscribeMessage, RecordsSubscribeReply, RecordsWriteMessage } from '../types/records-types.js'; - -import { authenticate } from '../core/auth.js'; -import { Message } from '../core/message.js'; -import { messageReplyFromError } from '../core/message-reply.js'; -import { ProtocolAuthorization } from '../core/protocol-authorization.js'; -import { Records } from '../utils/records.js'; -import { RecordsDelete } from '../interfaces/records-delete.js'; -import { RecordsSubscribe } from '../interfaces/records-subscribe.js'; -import { RecordsWrite } from '../interfaces/records-write.js'; -import { SubscriptionHandlerBase } from '../event-log/subscription.js'; -import { Time } from '../utils/time.js'; -import { DwnError, DwnErrorCode } from '../core/dwn-error.js'; -import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; - -export class RecordsSubscribeHandler implements MethodHandler { - - constructor(private didResolver: DidResolver, private messageStore: MessageStore, private eventStream: EventStream) { } - - public async handle({ - tenant, - message - }: {tenant: string, message: RecordsSubscribeMessage}): Promise { - let recordsSubscribe: RecordsSubscribe; - try { - recordsSubscribe = await RecordsSubscribe.parse(message); - } catch (e) { - return messageReplyFromError(e, 400); - } - - let filters:Filter[] = []; - // if this is an anonymous subscribe and the filter supports published records, subscribe to only published records - if (RecordsSubscribeHandler.filterIncludesPublishedRecords(recordsSubscribe) && recordsSubscribe.author === undefined) { - // build filters for a stream of published records - filters = await RecordsSubscribeHandler.subscribePublishedRecords(recordsSubscribe); - } else { - // authentication and authorization - try { - await authenticate(message.authorization!, this.didResolver); - await RecordsSubscribeHandler.authorizeRecordsSubscribe(tenant, recordsSubscribe, this.messageStore); - } catch (error) { - return messageReplyFromError(error, 401); - } - - if (recordsSubscribe.author === tenant) { - // if the subscribe author is the tenant, filter as owner. - filters = await RecordsSubscribeHandler.subscribeAsOwner(recordsSubscribe); - } else { - // otherwise build filters based on published records, permissions, or protocol rules - filters = await RecordsSubscribeHandler.subscribeAsNonOwner(recordsSubscribe); - } - } - - const subscription = await this.eventStream.subscribe(tenant, message, filters, this.messageStore); - return { - status: { code: 200, detail: 'OK' }, - subscription - }; - } - - /** - * Fetches the records as the owner of the DWN with no additional filtering. - */ - private static async subscribeAsOwner(RecordsSubscribe: RecordsSubscribe): Promise { - const { filter } = RecordsSubscribe.message.descriptor; - - const subscribeFilter = { - ...Records.convertFilter(filter), - interface : DwnInterfaceName.Records, - method : [ DwnMethodName.Write, DwnMethodName.Delete ], // we fetch both write and delete so that subscriber can update state. - }; - - return [subscribeFilter]; - } - - /** - * Subscribe to records as a non-owner. - * - * Filters can support returning both published and unpublished records, - * as well as explicitly only published or only unpublished records. - * - * A) BOTH published and unpublished: - * 1. published records; and - * 2. unpublished records intended for the subscription author (where `recipient` is the subscription author); and - * 3. unpublished records authorized by a protocol rule. - * - * B) PUBLISHED: - * 1. only published records; - * - * C) UNPUBLISHED: - * 1. unpublished records intended for the subscription author (where `recipient` is the subscription author); and - * 2. unpublished records authorized by a protocol rule. - * - */ - private static async subscribeAsNonOwner( - recordsSubscribe: RecordsSubscribe - ): Promise { - const filters:Filter[] = []; - - if (RecordsSubscribeHandler.filterIncludesPublishedRecords(recordsSubscribe)) { - filters.push(RecordsSubscribeHandler.buildPublishedRecordsFilter(recordsSubscribe)); - } - - if (RecordsSubscribeHandler.filterIncludesUnpublishedRecords(recordsSubscribe)) { - filters.push(RecordsSubscribeHandler.buildUnpublishedRecordsBySubscribeAuthorFilter(recordsSubscribe)); - - const recipientFilter = recordsSubscribe.message.descriptor.filter.recipient; - if (recipientFilter === undefined || recipientFilter === recordsSubscribe.author) { - filters.push(RecordsSubscribeHandler.buildUnpublishedRecordsForSubscribeAuthorFilter(recordsSubscribe)); - } - - if (RecordsSubscribeHandler.shouldProtocolAuthorizeSubscribe(recordsSubscribe)) { - filters.push(RecordsSubscribeHandler.buildUnpublishedProtocolAuthorizedRecordsFilter(recordsSubscribe)); - } - } - return filters; - } - - /** - * Fetches only published records. - */ - private static async subscribePublishedRecords( - recordsSubscribe: RecordsSubscribe - ): Promise { - const filter = RecordsSubscribeHandler.buildPublishedRecordsFilter(recordsSubscribe); - return [filter]; - } - - private static buildPublishedRecordsFilter(recordsSubscribe: RecordsSubscribe): Filter { - // fetch all published records matching the subscribe - return { - ...Records.convertFilter(recordsSubscribe.message.descriptor.filter), - interface : DwnInterfaceName.Records, - method : [ DwnMethodName.Write, DwnMethodName.Delete ], - published : true, - }; - } - - /** - * Creates a filter for unpublished records that are intended for the subscribe author (where `recipient` is the author). - */ - private static buildUnpublishedRecordsForSubscribeAuthorFilter(recordsSubscribe: RecordsSubscribe): Filter { - // include records where recipient is subscribe author - return { - ...Records.convertFilter(recordsSubscribe.message.descriptor.filter), - interface : DwnInterfaceName.Records, - method : [ DwnMethodName.Write, DwnMethodName.Delete ], - recipient : recordsSubscribe.author!, - published : false - }; - } - - /** - * Creates a filter for unpublished records that are within the specified protocol. - * Validation that `protocol` and other required protocol-related fields occurs before this method. - */ - private static buildUnpublishedProtocolAuthorizedRecordsFilter(recordsSubscribe: RecordsSubscribe): Filter { - return { - ...Records.convertFilter(recordsSubscribe.message.descriptor.filter), - interface : DwnInterfaceName.Records, - method : [ DwnMethodName.Write, DwnMethodName.Delete ], - published : false - }; - } - - /** - * Creates a filter for only unpublished records where the author is the same as the subscribe author. - */ - private static buildUnpublishedRecordsBySubscribeAuthorFilter(recordsSubscribe: RecordsSubscribe): Filter { - // include records where author is the same as the subscribe author - return { - ...Records.convertFilter(recordsSubscribe.message.descriptor.filter), - author : recordsSubscribe.author!, - interface : DwnInterfaceName.Records, - method : [ DwnMethodName.Write, DwnMethodName.Delete ], - published : false - }; - } - - /** - * Determines if ProtocolAuthorization.authorizeSubscribe should be run and if the corresponding filter should be used. - */ - private static shouldProtocolAuthorizeSubscribe(recordsSubscribe: RecordsSubscribe): boolean { - return recordsSubscribe.signaturePayload!.protocolRole !== undefined; - } - - /** - * Checks if the recordSubscribe filter supports returning published records. - */ - private static filterIncludesPublishedRecords(recordsSubscribe: RecordsSubscribe): boolean { - const { filter } = recordsSubscribe.message.descriptor; - // When `published` and `datePublished` range are both undefined, published records can be returned. - return filter.datePublished !== undefined || filter.published !== false; - } - - /** - * Checks if the recordSubscribe filter supports returning unpublished records. - */ - private static filterIncludesUnpublishedRecords(recordsSubscribe: RecordsSubscribe): boolean { - const { filter } = recordsSubscribe.message.descriptor; - // When `published` and `datePublished` range are both undefined, unpublished records can be returned. - if (filter.datePublished === undefined && filter.published === undefined) { - return true; - } - return filter.published === false; - } - - /** - * @param messageStore Used to check if the grant has been revoked. - */ - public static async authorizeRecordsSubscribe( - tenant: string, - recordsSubscribe: RecordsSubscribe, - messageStore: MessageStore - ): Promise { - - if (Message.isSignedByDelegate(recordsSubscribe.message)) { - await recordsSubscribe.authorizeDelegate(messageStore); - } - - // Only run protocol authz if message deliberately invokes it - if (RecordsSubscribeHandler.shouldProtocolAuthorizeSubscribe(recordsSubscribe)) { - await ProtocolAuthorization.authorizeSubscription(tenant, recordsSubscribe, messageStore); - } - } -} - -export class RecordsSubscriptionHandler extends SubscriptionHandlerBase { - private recordsSubscribe: RecordsSubscribe; - - private reauthorizationTTL: number; - private reauthorizationTime?: string; - - constructor(options: { - id: string, - tenant: string, - recordsSubscribe: RecordsSubscribe, - filters: Filter[], - eventEmitter: EventEmitter, - messageStore: MessageStore, - unsubscribe: () => Promise; - reauthorizationTTL:number, - }) { - const { recordsSubscribe, reauthorizationTTL } = options; - super({ ...options, message: recordsSubscribe.message }); - this.recordsSubscribe = recordsSubscribe; - - // set reauthorization option, if reauthorizationTTL is zero it will never re-authorize. - // if reauthorizationTTL is less than zero it will re-authorize with each matching event. - // otherwise it will re-authorize only after a TTL and reset its timer each time. - this.reauthorizationTTL = reauthorizationTTL; - if (this.reauthorizationTTL > 0) { - this.reauthorizationTime = Time.createOffsetTimestamp({ seconds: this.reauthorizationTTL }); - } - } - - get shouldAuthorize(): boolean { - return this.reauthorizationTTL < 0 || - this.reauthorizationTime !== undefined && Time.getCurrentTimestamp() >= this.reauthorizationTime; - } - - private async reauthorize():Promise { - this.reauthorizationTime = Time.createOffsetTimestamp({ seconds: this.reauthorizationTTL! }); - await RecordsSubscribeHandler.authorizeRecordsSubscribe(this.tenant, this.recordsSubscribe, this.messageStore); - } - - public static async create(input: { - tenant: string, - message: RecordsSubscribeMessage, - filters: Filter[], - eventEmitter: EventEmitter, - messageStore: MessageStore, - unsubscribe: () => Promise; - reauthorizationTTL: number - }): Promise { - const id = await Message.getCid(input.message); - const recordsSubscribe = await RecordsSubscribe.parse(input.message); - return new RecordsSubscriptionHandler({ ...input, id, recordsSubscribe }); - } - - public listener: EmitFunction = async (tenant, message, indexes):Promise => { - if ((RecordsWrite.isRecordsWriteMessage(message) || RecordsDelete.isRecordsDeleteMessage(message)) && this.matchFilters(tenant, indexes)) { - try { - if (this.shouldAuthorize) { - await this.reauthorize(); - } - - this.eventEmitter.emit(this.eventChannel, message); - } catch (error) { - const e = error as any; - //todo: add tests and error checking for other authorization errors - if (e.code === DwnErrorCode.ProtocolAuthorizationMissingRole) { - this.eventEmitter.emit(this.errorEventChannel, new DwnError(DwnErrorCode.RecordsSubscribeUnauthorized, 'this subscription has become unauthorized')); - } else { - this.eventEmitter.emit(this.errorEventChannel, new DwnError(DwnErrorCode.RecordsSubscribeUnknownError, 'unknown error occurred, the subscription has been closed')); - } - - await this.close(); - } - } - }; - - on(handler:(message: RecordsWriteMessage | RecordsDeleteMessage) => void): { off: () => void } { - this.eventEmitter.on(this.eventChannel, handler); - return { - off: (): void => { - this.eventEmitter.off(this.eventChannel, handler); - } - }; - } -}; diff --git a/src/index.ts b/src/index.ts index 5c7c78b02..a00723658 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,7 @@ export type { Filter, EqualFilter, OneOfFilter, RangeFilter, RangeCriterion, Pag export type { PermissionConditions, PermissionScope, PermissionsGrantDescriptor } from './types/permissions-grant-descriptor.js'; export type { PermissionsGrantMessage, PermissionsRequestDescriptor, PermissionsRequestMessage, PermissionsRevokeDescriptor, PermissionsRevokeMessage } from './types/permissions-types.js'; export type { ProtocolsConfigureDescriptor, ProtocolDefinition, ProtocolTypes, ProtocolRuleSet, ProtocolsQueryFilter, ProtocolsConfigureMessage, ProtocolsQueryMessage, ProtocolsQueryReply } from './types/protocols-types.js'; -export type { EncryptionProperty, RecordsDeleteMessage, RecordsQueryMessage, RecordsQueryReply, RecordsQueryReplyEntry, RecordsReadReply, RecordsSubscribeDescriptor, RecordsSubscribeMessage, RecordsSubscription, RecordsWriteDescriptor, RecordsWriteMessage } from './types/records-types.js'; +export type { EncryptionProperty, RecordsDeleteMessage, RecordsQueryMessage, RecordsQueryReply, RecordsQueryReplyEntry, RecordsReadReply, RecordsWriteDescriptor, RecordsWriteMessage } from './types/records-types.js'; export { authenticate } from './core/auth.js'; export { ActiveTenantCheckResult, AllowAllTenantGate, TenantGate } from './core/tenant-gate.js'; export { Cid } from './utils/cid.js'; @@ -47,7 +47,6 @@ export { ProtocolsQuery, ProtocolsQueryOptions } from './interfaces/protocols-qu export { Records } from './utils/records.js'; export { RecordsDelete, RecordsDeleteOptions } from './interfaces/records-delete.js'; export { RecordsRead, RecordsReadOptions } from './interfaces/records-read.js'; -export { RecordsSubscribe, RecordsSubscribeOptions } from './interfaces/records-subscribe.js'; export { Secp256k1 } from './utils/secp256k1.js'; export { Signer } from './types/signer.js'; export { SortDirection } from './types/query-types.js'; diff --git a/src/interfaces/records-subscribe.ts b/src/interfaces/records-subscribe.ts deleted file mode 100644 index c4be87c9f..000000000 --- a/src/interfaces/records-subscribe.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { DelegatedGrantMessage } from '../types/delegated-grant-message.js'; -import type { GenericMessage } from '../types/message-types.js'; -import type { MessageStore } from '../types/message-store.js'; -import type { Signer } from '../types/signer.js'; -import type { RecordsFilter, RecordsSubscribeDescriptor, RecordsSubscribeMessage } from '../types/records-types.js'; - -import { AbstractMessage } from '../core/abstract-message.js'; -import { Message } from '../core/message.js'; -import { Records } from '../utils/records.js'; -import { RecordsGrantAuthorization } from '../core/records-grant-authorization.js'; -import { removeUndefinedProperties } from '../utils/object.js'; -import { Time } from '../utils/time.js'; -import { DwnError, DwnErrorCode } from '../core/dwn-error.js'; -import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; -import { validateProtocolUrlNormalized, validateSchemaUrlNormalized } from '../utils/url.js'; - -export type RecordsSubscribeOptions = { - messageTimestamp?: string; - filter: RecordsFilter; - signer?: Signer; - protocolRole?: string; - - /** - * The delegated grant to sign on behalf of the logical author, which is the grantor (`grantedBy`) of the delegated grant. - */ - delegatedGrant?: DelegatedGrantMessage; -}; - -/** - * A class representing a RecordsSubscribe DWN message. - */ -export class RecordsSubscribe extends AbstractMessage { - - public static async parse(message: RecordsSubscribeMessage): Promise { - let signaturePayload; - if (message.authorization !== undefined) { - signaturePayload = await Message.validateSignatureStructure(message.authorization.signature, message.descriptor); - } - - Records.validateDelegatedGrantReferentialIntegrity(message, signaturePayload); - - if (signaturePayload?.protocolRole !== undefined) { - if (message.descriptor.filter.protocolPath === undefined) { - throw new DwnError( - DwnErrorCode.RecordsSubscribeFilterMissingRequiredProperties, - 'Role-authorized queries must include `protocolPath` in the filter' - ); - } - } - if (message.descriptor.filter.protocol !== undefined) { - validateProtocolUrlNormalized(message.descriptor.filter.protocol); - } - if (message.descriptor.filter.schema !== undefined) { - validateSchemaUrlNormalized(message.descriptor.filter.schema); - } - Time.validateTimestamp(message.descriptor.messageTimestamp); - - return new RecordsSubscribe(message); - } - - public static async create(options: RecordsSubscribeOptions): Promise { - const descriptor: RecordsSubscribeDescriptor = { - interface : DwnInterfaceName.Records, - method : DwnMethodName.Subscribe, - messageTimestamp : options.messageTimestamp ?? Time.getCurrentTimestamp(), - filter : Records.normalizeFilter(options.filter), - }; - - // delete all descriptor properties that are `undefined` else the code will encounter the following IPLD issue when attempting to generate CID: - // Error: `undefined` is not supported by the IPLD Data Model and cannot be encoded - removeUndefinedProperties(descriptor); - - // only generate the `authorization` property if signature input is given - const signer = options.signer; - let authorization; - if (signer) { - authorization = await Message.createAuthorization({ - descriptor, - signer, - protocolRole : options.protocolRole, - delegatedGrant : options.delegatedGrant - }); - } - const message = { descriptor, authorization }; - - Message.validateJsonSchema(message); - - return new RecordsSubscribe(message); - } - - /** - * Authorizes the delegate who signed the message. - * @param messageStore Used to check if the grant has been revoked. - */ - public async authorizeDelegate(messageStore: MessageStore): Promise { - const delegatedGrant = this.message.authorization!.authorDelegatedGrant!; - await RecordsGrantAuthorization.authorizeSubscribe({ - recordsSubscribeMessage : this.message, - expectedGrantedToInGrant : this.signer!, - expectedGrantedForInGrant : this.author!, - permissionsGrantMessage : delegatedGrant, - messageStore - }); - } - - public static isRecordsSubscribeMessage(message: GenericMessage): message is RecordsSubscribeMessage { - return message.descriptor.interface === DwnInterfaceName.Records && message.descriptor.method === DwnMethodName.Subscribe; - } -} diff --git a/src/types/records-types.ts b/src/types/records-types.ts index 358ab8674..18ea8211b 100644 --- a/src/types/records-types.ts +++ b/src/types/records-types.ts @@ -1,4 +1,3 @@ -import type { DwnError } from '../core/dwn-error.js'; import type { EncryptionAlgorithm } from '../utils/encryption.js'; import type { GeneralJws } from './jws-types.js'; import type { KeyDerivationScheme } from '../utils/hd-key.js'; @@ -107,13 +106,6 @@ export type RecordsQueryDescriptor = { pagination?: Pagination; }; -export type RecordsSubscribeDescriptor = { - interface: DwnInterfaceName.Records; - method: DwnMethodName.Subscribe; - messageTimestamp: string; - filter: RecordsFilter; -}; - export type RecordsFilter = { /**the logical author of the record */ author?: string; @@ -154,14 +146,6 @@ export type RecordsQueryReply = GenericMessageReply & { cursor?: PaginationCursor; }; -export type RecordsSubscribeMessage = GenericMessage & { - descriptor: RecordsSubscribeDescriptor; -}; - -export type RecordsSubscribeReply = GenericMessageReply & { - subscription?: RecordsSubscription; -}; - export type RecordsReadMessage = { authorization?: AuthorizationModel; descriptor: RecordsReadDescriptor; @@ -195,10 +179,3 @@ export type RecordsDeleteDescriptor = { recordId: string; messageTimestamp: string; }; - -export type RecordsSubscription = { - id: string; - on: (handler:(message: RecordsWriteMessage | RecordsDeleteMessage) => void) => { off: () => void }; - onError: (handler:(error: DwnError) => void) => void; - close: () => Promise; -}; \ No newline at end of file diff --git a/src/types/subscriptions.ts b/src/types/subscriptions.ts index c5ae6ae1c..2ec702cf3 100644 --- a/src/types/subscriptions.ts +++ b/src/types/subscriptions.ts @@ -4,7 +4,6 @@ import type { MessageStore } from './message-store.js'; import type { EventsSubscribeMessage, EventsSubscription } from './events-types.js'; import type { Filter, KeyValues } from './query-types.js'; import type { GenericMessage, GenericMessageHandler, GenericMessageSubscription } from './message-types.js'; -import type { RecordsSubscribeMessage, RecordsSubscription } from './records-types.js'; export type EmitFunction = (tenant: string, message: GenericMessage, indexes: KeyValues) => void; @@ -13,7 +12,6 @@ export type EmitFunction = (tenant: string, message: GenericMessage, indexes: Ke */ export interface EventStream { subscribe(tenant: string, message: EventsSubscribeMessage, filters: Filter[], messageStore: MessageStore): Promise; - subscribe(tenant: string, message: RecordsSubscribeMessage, filters: Filter[], messageStore: MessageStore): Promise; subscribe(tenant: string, message: GenericMessage, filters: Filter[], messageStore: MessageStore): Promise; emit(tenant: string, message: GenericMessage, indexes: KeyValues): void; open(): Promise; @@ -22,8 +20,6 @@ export interface EventStream { /** * The SubscriptionHandler interface is implemented by specific types of Subscription Handlers. - * - * ie. `RecordsSubscriptionHandler` has behavior to re-authorize subscriptions. */ export interface SubscriptionHandler { id: string; diff --git a/src/utils/records.ts b/src/utils/records.ts index 266d8124d..f998f0d55 100644 --- a/src/utils/records.ts +++ b/src/utils/records.ts @@ -2,7 +2,7 @@ import type { DerivedPrivateJwk } from './hd-key.js'; import type { Filter } from '../types/query-types.js'; import type { GenericSignaturePayload } from '../types/message-types.js'; import type { Readable } from 'readable-stream'; -import type { RecordsDeleteMessage, RecordsFilter, RecordsQueryMessage, RecordsReadMessage, RecordsSubscribeMessage, RecordsWriteDescriptor, RecordsWriteMessage } from '../types/records-types.js'; +import type { RecordsDeleteMessage, RecordsFilter, RecordsQueryMessage, RecordsReadMessage, RecordsWriteDescriptor, RecordsWriteMessage } from '../types/records-types.js'; import { DateSort } from '../types/records-types.js'; import { Encoder } from './encoder.js'; @@ -289,7 +289,7 @@ export class Records { * Usage of this property is purely for performance optimization so we don't have to decode the signature payload again. */ public static validateDelegatedGrantReferentialIntegrity( - message: RecordsReadMessage | RecordsQueryMessage | RecordsWriteMessage | RecordsDeleteMessage | RecordsSubscribeMessage, + message: RecordsReadMessage | RecordsQueryMessage | RecordsWriteMessage | RecordsDeleteMessage, signaturePayload: GenericSignaturePayload | undefined ): void { // `deletedGrantId` in the payload of the message signature and `authorDelegatedGrant` in `authorization` must both exist or be both undefined diff --git a/tests/event-log/event-stream-emitter.spec.ts b/tests/event-log/event-stream-emitter.spec.ts index 85186e406..7308e0ba0 100644 --- a/tests/event-log/event-stream-emitter.spec.ts +++ b/tests/event-log/event-stream-emitter.spec.ts @@ -39,7 +39,7 @@ describe('EventStreamEmitter', () => { expect(emitter.listenerCount('events_bus')).to.equal(0); // initiate a subscription, which should add a listener - const { message } = await TestDataGenerator.generateRecordsSubscribe({ author: alice }); + const { message } = await TestDataGenerator.generateEventsSubscribe({ author: alice }); const sub = await eventStream.subscribe(alice.did, message, [], messageStore); expect(emitter.listenerCount('events_bus')).to.equal(1); @@ -54,7 +54,7 @@ describe('EventStreamEmitter', () => { eventStream = new EventStreamEmitter({ emitter }); // initiate a subscription - const { message } = await TestDataGenerator.generateRecordsSubscribe(); + const { message } = await TestDataGenerator.generateEventsSubscribe(); const sub = await eventStream.subscribe(alice.did, message, [], messageStore); const messageCid = await Message.getCid(message); diff --git a/tests/handlers/records-subscribe.spec.ts b/tests/handlers/records-subscribe.spec.ts deleted file mode 100644 index 0722b052a..000000000 --- a/tests/handlers/records-subscribe.spec.ts +++ /dev/null @@ -1,714 +0,0 @@ -import type { EventStream } from '../../src/types/subscriptions.js'; -import type { GenericMessage } from '../../src/types/message-types.js'; -import type { DataStore, EventLog, MessageStore, RecordsWriteMessage } from '../../src/index.js'; -import type { RecordsDeleteMessage, RecordsFilter } from '../../src/types/records-types.js'; - -import chaiAsPromised from 'chai-as-promised'; -import sinon from 'sinon'; -import chai, { expect } from 'chai'; - -import friendRoleProtocolDefinition from '../vectors/protocol-definitions/friend-role.json' assert { type: 'json' }; -import threadRoleProtocolDefinition from '../vectors/protocol-definitions/thread-role.json' assert { type: 'json' }; - -import { DidKeyResolver } from '../../src/did/did-key-resolver.js'; -import { Jws } from '../../src/utils/jws.js'; -import { Message } from '../../src/core/message.js'; -import { RecordsSubscribe } from '../../src/interfaces/records-subscribe.js'; -import { RecordsSubscribeHandler } from '../../src/handlers/records-subscribe.js'; -import { stubInterface } from 'ts-sinon'; -import { TestDataGenerator } from '../utils/test-data-generator.js'; -import { TestEventStream } from '../test-event-stream.js'; -import { TestStores } from '../test-stores.js'; -import { TestStubGenerator } from '../utils/test-stub-generator.js'; -import { DidResolver, Dwn, Time } from '../../src/index.js'; -import { DwnErrorCode, DwnInterfaceName, DwnMethodName } from '../../src/index.js'; - -chai.use(chaiAsPromised); - -export function testRecordsSubscribeHandler(): void { - describe('RecordsSubscribeHandler.handle()', () => { - describe('functional tests', () => { - let didResolver: DidResolver; - let messageStore: MessageStore; - let dataStore: DataStore; - let eventLog: EventLog; - let eventStream: EventStream; - let dwn: Dwn; - - // important to follow the `before` and `after` pattern to initialize and clean the stores in tests - // so that different test suites can reuse the same backend store for testing - before(async () => { - didResolver = new DidResolver([new DidKeyResolver()]); - - const stores = TestStores.get(); - messageStore = stores.messageStore; - dataStore = stores.dataStore; - eventLog = stores.eventLog; - - eventStream = TestEventStream.get(); - - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); - }); - - beforeEach(async () => { - sinon.restore(); // wipe all previous stubs/spies/mocks/fakes - - // clean up before each test rather than after so that a test does not depend on other tests to do the clean up - await messageStore.clear(); - await dataStore.clear(); - await eventLog.clear(); - }); - - after(async () => { - await dwn.close(); - }); - - it('should return a subscription object', async () => { - const alice = await DidKeyResolver.generate(); - - // subscribe for non-normalized protocol - const recordsSubscribe = await TestDataGenerator.generateRecordsSubscribe({ - author : alice, - filter : { schema: 'some-schema' }, - }); - - // Send records subscribe message - const reply = await dwn.processMessage(alice.did, recordsSubscribe.message); - expect(reply.status.code).to.equal(200); - expect(reply.subscription).to.exist; - }); - - it('should return 400 if protocol is not normalized', async () => { - const alice = await DidKeyResolver.generate(); - - // subscribe for non-normalized protocol - const recordsSubscribe = await TestDataGenerator.generateRecordsSubscribe({ - author : alice, - filter : { protocol: 'example.com/' }, - }); - - // overwrite protocol because #create auto-normalizes protocol - recordsSubscribe.message.descriptor.filter.protocol = 'example.com/'; - - // Re-create auth because we altered the descriptor after signing - recordsSubscribe.message.authorization = await Message.createAuthorization({ - descriptor : recordsSubscribe.message.descriptor, - signer : Jws.createSigner(alice) - }); - - // Send records subscribe message - const reply = await dwn.processMessage(alice.did, recordsSubscribe.message); - expect(reply.status.code).to.equal(400); - expect(reply.status.detail).to.contain(DwnErrorCode.UrlProtocolNotNormalized); - }); - - it('should return 400 if schema is not normalized', async () => { - const alice = await DidKeyResolver.generate(); - - // subscribe for non-normalized schema - const recordsSubscribe = await TestDataGenerator.generateRecordsSubscribe({ - author : alice, - filter : { schema: 'example.com/' }, - }); - - // overwrite schema because #create auto-normalizes schema - recordsSubscribe.message.descriptor.filter.schema = 'example.com/'; - - // Re-create auth because we altered the descriptor after signing - recordsSubscribe.message.authorization = await Message.createAuthorization({ - descriptor : recordsSubscribe.message.descriptor, - signer : Jws.createSigner(alice) - }); - - // Send records subscribe message - const reply = await dwn.processMessage(alice.did, recordsSubscribe.message); - expect(reply.status.code).to.equal(400); - expect(reply.status.detail).to.contain(DwnErrorCode.UrlSchemaNotNormalized); - }); - - it('should return 400 if published is set to false and a datePublished range is provided', async () => { - const fromDatePublished = Time.getCurrentTimestamp(); - const alice = await DidKeyResolver.generate(); - // set to true so create does not fail - const recordSubscribe = await TestDataGenerator.generateRecordsSubscribe({ - author : alice, - filter : { datePublished: { from: fromDatePublished }, published: true } - }); - - // set to false - recordSubscribe.message.descriptor.filter.published = false; - const subscribeResponse = await dwn.processMessage(alice.did, recordSubscribe.message); - expect(subscribeResponse.status.code).to.equal(400); - expect(subscribeResponse.status.detail).to.contain('descriptor/filter/published: must be equal to one of the allowed values'); - }); - - it('should return 401 for anonymous subscriptions that filter explicitly for unpublished records', async () => { - const alice = await DidKeyResolver.generate(); - - // create an unpublished record - const draftWrite = await TestDataGenerator.generateRecordsWrite({ author: alice, schema: 'post' }); - const draftWriteReply = await dwn.processMessage(alice.did, draftWrite.message, draftWrite.dataStream); - expect(draftWriteReply.status.code).to.equal(202); - - // validate that alice can subscribe - const unpublishedPostSubscribe = await TestDataGenerator.generateRecordsSubscribe({ author: alice, filter: { schema: 'post', published: false } }); - const unpublishedPostReply = await dwn.processMessage(alice.did, unpublishedPostSubscribe.message); - expect(unpublishedPostReply.status.code).to.equal(200); - expect(unpublishedPostReply.subscription).to.exist; - - // anonymous subscribe for unpublished records - const unpublishedAnonymous = await RecordsSubscribe.create({ filter: { schema: 'post', published: false } }); - const anonymousPostReply = await dwn.processMessage(alice.did, unpublishedAnonymous.message); - expect(anonymousPostReply.status.code).to.equal(401); - expect(anonymousPostReply.status.detail).contains('Missing JWS'); - expect(anonymousPostReply.subscription).to.not.exist; - }); - - it('should return 401 if signature check fails', async () => { - const { author, message } = await TestDataGenerator.generateRecordsSubscribe(); - const tenant = author!.did; - - // setting up a stub did resolver & message store - // intentionally not supplying the public key so a different public key is generated to simulate invalid signature - const mismatchingPersona = await TestDataGenerator.generatePersona({ did: author!.did, keyId: author!.keyId }); - const didResolver = TestStubGenerator.createDidResolverStub(mismatchingPersona); - const messageStore = stubInterface(); - const eventStream = stubInterface(); - - const recordsSubscribeHandler = new RecordsSubscribeHandler(didResolver, messageStore, eventStream); - const reply = await recordsSubscribeHandler.handle({ tenant, message }); - - expect(reply.status.code).to.equal(401); - }); - - it('should return 400 if fail parsing the message', async () => { - const { author, message } = await TestDataGenerator.generateRecordsSubscribe(); - const tenant = author!.did; - - // setting up a stub method resolver & message store - const didResolver = TestStubGenerator.createDidResolverStub(author!); - const messageStore = stubInterface(); - const eventStream = stubInterface(); - const recordsSubscribeHandler = new RecordsSubscribeHandler(didResolver, messageStore, eventStream); - - // stub the `parse()` function to throw an error - sinon.stub(RecordsSubscribe, 'parse').throws('anyError'); - const reply = await recordsSubscribeHandler.handle({ tenant, message }); - - expect(reply.status.code).to.equal(400); - }); - - describe('protocol based subscriptions', () => { - it('does not try protocol authorization if protocolRole is not invoked', async () => { - // scenario: Alice creates a thread and writes some chat messages. Alice addresses - // only one chat message to Bob. Bob subscribes by protocol URI without invoking a protocolRole, - // and he is able to receive the message addressed to him. - - const alice = await DidKeyResolver.generate(); - const bob = await DidKeyResolver.generate(); - - const protocolDefinition = threadRoleProtocolDefinition; - - const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({ - author: alice, - protocolDefinition - }); - const protocolsConfigureReply = await dwn.processMessage(alice.did, protocolsConfig.message); - expect(protocolsConfigureReply.status.code).to.equal(202); - - const bobSubscription = await TestDataGenerator.generateRecordsSubscribe({ - author : bob, - filter : { - published : false, - protocol : protocolDefinition.protocol, - } - }); - const subscriptionReply = await dwn.processMessage(alice.did, bobSubscription.message); - expect(subscriptionReply.status.code).to.equal(200); - expect(subscriptionReply.subscription).to.exist; - const messageCids: string[] = []; - const addCid = async (message: RecordsWriteMessage | RecordsDeleteMessage): Promise => { - const messageCid = await Message.getCid(message); - messageCids.push(messageCid); - }; - subscriptionReply.subscription?.on(addCid); - - // Alice writes a 'thread' record - const threadRecord = await TestDataGenerator.generateRecordsWrite({ - author : alice, - protocol : protocolDefinition.protocol, - protocolPath : 'thread', - }); - const threadRoleReply = await dwn.processMessage(alice.did, threadRecord.message, threadRecord.dataStream); - expect(threadRoleReply.status.code).to.equal(202); - - // Alice writes one 'chat' record addressed to Bob - const chatRecordForBob = await TestDataGenerator.generateRecordsWrite({ - author : alice, - recipient : bob.did, - protocol : protocolDefinition.protocol, - protocolPath : 'thread/chat', - published : false, - contextId : threadRecord.message.contextId, - parentId : threadRecord.message.recordId, - data : new TextEncoder().encode('Bob can read this cuz he is my friend'), - }); - const chatRecordForBobReply = await dwn.processMessage(alice.did, chatRecordForBob.message, chatRecordForBob.dataStream); - expect(chatRecordForBobReply.status.code).to.equal(202); - - // Alice writes two 'chat' records NOT addressed to Bob - for (let i = 0; i < 2; i++) { - const chatRecord = await TestDataGenerator.generateRecordsWrite({ - author : alice, - recipient : alice.did, - protocol : protocolDefinition.protocol, - protocolPath : 'thread/chat', - published : false, - contextId : threadRecord.message.contextId, - parentId : threadRecord.message.recordId, - data : new TextEncoder().encode('Bob cannot read this'), - }); - const chatReply = await dwn.processMessage(alice.did, chatRecord.message, chatRecord.dataStream); - expect(chatReply.status.code).to.equal(202); - } - - expect(messageCids.length).to.equal(1); - expect(messageCids[0]).to.equal(await Message.getCid(chatRecordForBob.message)); - }); - - it('allows $globalRole authorized subscriptions', async () => { - // scenario: Alice creates a thread and writes some chat messages writes a chat message. Bob invokes his - // thread member role in order to subscribe to the chat messages. - - const alice = await DidKeyResolver.generate(); - const bob = await DidKeyResolver.generate(); - - const protocolDefinition = friendRoleProtocolDefinition; - - const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({ - author: alice, - protocolDefinition - }); - const protocolsConfigureReply = await dwn.processMessage(alice.did, protocolsConfig.message); - expect(protocolsConfigureReply.status.code).to.equal(202); - - const filter: RecordsFilter = { - published : false, - protocol : protocolDefinition.protocol, - protocolPath : 'chat' - }; - - // subscribe without role, expect no messages - const noRoleSubscription = await TestDataGenerator.generateRecordsSubscribe({ - author: bob, - filter - }); - - const subscriptionReply = await dwn.processMessage(alice.did, noRoleSubscription.message); - expect(subscriptionReply.status.code).to.equal(200); - expect(subscriptionReply.subscription).to.exist; - const noRoleRecords: string[] = []; - const addNoRole = async (message: GenericMessage): Promise => { - if (message.descriptor.interface === DwnInterfaceName.Records && message.descriptor.method === DwnMethodName.Write) { - const recordsWriteMessage = message as RecordsWriteMessage; - noRoleRecords.push(recordsWriteMessage.recordId); - } - }; - subscriptionReply.subscription?.on(addNoRole); - - // Alice writes a 'friend' $globalRole record with Bob as recipient - const friendRoleRecord = await TestDataGenerator.generateRecordsWrite({ - author : alice, - recipient : bob.did, - protocol : protocolDefinition.protocol, - protocolPath : 'friend', - data : new TextEncoder().encode('Bob is my friend'), - }); - const friendRoleReply = await dwn.processMessage(alice.did, friendRoleRecord.message, friendRoleRecord.dataStream); - expect(friendRoleReply.status.code).to.equal(202); - - // subscribe with friend role - const bobSubscriptionWithRole = await TestDataGenerator.generateRecordsSubscribe({ - filter, - author : bob, - protocolRole : 'friend', - }); - - const subscriptionWithRoleReply = await dwn.processMessage(alice.did, bobSubscriptionWithRole.message); - expect(subscriptionWithRoleReply.status.code).to.equal(200); - expect(subscriptionWithRoleReply.subscription).to.exist; - const recordIds: string[] = []; - const addRecord = async (message: GenericMessage): Promise => { - if (message.descriptor.interface === DwnInterfaceName.Records && message.descriptor.method === DwnMethodName.Write) { - const recordsWriteMessage = message as RecordsWriteMessage; - recordIds.push(recordsWriteMessage.recordId); - } - }; - subscriptionWithRoleReply.subscription?.on(addRecord); - - // Alice writes three 'chat' records - const chatRecordIds = []; - for (let i = 0; i < 3; i++) { - const chatRecord = await TestDataGenerator.generateRecordsWrite({ - author : alice, - recipient : alice.did, - protocol : protocolDefinition.protocol, - protocolPath : 'chat', - published : false, - data : new TextEncoder().encode('Bob can read this cuz he is my friend'), - }); - const chatReply = await dwn.processMessage(alice.did, chatRecord.message, chatRecord.dataStream); - expect(chatReply.status.code).to.equal(202); - chatRecordIds.push(chatRecord.message.recordId); - } - - // there should not be any messages in the subscription without a friend role. - expect(noRoleRecords.length).to.equal(0); - - // should have all chat messages - expect(recordIds).to.have.members(chatRecordIds); - }); - - it('allows protocol authorized subscriptions', async () => { - // scenario: Alice writes some chat messages writes a chat message. - // Bob, having a thread/participant record, can subscribe to the chat. - - const alice = await DidKeyResolver.generate(); - const bob = await DidKeyResolver.generate(); - - const protocolDefinition = threadRoleProtocolDefinition; - - const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({ - author: alice, - protocolDefinition - }); - const protocolsConfigureReply = await dwn.processMessage(alice.did, protocolsConfig.message); - expect(protocolsConfigureReply.status.code).to.equal(202); - - - // Alice writes a 'thread' record - const threadRecord = await TestDataGenerator.generateRecordsWrite({ - author : alice, - protocol : protocolDefinition.protocol, - protocolPath : 'thread', - }); - const threadRoleReply = await dwn.processMessage(alice.did, threadRecord.message, threadRecord.dataStream); - expect(threadRoleReply.status.code).to.equal(202); - - const filter: RecordsFilter = { - protocol : protocolDefinition.protocol, - protocolPath : 'thread/chat', - contextId : threadRecord.message.contextId, - }; - - // subscribe without role, expect no messages - const noRoleSubscription = await TestDataGenerator.generateRecordsSubscribe({ - author: bob, - filter - }); - - const subscriptionReply = await dwn.processMessage(alice.did, noRoleSubscription.message); - expect(subscriptionReply.status.code).to.equal(200); - expect(subscriptionReply.subscription).to.exist; - const noRoleRecords: string[] = []; - const addNoRole = async (message: GenericMessage): Promise => { - if (message.descriptor.interface === DwnInterfaceName.Records && message.descriptor.method === DwnMethodName.Write) { - const recordsWriteMessage = message as RecordsWriteMessage; - noRoleRecords.push(recordsWriteMessage.recordId); - } - }; - subscriptionReply.subscription?.on(addNoRole); - - // Alice writes a 'participant' $contextRole record with Bob as recipient - const participantRoleRecord = await TestDataGenerator.generateRecordsWrite({ - author : alice, - recipient : bob.did, - protocol : protocolDefinition.protocol, - protocolPath : 'thread/participant', - contextId : threadRecord.message.contextId, - parentId : threadRecord.message.recordId, - data : new TextEncoder().encode('Bob is my friend'), - }); - const participantRoleReply = await dwn.processMessage(alice.did, participantRoleRecord.message, participantRoleRecord.dataStream); - expect(participantRoleReply.status.code).to.equal(202); - - // subscribe with the participant role - const bobSubscriptionWithRole = await TestDataGenerator.generateRecordsSubscribe({ - filter, - author : bob, - protocolRole : 'thread/participant', - }); - - const subscriptionWithRoleReply = await dwn.processMessage(alice.did, bobSubscriptionWithRole.message); - expect(subscriptionWithRoleReply.status.code).to.equal(200); - expect(subscriptionWithRoleReply.subscription).to.exist; - const recordIds: string[] = []; - const addRecord = async (message: GenericMessage): Promise => { - if (message.descriptor.interface === DwnInterfaceName.Records && message.descriptor.method === DwnMethodName.Write) { - const recordsWriteMessage = message as RecordsWriteMessage; - recordIds.push(recordsWriteMessage.recordId); - } - }; - subscriptionWithRoleReply.subscription?.on(addRecord); - - // Alice writes three 'chat' records - const chatRecordIds = []; - for (let i = 0; i < 3; i++) { - const chatRecord = await TestDataGenerator.generateRecordsWrite({ - author : alice, - recipient : alice.did, - protocol : protocolDefinition.protocol, - protocolPath : 'thread/chat', - published : false, - contextId : threadRecord.message.contextId, - parentId : threadRecord.message.recordId, - data : new TextEncoder().encode('Bob can read this cuz he is my friend'), - }); - const chatReply = await dwn.processMessage(alice.did, chatRecord.message, chatRecord.dataStream); - expect(chatReply.status.code).to.equal(202); - chatRecordIds.push(chatRecord.message.recordId); - } - - // there should not be any messages in the subscription without a participant role. - expect(noRoleRecords.length).to.equal(0); - - // should have all chat messages. - expect(recordIds).to.have.members(chatRecordIds); - }); - - it('does not execute protocol subscriptions where protocolPath is missing from the filter', async () => { - // scenario: Alice writes some chat messages. Bob invokes his $globalRole to subscribe those messages, - // but his subscription filter does not include protocolPath. - - const alice = await DidKeyResolver.generate(); - const bob = await DidKeyResolver.generate(); - - const protocolDefinition = friendRoleProtocolDefinition; - - const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({ - author: alice, - protocolDefinition - }); - const protocolsConfigureReply = await dwn.processMessage(alice.did, protocolsConfig.message); - expect(protocolsConfigureReply.status.code).to.equal(202); - - // Alice writes a 'friend' $globalRole record with Bob as recipient - const friendRoleRecord = await TestDataGenerator.generateRecordsWrite({ - author : alice, - recipient : bob.did, - protocol : protocolDefinition.protocol, - protocolPath : 'friend', - data : new TextEncoder().encode('Bob is my friend'), - }); - const friendRoleReply = await dwn.processMessage(alice.did, friendRoleRecord.message, friendRoleRecord.dataStream); - expect(friendRoleReply.status.code).to.equal(202); - - // Alice writes three 'chat' records - const chatRecordIds = []; - for (let i = 0; i < 3; i++) { - const chatRecord = await TestDataGenerator.generateRecordsWrite({ - author : alice, - recipient : alice.did, - protocol : protocolDefinition.protocol, - protocolPath : 'chat', - published : false, - data : new TextEncoder().encode('Bob can read this cuz he is my friend'), - }); - const chatReply = await dwn.processMessage(alice.did, chatRecord.message, chatRecord.dataStream); - expect(chatReply.status.code).to.equal(202); - chatRecordIds.push(chatRecord.message.recordId); - } - - // Bob invokes his friendRole to subscribe but does not have `protocolPath` in the filter - const chatSubscribe = await TestDataGenerator.generateRecordsSubscribe({ - author : bob, - filter : { - protocol: protocolDefinition.protocol, - // protocolPath deliberately omitted - }, - protocolRole: 'friend', - }); - const chatSubscribeReply = await dwn.processMessage(alice.did, chatSubscribe.message); - expect(chatSubscribeReply.status.code).to.equal(400); - expect(chatSubscribeReply.status.detail).to.contain(DwnErrorCode.RecordsSubscribeFilterMissingRequiredProperties); - expect(chatSubscribeReply.subscription).to.not.exist; - }); - - it('does not execute $contextRole authorized subscriptions where contextId is missing from the filter', async () => { - // scenario: Alice writes some chat messages and gives Bob a role allowing him to access them. But Bob's filter - // does not contain a contextId so the subscription fails. - const alice = await DidKeyResolver.generate(); - const bob = await DidKeyResolver.generate(); - - const protocolDefinition = threadRoleProtocolDefinition; - - const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({ - author: alice, - protocolDefinition - }); - const protocolsConfigureReply = await dwn.processMessage(alice.did, protocolsConfig.message); - expect(protocolsConfigureReply.status.code).to.equal(202); - - // Alice writes a 'thread' record - const threadRecord = await TestDataGenerator.generateRecordsWrite({ - author : alice, - protocol : protocolDefinition.protocol, - protocolPath : 'thread', - }); - const threadRoleReply = await dwn.processMessage(alice.did, threadRecord.message, threadRecord.dataStream); - expect(threadRoleReply.status.code).to.equal(202); - - // Alice writes a 'friend' $globalRole record with Bob as recipient - const participantRoleRecord = await TestDataGenerator.generateRecordsWrite({ - author : alice, - recipient : bob.did, - protocol : protocolDefinition.protocol, - protocolPath : 'thread/participant', - contextId : threadRecord.message.contextId, - parentId : threadRecord.message.recordId, - data : new TextEncoder().encode('Bob is my friend'), - }); - const participantRoleReply = await dwn.processMessage(alice.did, participantRoleRecord.message, participantRoleRecord.dataStream); - expect(participantRoleReply.status.code).to.equal(202); - - // Alice writes three 'chat' records - const chatRecordIds = []; - for (let i = 0; i < 3; i++) { - const chatRecord = await TestDataGenerator.generateRecordsWrite({ - author : alice, - recipient : alice.did, - protocol : protocolDefinition.protocol, - protocolPath : 'thread/chat', - published : false, - contextId : threadRecord.message.contextId, - parentId : threadRecord.message.recordId, - data : new TextEncoder().encode('Bob can read this cuz he is my friend'), - }); - const chatReply = await dwn.processMessage(alice.did, chatRecord.message, chatRecord.dataStream); - expect(chatReply.status.code).to.equal(202); - chatRecordIds.push(chatRecord.message.recordId); - } - - // Bob invokes his thread participant role to subscribe - const chatSubscribe = await TestDataGenerator.generateRecordsSubscribe({ - author : bob, - filter : { - protocol : protocolDefinition.protocol, - protocolPath : 'thread/chat', - // contextId deliberately omitted - }, - protocolRole: 'thread/participant', - }); - const chatSubscribeReply = await dwn.processMessage(alice.did, chatSubscribe.message); - expect(chatSubscribeReply.status.code).to.eq(401); - expect(chatSubscribeReply.status.detail).to.contain(DwnErrorCode.ProtocolAuthorizationMissingContextId); - expect(chatSubscribeReply.subscription).to.not.exist; - }); - - it('rejects $globalRole authorized subscriptions if the request author does not have a matching $globalRole', async () => { - // scenario: Alice creates a thread and writes some chat messages writes a chat message. Bob invokes a - // $globalRole but fails because he does not actually have a role. - - const alice = await DidKeyResolver.generate(); - const bob = await DidKeyResolver.generate(); - - const protocolDefinition = friendRoleProtocolDefinition; - - const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({ - author: alice, - protocolDefinition - }); - const protocolsConfigureReply = await dwn.processMessage(alice.did, protocolsConfig.message); - expect(protocolsConfigureReply.status.code).to.equal(202); - - // Alice writes three 'chat' records - const chatRecordIds = []; - for (let i = 0; i < 3; i++) { - const chatRecord = await TestDataGenerator.generateRecordsWrite({ - author : alice, - recipient : alice.did, - protocol : protocolDefinition.protocol, - protocolPath : 'chat', - published : false, - data : new TextEncoder().encode('Bob can read this cuz he is my friend'), - }); - const chatReply = await dwn.processMessage(alice.did, chatRecord.message, chatRecord.dataStream); - expect(chatReply.status.code).to.equal(202); - chatRecordIds.push(chatRecord.message.recordId); - } - - // Bob invokes his friendRole to subscribe to the records - const chatSubscribe = await TestDataGenerator.generateRecordsSubscribe({ - author : bob, - filter : { - protocol : protocolDefinition.protocol, - protocolPath : 'chat', - }, - protocolRole: 'friend', - }); - const chatSubscribeReply = await dwn.processMessage(alice.did, chatSubscribe.message); - expect(chatSubscribeReply.status.code).to.eq(401); - expect(chatSubscribeReply.status.detail).to.contain(DwnErrorCode.ProtocolAuthorizationMissingRole); - expect(chatSubscribeReply.subscription).to.not.exist; - }); - - it('rejects protocol authorized subscriptions where the subscription author does not have a matching $contextRole', async () => { - - const alice = await DidKeyResolver.generate(); - const bob = await DidKeyResolver.generate(); - - const protocolDefinition = threadRoleProtocolDefinition; - - const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({ - author: alice, - protocolDefinition - }); - const protocolsConfigureReply = await dwn.processMessage(alice.did, protocolsConfig.message); - expect(protocolsConfigureReply.status.code).to.equal(202); - - // Alice writes a 'thread' record - const threadRecord = await TestDataGenerator.generateRecordsWrite({ - author : alice, - protocol : protocolDefinition.protocol, - protocolPath : 'thread', - }); - const threadRoleReply = await dwn.processMessage(alice.did, threadRecord.message, threadRecord.dataStream); - expect(threadRoleReply.status.code).to.equal(202); - - // Alice writes three 'chat' records - const chatRecordIds = []; - for (let i = 0; i < 3; i++) { - const chatRecord = await TestDataGenerator.generateRecordsWrite({ - author : alice, - recipient : alice.did, - protocol : protocolDefinition.protocol, - protocolPath : 'thread/chat', - published : false, - contextId : threadRecord.message.contextId, - parentId : threadRecord.message.recordId, - data : new TextEncoder().encode('Bob can read this cuz he is my friend'), - }); - const chatReply = await dwn.processMessage(alice.did, chatRecord.message, chatRecord.dataStream); - expect(chatReply.status.code).to.equal(202); - chatRecordIds.push(chatRecord.message.recordId); - } - - // Bob invokes his friendRole to subscribe to the records - const chatSubscribe = await TestDataGenerator.generateRecordsSubscribe({ - author : bob, - filter : { - protocol : protocolDefinition.protocol, - protocolPath : 'thread/chat', - contextId : threadRecord.message.contextId, - }, - protocolRole: 'thread/participant', - }); - const chatSubscribeReply = await dwn.processMessage(alice.did, chatSubscribe.message); - expect(chatSubscribeReply.status.code).to.eq(401); - expect(chatSubscribeReply.status.detail).to.contain(DwnErrorCode.ProtocolAuthorizationMissingRole); - expect(chatSubscribeReply.subscription).to.not.exist; - }); - }); - }); - }); -} \ No newline at end of file diff --git a/tests/interfaces/records-subscribe.spec.ts b/tests/interfaces/records-subscribe.spec.ts deleted file mode 100644 index 99e1ebebe..000000000 --- a/tests/interfaces/records-subscribe.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import chaiAsPromised from 'chai-as-promised'; -import chai, { expect } from 'chai'; - -import dexProtocolDefinition from '../vectors/protocol-definitions/dex.json' assert { type: 'json' }; -import { Jws } from '../../src/index.js'; -import { RecordsQuery } from '../../src/interfaces/records-query.js'; -import { TestDataGenerator } from '../utils/test-data-generator.js'; -import { Time } from '../../src/utils/time.js'; - -chai.use(chaiAsPromised); - -describe('RecordsSubscribe', () => { - describe('create()', () => { - it('should not allow published to be set to false with a datePublished filter also set', async () => { - // test control - const randomDate = TestDataGenerator.randomTimestamp(); - const recordQueryControl = TestDataGenerator.generateRecordsQuery({ - filter: { datePublished: { from: randomDate, }, published: true } - }); - - await expect(recordQueryControl).to.eventually.not.be.rejected; - - const recordQueryRejected = TestDataGenerator.generateRecordsQuery({ - filter: { datePublished: { from: randomDate }, published: false } - }); - await expect(recordQueryRejected).to.eventually.be.rejectedWith('descriptor/filter/published: must be equal to one of the allowed values'); - }); - - it('should use `messageTimestamp` as is if given', async () => { - const alice = await TestDataGenerator.generatePersona(); - - const currentTime = Time.getCurrentTimestamp(); - const recordsQuery = await RecordsQuery.create({ - filter : { schema: 'anything' }, - messageTimestamp : currentTime, - signer : Jws.createSigner(alice), - }); - - expect(recordsQuery.message.descriptor.messageTimestamp).to.equal(currentTime); - }); - - it('should auto-normalize protocol URL', async () => { - const alice = await TestDataGenerator.generatePersona(); - - const options = { - recipient : alice.did, - data : TestDataGenerator.randomBytes(10), - dataFormat : 'application/json', - signer : Jws.createSigner(alice), - filter : { protocol: 'example.com/' }, - definition : dexProtocolDefinition - }; - const recordsQuery = await RecordsQuery.create(options); - - const message = recordsQuery.message; - - expect(message.descriptor.filter!.protocol).to.eq('http://example.com'); - }); - - it('should auto-normalize schema URL', async () => { - const alice = await TestDataGenerator.generatePersona(); - - const options = { - recipient : alice.did, - data : TestDataGenerator.randomBytes(10), - dataFormat : 'application/json', - signer : Jws.createSigner(alice), - filter : { schema: 'example.com/' }, - definition : dexProtocolDefinition - }; - const recordsQuery = await RecordsQuery.create(options); - - const message = recordsQuery.message; - - expect(message.descriptor.filter!.schema).to.eq('http://example.com'); - }); - }); -}); - diff --git a/tests/scenarios/delegated-grant.spec.ts b/tests/scenarios/delegated-grant.spec.ts index 50fa3ee44..74e533db7 100644 --- a/tests/scenarios/delegated-grant.spec.ts +++ b/tests/scenarios/delegated-grant.spec.ts @@ -1,5 +1,5 @@ import type { EventStream } from '../../src/types/subscriptions.js'; -import type { DataStore, EventLog, MessageStore, PermissionScope, RecordsDeleteMessage, RecordsWriteMessage } from '../../src/index.js'; +import type { DataStore, EventLog, MessageStore, PermissionScope } from '../../src/index.js'; import chaiAsPromised from 'chai-as-promised'; import emailProtocolDefinition from '../vectors/protocol-definitions/email.json' assert { type: 'json' }; @@ -21,7 +21,7 @@ import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { Time } from '../../src/utils/time.js'; -import { DwnInterfaceName, DwnMethodName, PermissionsGrant, RecordsDelete, RecordsQuery, RecordsRead, RecordsSubscribe } from '../../src/index.js'; +import { DwnInterfaceName, DwnMethodName, PermissionsGrant, RecordsDelete, RecordsQuery, RecordsRead } from '../../src/index.js'; chai.use(chaiAsPromised); @@ -184,7 +184,7 @@ export function testDelegatedGrantScenarios(): void { expect(carolWriteReply.status.detail).to.contain(DwnErrorCode.RecordsValidateIntegrityGrantedToAndSignerMismatch); }); - it('should only allow correct entity invoking a delegated grant to read, query or subscribe', async () => { + it('should only allow correct entity invoking a delegated grant to read and query ', async () => { // scenario: // 1. Alice creates read and query delegated grants for device X, // 2. Bob starts a chat thread with Alice on his DWN @@ -227,62 +227,6 @@ export function testDelegatedGrantScenarios(): void { const participantRoleReply = await dwn.processMessage(bob.did, participantRoleRecord.message, { dataStream: participantRoleRecord.dataStream }); expect(participantRoleReply.status.code).to.equal(202); - // Alice creates a delegated subscribe grant for device X to act as Alice. - const subscribeGrantForDeviceX = await PermissionsGrant.create({ - delegated : true, // this is a delegated grant - dateExpires : Time.createOffsetTimestamp({ seconds: 100 }), - grantedBy : alice.did, - grantedTo : deviceX.did, - grantedFor : alice.did, - scope : { - interface : DwnInterfaceName.Records, - method : DwnMethodName.Subscribe, - protocol - }, - signer: Jws.createSigner(alice) - }); - - // verify device X is able to subscribe the chat message from Bob's DWN - const recordsSubscribeByDeviceX = await RecordsSubscribe.create({ - signer : Jws.createSigner(deviceX), - delegatedGrant : subscribeGrantForDeviceX.asDelegatedGrant(), - protocolRole : 'thread/participant', - filter : { - contextId : threadRecord.message.contextId, - protocol : protocolDefinition.protocol, - protocolPath : 'thread/chat' - } - }); - const recordsSubscribeByDeviceXReply = await dwn.processMessage(bob.did, recordsSubscribeByDeviceX.message); - expect(recordsSubscribeByDeviceXReply.status.code).to.equal(200, 'subscribe'); - - const subscriptionChatRecords:Set = new Set(); - const captureChatRecords = async (message: RecordsWriteMessage | RecordsDeleteMessage): Promise => { - if (message.descriptor.method === DwnMethodName.Delete) { - const recordId = message.descriptor.recordId; - subscriptionChatRecords.delete(recordId); - } else { - const recordId = (message as RecordsWriteMessage).recordId; - subscriptionChatRecords.add(recordId); - } - }; - const deviceXSub = recordsSubscribeByDeviceXReply.subscription!.on(captureChatRecords); - - // Verify that Carol cannot subscribe as Alice by invoking the delegated grant granted to Device X - const recordsSubscribeByCarol = await RecordsSubscribe.create({ - signer : Jws.createSigner(carol), - delegatedGrant : subscribeGrantForDeviceX.asDelegatedGrant(), - protocolRole : 'thread/participant', - filter : { - contextId : threadRecord.message.contextId, - protocol : protocolDefinition.protocol, - protocolPath : 'thread/chat' - } - }); - const recordsSubscribeByCarolReply = await dwn.processMessage(bob.did, recordsSubscribeByCarol.message); - expect(recordsSubscribeByCarolReply.status.code).to.equal(400, 'carol subscribe'); - expect(recordsSubscribeByCarolReply.status.detail).to.contain(DwnErrorCode.RecordsValidateIntegrityGrantedToAndSignerMismatch); - // Bob writes a chat message in the thread const chatRecord = await TestDataGenerator.generateRecordsWrite({ author : bob, @@ -402,10 +346,6 @@ export function testDelegatedGrantScenarios(): void { const recordsReadByCarolReply = await dwn.processMessage(bob.did, recordsReadByCarol.message); expect(recordsReadByCarolReply.status.code).to.equal(400); expect(recordsQueryByCarolReply.status.detail).to.contain(DwnErrorCode.RecordsValidateIntegrityGrantedToAndSignerMismatch); - - deviceXSub.off(); - expect(subscriptionChatRecords.size).to.equal(1); - expect([...subscriptionChatRecords]).to.have.members([chatRecord.message.recordId]); }); it('should only allow correct entity invoking a delegated grant to delete', async () => { diff --git a/tests/scenarios/subscriptions.spec.ts b/tests/scenarios/subscriptions.spec.ts index c74032b45..df8383b9e 100644 --- a/tests/scenarios/subscriptions.spec.ts +++ b/tests/scenarios/subscriptions.spec.ts @@ -1,26 +1,20 @@ import type { DataStore, - DwnError, EventLog, EventStream, GenericMessage, MessageStore, - RecordsDeleteMessage, - RecordsWriteMessage, } from '../../src/index.js'; import freeForAll from '../vectors/protocol-definitions/free-for-all.json' assert { type: 'json' }; -import friendRole from '../vectors/protocol-definitions/friend-role.json' assert { type: 'json' }; -import { RecordsSubscriptionHandler } from '../../src/handlers/records-subscribe.js'; import { TestDataGenerator } from '../utils/test-data-generator.js'; import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { Time } from '../../src/utils/time.js'; -import { DidKeyResolver, DidResolver, Dwn, DwnErrorCode, EventStreamEmitter, Message } from '../../src/index.js'; +import { DidKeyResolver, DidResolver, Dwn, Message } from '../../src/index.js'; import { expect } from 'chai'; -import sinon from 'sinon'; export function testSubscriptionScenarios(): void { describe('subscriptions', () => { @@ -57,150 +51,6 @@ export function testSubscriptionScenarios(): void { await dwn.close(); }); - describe('records subscribe', () => { - it('filters by protocol', async () => { - const alice = await DidKeyResolver.generate(); - - // create a proto1 - const protoConf1 = await TestDataGenerator.generateProtocolsConfigure({ - author : alice, - protocolDefinition : { ...freeForAll, protocol: 'proto1' } - }); - - const postProperties = { - protocolPath : 'post', - schema : freeForAll.types.post.schema, - dataFormat : freeForAll.types.post.dataFormats[0], - }; - - // create a proto1 - const proto1 = protoConf1.message.descriptor.definition.protocol; - const protoConf1Response = await dwn.processMessage(alice.did, protoConf1.message); - expect(protoConf1Response.status.code).equals(202); - - // create a proto2 - const protoConf2 = await TestDataGenerator.generateProtocolsConfigure({ - author : alice, - protocolDefinition : { ...freeForAll, protocol: 'proto2' } - }); - const proto2 = protoConf2.message.descriptor.definition.protocol; - const protoConf2Response = await dwn.processMessage(alice.did, protoConf2.message); - expect(protoConf2Response.status.code).equals(202); - - // we will add messageCids to these arrays as they are received by their handler to check against later - const proto1Messages:string[] = []; - const proto2Messages:string[] = []; - - // subscribe to proto1 messages - const proto1Subscription = await TestDataGenerator.generateRecordsSubscribe({ author: alice, filter: { protocol: proto1 } }); - const proto1SubscriptionReply = await dwn.processMessage(alice.did, proto1Subscription.message); - expect(proto1SubscriptionReply.status.code).to.equal(200); - expect(proto1SubscriptionReply.subscription?.id).to.equal(await Message.getCid(proto1Subscription.message)); - - // we add a handler to the subscription and add the messageCid to the appropriate array - const proto1Handler = async (message:GenericMessage):Promise => { - const messageCid = await Message.getCid(message); - proto1Messages.push(messageCid); - }; - const proto1Sub = proto1SubscriptionReply.subscription!.on(proto1Handler); - - // subscribe to proto2 messages - const proto2Subscription = await TestDataGenerator.generateRecordsSubscribe({ author: alice, filter: { protocol: proto2 } }); - const proto2SubscriptionReply = await dwn.processMessage(alice.did, proto2Subscription.message); - expect(proto2SubscriptionReply.status.code).to.equal(200); - expect(proto2SubscriptionReply.subscription?.id).to.equal(await Message.getCid(proto2Subscription.message)); - // we add a handler to the subscription and add the messageCid to the appropriate array - const proto2Handler = async (message:GenericMessage):Promise => { - const messageCid = await Message.getCid(message); - proto2Messages.push(messageCid); - }; - proto2SubscriptionReply.subscription!.on(proto2Handler); - - // create some random record, will not show up in records subscription - const write1Random = await TestDataGenerator.generateRecordsWrite({ author: alice }); - const write1RandomResponse = await dwn.processMessage(alice.did, write1Random.message, write1Random.dataStream); - expect(write1RandomResponse.status.code).to.equal(202); - - // create a record for proto1 - const write1proto1 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto1, ...postProperties }); - const write1Response = await dwn.processMessage(alice.did, write1proto1.message, write1proto1.dataStream); - expect(write1Response.status.code).equals(202); - - // create a record for proto2 - const write1proto2 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto2, ...postProperties }); - const write1Proto2Response = await dwn.processMessage(alice.did, write1proto2.message, write1proto2.dataStream); - expect(write1Proto2Response.status.code).equals(202); - - expect(proto1Messages.length).to.equal(1, 'proto1'); - expect(proto1Messages).to.include(await Message.getCid(write1proto1.message)); - expect(proto2Messages.length).to.equal(1, 'proto2'); - expect(proto2Messages).to.include(await Message.getCid(write1proto2.message)); - - // remove listener for proto1 - proto1Sub.off(); - - // create another record for proto1 - const write2proto1 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto1, ...postProperties }); - const write2Response = await dwn.processMessage(alice.did, write2proto1.message, write2proto1.dataStream); - expect(write2Response.status.code).equals(202); - - // create another record for proto2 - const write2proto2 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto2, ...postProperties }); - const write2Proto2Response = await dwn.processMessage(alice.did, write2proto2.message, write2proto2.dataStream); - expect(write2Proto2Response.status.code).equals(202); - - // proto1 messages from handler do not change. - expect(proto1Messages.length).to.equal(1, 'proto1 after subscription.off()'); - expect(proto1Messages).to.include(await Message.getCid(write1proto1.message)); - - //proto2 messages from handler have the new message. - expect(proto2Messages.length).to.equal(2, 'proto2 after subscription.off()'); - expect(proto2Messages).to.have.members([await Message.getCid(write1proto2.message), await Message.getCid(write2proto2.message)]); - }); - - it('unsubscribes', async () => { - const alice = await DidKeyResolver.generate(); - - // subscribe to schema1 - const schema1Subscription = await TestDataGenerator.generateRecordsSubscribe({ author: alice, filter: { schema: 'schema1' } }); - const schema1SubscriptionReply = await dwn.processMessage(alice.did, schema1Subscription.message); - expect(schema1SubscriptionReply.status.code).to.equal(200); - - // messageCids of schema1 - const schema1Messages:string[] = []; - - const schema1Handler = async (message: GenericMessage): Promise => { - const messageCid = await Message.getCid(message); - schema1Messages.push(messageCid); - }; - schema1SubscriptionReply.subscription!.on(schema1Handler); - expect(schema1Messages.length).to.equal(0); // no messages exist; - - const record1 = await TestDataGenerator.generateRecordsWrite({ author: alice, schema: 'schema1' }); - const record1Reply = await dwn.processMessage(alice.did, record1.message, record1.dataStream); - expect(record1Reply.status.code).to.equal(202); - const record1MessageCid = await Message.getCid(record1.message); - - expect(schema1Messages.length).to.equal(1); // message exists - expect(schema1Messages).to.eql([ record1MessageCid ]); - - // unsubscribe, this should be used as clean up. - await schema1SubscriptionReply.subscription!.close(); - - // write another message. - const record2 = await TestDataGenerator.generateRecordsWrite({ author: alice, schema: 'schema1' }); - const record2Reply = await dwn.processMessage(alice.did, record2.message, record2.dataStream); - expect(record2Reply.status.code).to.equal(202); - - // sleep to make sure events have some time to emit. - await Time.minimalSleep(); - - expect(schema1Messages.length).to.equal(1); // same as before - expect(schema1Messages).to.eql([ record1MessageCid ]); - - }); - }); - describe('events subscribe', () => { it('all events', async () => { const alice = await DidKeyResolver.generate(); @@ -399,392 +249,5 @@ export function testSubscriptionScenarios(): void { }); }); - describe('reauthorization', () => { - let didResolver: DidResolver; - let messageStore: MessageStore; - let dataStore: DataStore; - let eventLog: EventLog; - let eventStream: EventStream; - let dwn: Dwn; - - before(async () => { - didResolver = new DidResolver([new DidKeyResolver()]); - - const stores = TestStores.get(); - messageStore = stores.messageStore; - dataStore = stores.dataStore; - eventLog = stores.eventLog; - - eventStream = TestEventStream.get(); - - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); - }); - - after(async () => { - sinon.restore(); - await dwn.close(); - }); - - beforeEach(async () => { - // clean up before each test rather than after so that a test does not depend on other tests to do the clean up - await messageStore.clear(); - await dataStore.clear(); - await eventLog.clear(); - }); - - it('does not reauthorize if TTL is set to zero', async () => { - const eventStream = new EventStreamEmitter({ reauthorizationTTL: 0 }); - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); - - const authorizeSpy = sinon.spy(RecordsSubscriptionHandler.prototype as any, 'reauthorize'); - - const alice = await DidKeyResolver.generate(); - const bob = await DidKeyResolver.generate(); - - // alice writes the friend role protocol - const protocolConf = await TestDataGenerator.generateProtocolsConfigure({ - author : alice, - published : true, - protocolDefinition : { ...friendRole } - }); - const proto = protocolConf.message.descriptor.definition.protocol; - const protoConfResponse = await dwn.processMessage(alice.did, protocolConf.message); - expect(protoConfResponse.status.code).equals(202); - - // alice adds bob as a friend. - const bobFriend = await TestDataGenerator.generateRecordsWrite({ - author : alice, - recipient : bob.did, - protocol : proto, - protocolPath : 'friend', - }); - const bobFriendReply = await dwn.processMessage(alice.did, bobFriend.message, bobFriend.dataStream); - expect(bobFriendReply.status.code).to.equal(202); - - // bob subscribes - const bobSubscribe = await TestDataGenerator.generateRecordsSubscribe({ - author : bob, - filter : { - protocol : proto, - protocolPath : 'chat', - }, - protocolRole: 'friend' - }); - const bobSubscribeReply = await dwn.processMessage(alice.did, bobSubscribe.message); - expect(bobSubscribeReply.status.code).to.equal(200); - - // capture the messageCids from the subscription - const messageCids: string[] = []; - const captureFunction = async (message: RecordsWriteMessage | RecordsDeleteMessage):Promise => { - const messageCid = await Message.getCid(message); - messageCids.push(messageCid); - }; - bobSubscribeReply.subscription!.on(captureFunction); - - //write some chat messages - const aliceMessage1 = await TestDataGenerator.generateRecordsWrite({ - author : alice, - protocol : proto, - protocolPath : 'chat' - }); - const aliceMessage1Cid = await Message.getCid(aliceMessage1.message); - const aliceMessage1Reply = await dwn.processMessage(alice.did, aliceMessage1.message, aliceMessage1.dataStream); - expect(aliceMessage1Reply.status.code).to.equal(202); - - const aliceMessage2 = await TestDataGenerator.generateRecordsWrite({ - author : alice, - protocol : proto, - protocolPath : 'chat' - }); - const aliceMessage2Cid = await Message.getCid(aliceMessage2.message); - const aliceMessage2Reply = await dwn.processMessage(alice.did, aliceMessage2.message, aliceMessage2.dataStream); - expect(aliceMessage2Reply.status.code).to.equal(202); - authorizeSpy.restore(); - - while (messageCids.length < 2) { - await Time.minimalSleep(); - } - - expect(authorizeSpy.callCount).to.equal(0, 'reauthorize'); // authorize is never called - expect(messageCids.length).to.equal(2, 'messageCids'); - expect(messageCids).to.have.members([ aliceMessage1Cid, aliceMessage2Cid ]); - }); - - it('reauthorize on every event emitted if TTL is less than zero', async () => { - const eventStream = new EventStreamEmitter({ reauthorizationTTL: -1 }); - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); - - const authorizeSpy = sinon.spy(RecordsSubscriptionHandler.prototype as any, 'reauthorize'); - - const alice = await DidKeyResolver.generate(); - const bob = await DidKeyResolver.generate(); - - // alice writes the friend role protocol - const protocolConf = await TestDataGenerator.generateProtocolsConfigure({ - author : alice, - published : true, - protocolDefinition : { ...friendRole } - }); - const proto = protocolConf.message.descriptor.definition.protocol; - const protoConfResponse = await dwn.processMessage(alice.did, protocolConf.message); - expect(protoConfResponse.status.code).equals(202); - - // alice adds bob as a friend. - const bobFriend = await TestDataGenerator.generateRecordsWrite({ - author : alice, - recipient : bob.did, - protocol : proto, - protocolPath : 'friend', - }); - const bobFriendReply = await dwn.processMessage(alice.did, bobFriend.message, bobFriend.dataStream); - expect(bobFriendReply.status.code).to.equal(202); - - // bob subscribes - const bobSubscribe = await TestDataGenerator.generateRecordsSubscribe({ - author : bob, - filter : { - protocol : proto, - protocolPath : 'chat', - }, - protocolRole: 'friend' - }); - const bobSubscribeReply = await dwn.processMessage(alice.did, bobSubscribe.message); - expect(bobSubscribeReply.status.code).to.equal(200); - - // capture the messageCids from the subscription - const messageCids: string[] = []; - const captureFunction = async (message: RecordsWriteMessage | RecordsDeleteMessage):Promise => { - const messageCid = await Message.getCid(message); - messageCids.push(messageCid); - }; - bobSubscribeReply.subscription!.on(captureFunction); - - //write some chat messages - const aliceMessage1 = await TestDataGenerator.generateRecordsWrite({ - author : alice, - protocol : proto, - protocolPath : 'chat' - }); - const aliceMessage1Cid = await Message.getCid(aliceMessage1.message); - const aliceMessage1Reply = await dwn.processMessage(alice.did, aliceMessage1.message, aliceMessage1.dataStream); - expect(aliceMessage1Reply.status.code).to.equal(202); - - const aliceMessage2 = await TestDataGenerator.generateRecordsWrite({ - author : alice, - protocol : proto, - protocolPath : 'chat' - }); - const aliceMessage2Cid = await Message.getCid(aliceMessage2.message); - const aliceMessage2Reply = await dwn.processMessage(alice.did, aliceMessage2.message, aliceMessage2.dataStream); - expect(aliceMessage2Reply.status.code).to.equal(202); - authorizeSpy.restore(); - - while (messageCids.length < 2) { - await Time.minimalSleep(); - } - - expect(authorizeSpy.callCount).to.equal(2, 'reauthorize'); // authorize on each message - expect(messageCids.length).to.equal(2, 'messageCids'); - expect(messageCids).to.have.members([ aliceMessage1Cid, aliceMessage2Cid ]); - }); - - it('reauthorizes after the ttl', async () => { - const clock = sinon.useFakeTimers(); - - const eventStream = new EventStreamEmitter({ reauthorizationTTL: 1 }); // every second - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); - - const authorizeSpy = sinon.spy(RecordsSubscriptionHandler.prototype as any, 'reauthorize'); - - const alice = await DidKeyResolver.generate(); - const bob = await DidKeyResolver.generate(); - - // alice writes the friend role protocol - const protocolConf = await TestDataGenerator.generateProtocolsConfigure({ - author : alice, - published : true, - protocolDefinition : { ...friendRole } - }); - const proto = protocolConf.message.descriptor.definition.protocol; - const protoConfResponse = await dwn.processMessage(alice.did, protocolConf.message); - expect(protoConfResponse.status.code).equals(202); - - // alice adds bob as a friend. - const bobFriend = await TestDataGenerator.generateRecordsWrite({ - author : alice, - recipient : bob.did, - protocol : proto, - protocolPath : 'friend', - }); - const bobFriendReply = await dwn.processMessage(alice.did, bobFriend.message, bobFriend.dataStream); - expect(bobFriendReply.status.code).to.equal(202); - - // bob subscribes - const bobSubscribe = await TestDataGenerator.generateRecordsSubscribe({ - author : bob, - filter : { - protocol : proto, - protocolPath : 'chat', - }, - protocolRole: 'friend' - }); - const bobSubscribeReply = await dwn.processMessage(alice.did, bobSubscribe.message); - expect(bobSubscribeReply.status.code).to.equal(200); - - // capture the messageCids from the subscription - const messageCids: string[] = []; - const captureFunction = async (message: RecordsWriteMessage | RecordsDeleteMessage):Promise => { - const messageCid = await Message.getCid(message); - messageCids.push(messageCid); - }; - bobSubscribeReply.subscription!.on(captureFunction); - - //write some chat messages - const aliceMessage1 = await TestDataGenerator.generateRecordsWrite({ - author : alice, - protocol : proto, - protocolPath : 'chat' - }); - const aliceMessage1Cid = await Message.getCid(aliceMessage1.message); - const aliceMessage1Reply = await dwn.processMessage(alice.did, aliceMessage1.message, aliceMessage1.dataStream); - expect(aliceMessage1Reply.status.code).to.equal(202); - - const aliceMessage2 = await TestDataGenerator.generateRecordsWrite({ - author : alice, - protocol : proto, - protocolPath : 'chat' - }); - const aliceMessage2Cid = await Message.getCid(aliceMessage2.message); - const aliceMessage2Reply = await dwn.processMessage(alice.did, aliceMessage2.message, aliceMessage2.dataStream); - expect(aliceMessage2Reply.status.code).to.equal(202); - - await clock.nextAsync(); - - expect(authorizeSpy.callCount).to.equal(0, 'reauthorize'); // has not reached TTL yet - expect(messageCids.length).to.equal(2, 'messageCids'); - expect(messageCids).to.have.members([ aliceMessage1Cid, aliceMessage2Cid ]); - - await clock.tickAsync(1000); - - const aliceMessage3 = await TestDataGenerator.generateRecordsWrite({ - author : alice, - protocol : proto, - protocolPath : 'chat' - }); - const aliceMessage3Cid = await Message.getCid(aliceMessage3.message); - const aliceMessage3Reply = await dwn.processMessage(alice.did, aliceMessage3.message, aliceMessage3.dataStream); - expect(aliceMessage3Reply.status.code).to.equal(202); - - authorizeSpy.restore(); - clock.restore(); - - while (messageCids.length < 3) { - await Time.minimalSleep(); - } - - expect(authorizeSpy.callCount).to.equal(1, 'reauthorize'); // called once after the TTL has passed - expect(messageCids.length).to.equal(3, 'messageCids'); - expect(messageCids).to.have.members([ aliceMessage1Cid, aliceMessage2Cid, aliceMessage3Cid ]); - - }); - - it('no longer sends to subscription handler if subscription becomes un-authorized', async () => { - const eventStream = new EventStreamEmitter({ reauthorizationTTL: -1 }); // reauthorize with each event - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); - - const alice = await DidKeyResolver.generate(); - const bob = await DidKeyResolver.generate(); - - // spy on subscription close to test for - const subscriptionCloseSpy = sinon.spy(RecordsSubscriptionHandler.prototype, 'close'); - - // alice writes the friend role protocol - const protocolConf = await TestDataGenerator.generateProtocolsConfigure({ - author : alice, - published : true, - protocolDefinition : { ...friendRole } - }); - const proto = protocolConf.message.descriptor.definition.protocol; - const protoConfResponse = await dwn.processMessage(alice.did, protocolConf.message); - expect(protoConfResponse.status.code).equals(202); - - // alice adds bob as a friend. - const bobFriend = await TestDataGenerator.generateRecordsWrite({ - author : alice, - recipient : bob.did, - protocol : proto, - protocolPath : 'friend', - }); - const bobFriendReply = await dwn.processMessage(alice.did, bobFriend.message, bobFriend.dataStream); - expect(bobFriendReply.status.code).to.equal(202); - - // bob subscribes - const bobSubscribe = await TestDataGenerator.generateRecordsSubscribe({ - author : bob, - filter : { - protocol : proto, - protocolPath : 'chat', - }, - protocolRole: 'friend' - }); - const bobSubscribeReply = await dwn.processMessage(alice.did, bobSubscribe.message); - expect(bobSubscribeReply.status.code).to.equal(200); - - - const errorHandlerPromise = new Promise((_,reject) => { - const errorHandler = (error: DwnError): void => { reject(error); }; - bobSubscribeReply.subscription!.onError(errorHandler); - }); - - // capture the messageCids from the subscription - const messageCids: string[] = []; - const captureFunction = async (message: RecordsWriteMessage | RecordsDeleteMessage):Promise => { - const messageCid = await Message.getCid(message); - messageCids.push(messageCid); - }; - bobSubscribeReply.subscription!.on(captureFunction); - - //write a chat messages - const aliceMessage1 = await TestDataGenerator.generateRecordsWrite({ - author : alice, - protocol : proto, - protocolPath : 'chat' - }); - const aliceMessage1Cid = await Message.getCid(aliceMessage1.message); - const aliceMessage1Reply = await dwn.processMessage(alice.did, aliceMessage1.message, aliceMessage1.dataStream); - expect(aliceMessage1Reply.status.code).to.equal(202); - - // delete friend role - const deleteBobFriend = await TestDataGenerator.generateRecordsDelete({ - author : alice, - recordId : bobFriend.message.recordId, - }); - const deleteBobFriendReply = await dwn.processMessage(alice.did, deleteBobFriend.message); - expect(deleteBobFriendReply.status.code).to.equal(202); - - const aliceMessage2 = await TestDataGenerator.generateRecordsWrite({ - author : alice, - protocol : proto, - protocolPath : 'chat' - }); - const aliceMessage2Reply = await dwn.processMessage(alice.did, aliceMessage2.message, aliceMessage2.dataStream); - expect(aliceMessage2Reply.status.code).to.equal(202); - - const aliceMessage3 = await TestDataGenerator.generateRecordsWrite({ - author : alice, - protocol : proto, - protocolPath : 'chat' - }); - const aliceMessage3Reply = await dwn.processMessage(alice.did, aliceMessage3.message, aliceMessage3.dataStream); - expect(aliceMessage3Reply.status.code).to.equal(202); - - await Time.minimalSleep(); - - expect(messageCids.length).to.equal(1, 'messageCids'); - expect(messageCids).to.have.members([ aliceMessage1Cid ]); - expect(subscriptionCloseSpy.called).to.be.true; - await expect(errorHandlerPromise).to.eventually.be.rejectedWith(DwnErrorCode.RecordsSubscribeUnauthorized); - }); - }); }); } \ No newline at end of file diff --git a/tests/test-suite.ts b/tests/test-suite.ts index 1e2d51cb3..01e456b99 100644 --- a/tests/test-suite.ts +++ b/tests/test-suite.ts @@ -17,7 +17,6 @@ import { testProtocolsQueryHandler } from './handlers/protocols-query.spec.js'; import { testRecordsDeleteHandler } from './handlers/records-delete.spec.js'; import { testRecordsQueryHandler } from './handlers/records-query.spec.js'; import { testRecordsReadHandler } from './handlers/records-read.spec.js'; -import { testRecordsSubscribeHandler } from './handlers/records-subscribe.spec.js'; import { testRecordsWriteHandler } from './handlers/records-write.spec.js'; import { TestStores } from './test-stores.js'; import { testSubscriptionScenarios } from './scenarios/subscriptions.spec.js'; @@ -53,7 +52,6 @@ export class TestSuite { testRecordsDeleteHandler(); testRecordsQueryHandler(); testRecordsReadHandler(); - testRecordsSubscribeHandler(); testRecordsWriteHandler(); // scenario tests diff --git a/tests/utils/test-data-generator.ts b/tests/utils/test-data-generator.ts index 9015faf2d..ac8c3cbd4 100644 --- a/tests/utils/test-data-generator.ts +++ b/tests/utils/test-data-generator.ts @@ -11,7 +11,7 @@ import type { ProtocolsConfigureOptions } from '../../src/interfaces/protocols-c import type { ProtocolsQueryOptions } from '../../src/interfaces/protocols-query.js'; import type { Readable } from 'readable-stream'; import type { RecordsQueryOptions } from '../../src/interfaces/records-query.js'; -import type { RecordsSubscribeOptions } from '../../src/interfaces/records-subscribe.js'; +import type { RecordsWriteMessage } from '../../src/types/records-types.js'; import type { Signer } from '../../src/types/signer.js'; import type { AuthorizationModel, Pagination } from '../../src/types/message-types.js'; import type { CreateFromOptions, EncryptionInput, KeyEncryptionInput, RecordsWriteOptions } from '../../src/interfaces/records-write.js'; @@ -21,7 +21,6 @@ import type { PermissionConditions, PermissionScope } from '../../src/types/perm import type { PermissionsGrantMessage, PermissionsRequestMessage, PermissionsRevokeMessage } from '../../src/types/permissions-types.js'; import type { PrivateJwk, PublicJwk } from '../../src/types/jose-types.js'; import type { ProtocolDefinition, ProtocolsConfigureMessage, ProtocolsQueryMessage } from '../../src/types/protocols-types.js'; -import type { RecordsSubscribeMessage, RecordsWriteMessage } from '../../src/types/records-types.js'; import * as cbor from '@ipld/dag-cbor'; @@ -43,7 +42,6 @@ import { ProtocolsQuery } from '../../src/interfaces/protocols-query.js'; import { Records } from '../../src/utils/records.js'; import { RecordsDelete } from '../../src/interfaces/records-delete.js'; import { RecordsQuery } from '../../src/interfaces/records-query.js'; -import { RecordsSubscribe } from '../../src/interfaces/records-subscribe.js'; import { RecordsWrite } from '../../src/interfaces/records-write.js'; import { removeUndefinedProperties } from '../../src/utils/object.js'; import { Secp256k1 } from '../../src/utils/secp256k1.js'; @@ -168,22 +166,6 @@ export type GenerateRecordsQueryOutput = { message: RecordsQueryMessage; }; -export type GenerateRecordsSubscribeInput = { - /** - * Treated as `false` if not given. - */ - anonymous?: boolean; - author?: Persona; - messageTimestamp?: string; - filter?: RecordsFilter; - protocolRole?: string; -}; - -export type GenerateRecordsSubscribeOutput = { - author: Persona | undefined; - message: RecordsSubscribeMessage; -}; - export type GenerateRecordsDeleteInput = { author?: Persona; recordId?: string; @@ -669,44 +651,6 @@ export class TestDataGenerator { }; }; - /** - * Generates a RecordsSubscribe message for testing. - */ - public static async generateRecordsSubscribe(input?: GenerateRecordsSubscribeInput): Promise { - let author = input?.author; - const anonymous: boolean = input?.anonymous ?? false; - - if (anonymous && author) { - throw new Error('Cannot have `author` and be anonymous at the same time.'); - } - - // generate author if needed - if (author === undefined && !anonymous) { - author = await TestDataGenerator.generatePersona(); - } - - let signer = undefined; - if (author !== undefined) { - signer = Jws.createSigner(author); - } - - const options: RecordsSubscribeOptions = { - messageTimestamp : input?.messageTimestamp, - signer, - filter : input?.filter ?? { schema: TestDataGenerator.randomString(10) }, // must have one filter property if no filter is given - protocolRole : input?.protocolRole, - }; - removeUndefinedProperties(options); - - const recordsSubscribe = await RecordsSubscribe.create(options); - const message = recordsSubscribe.message; - - return { - author, - message - }; - } - /** * Generates a RecordsDelete for testing. */ diff --git a/tests/vectors/protocol-definitions/friend-role.json b/tests/vectors/protocol-definitions/friend-role.json index 88ac3901c..fc8e650ab 100644 --- a/tests/vectors/protocol-definitions/friend-role.json +++ b/tests/vectors/protocol-definitions/friend-role.json @@ -34,10 +34,6 @@ "role": "friend", "can": "query" }, - { - "role": "friend", - "can": "subscribe" - }, { "role": "admin", "can": "update" diff --git a/tests/vectors/protocol-definitions/thread-role.json b/tests/vectors/protocol-definitions/thread-role.json index ce2a07d72..88ac9842d 100644 --- a/tests/vectors/protocol-definitions/thread-role.json +++ b/tests/vectors/protocol-definitions/thread-role.json @@ -49,10 +49,6 @@ "role": "thread/participant", "can": "query" }, - { - "role": "thread/participant", - "can": "subscribe" - }, { "role": "thread/admin", "can": "update" From ee710883ad0776e360aad6d15c7124e79b72183b Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Thu, 11 Jan 2024 15:53:27 -0500 Subject: [PATCH 18/44] simplify the EventStream interface and logic --- src/dwn.ts | 1 - src/event-log/event-stream.ts | 66 +++---------- src/event-log/subscription.ts | 17 +--- src/handlers/events-subscribe.ts | 11 +-- src/types/subscriptions.ts | 20 +--- tests/event-log/event-stream-emitter.spec.ts | 99 ++++++++++---------- 6 files changed, 75 insertions(+), 139 deletions(-) diff --git a/src/dwn.ts b/src/dwn.ts index 3e379d2ca..82b7bf711 100644 --- a/src/dwn.ts +++ b/src/dwn.ts @@ -60,7 +60,6 @@ export class Dwn { ), [DwnInterfaceName.Events+ DwnMethodName.Subscribe]: new EventsSubscribeHandler( this.didResolver, - this.messageStore, this.eventStream, ), [DwnInterfaceName.Messages + DwnMethodName.Get]: new MessagesGetHandler( diff --git a/src/event-log/event-stream.ts b/src/event-log/event-stream.ts index 7b78f6eaa..e5eaa8661 100644 --- a/src/event-log/event-stream.ts +++ b/src/event-log/event-stream.ts @@ -1,38 +1,24 @@ -import type { MessageStore } from '../types/message-store.js'; -import type { EventsSubscribeMessage, EventsSubscription } from '../types/events-types.js'; -import type { EventStream, SubscriptionHandler } from '../types/subscriptions.js'; -import type { Filter, KeyValues } from '../types/query-types.js'; -import type { GenericMessage, GenericMessageSubscription } from '../types/message-types.js'; +import type { GenericMessage } from '../types/message-types.js'; +import type { KeyValues } from '../types/query-types.js'; +import type { EventListener, EventStream, EventSubscription } from '../types/subscriptions.js'; import { EventEmitter } from 'events'; -import { EventsSubscribe } from '../interfaces/events-subscribe.js'; -import { EventsSubscriptionHandler } from '../handlers/events-subscribe.js'; -import { Message } from '../core/message.js'; -import { DwnError, DwnErrorCode } from '../core/dwn-error.js'; -const eventChannel = 'events'; +const EVENTS_LISTENER_CHANNEL = 'events'; type EventStreamConfig = { emitter?: EventEmitter; - reauthorizationTTL?: number; }; export class EventStreamEmitter implements EventStream { private eventEmitter: EventEmitter; - private reauthorizationTTL: number; private isOpen: boolean = false; - private subscriptions: Map = new Map(); constructor(config?: EventStreamConfig) { - this.reauthorizationTTL = config?.reauthorizationTTL || 0; // if set to zero it does not reauthorize - // we capture the rejections and currently just log the errors that are produced this.eventEmitter = config?.emitter || new EventEmitter({ captureRejections: true }); - } - - private get eventChannel(): string { - return `${eventChannel}_bus`; + this.eventEmitter.on('error', this.eventError); } // we subscribe to the general `EventEmitter` error events with this handler. @@ -41,43 +27,15 @@ export class EventStreamEmitter implements EventStream { console.error('event emitter error', error); }; - async subscribe(tenant: string, message: EventsSubscribeMessage, filters: Filter[], messageStore: MessageStore): Promise; - async subscribe(tenant: string, message: GenericMessage, filters: Filter[], messageStore: MessageStore): Promise { - const messageCid = await Message.getCid(message); - let subscription = this.subscriptions.get(messageCid); - if (subscription !== undefined) { - return subscription; - } - - if (EventsSubscribe.isEventsSubscribeMessage(message)) { - subscription = await EventsSubscriptionHandler.create({ - tenant, - message, - filters, - messageStore, - unsubscribe : () => this.unsubscribe(messageCid), - eventEmitter : this.eventEmitter, - }); - } else { - throw new DwnError(DwnErrorCode.EventStreamSubscriptionNotSupported, 'not a supported subscription message'); - } - - this.subscriptions.set(messageCid, subscription); - this.eventEmitter.addListener(this.eventChannel, subscription.listener); - - return subscription; - } - - private async unsubscribe(id:string): Promise { - const subscription = this.subscriptions.get(id); - if (subscription !== undefined) { - this.subscriptions.delete(id); - this.eventEmitter.removeListener(this.eventChannel, subscription.listener); - } + async subscribe(id: string, listener: EventListener): Promise { + this.eventEmitter.on(EVENTS_LISTENER_CHANNEL, listener); + return { + id, + close: async (): Promise => { this.eventEmitter.off(EVENTS_LISTENER_CHANNEL, listener); } + }; } async open(): Promise { - this.eventEmitter.on('error', this.eventError); this.isOpen = true; } @@ -92,7 +50,7 @@ export class EventStreamEmitter implements EventStream { return; } try { - this.eventEmitter.emit(this.eventChannel, tenant, message, indexes); + this.eventEmitter.emit(EVENTS_LISTENER_CHANNEL, tenant, message, indexes); } catch (error) { this.eventError(error); } diff --git a/src/event-log/subscription.ts b/src/event-log/subscription.ts index 8fb337812..48df6ea93 100644 --- a/src/event-log/subscription.ts +++ b/src/event-log/subscription.ts @@ -1,7 +1,6 @@ import type { DwnError } from '../core/dwn-error.js'; import type { EventEmitter } from 'events'; -import type { MessageStore } from '../types/message-store.js'; -import type { EmitFunction, SubscriptionHandler } from '../types/subscriptions.js'; +import type { EventListener } from '../types/subscriptions.js'; import type { Filter, KeyValues } from '../types/query-types.js'; import type { GenericMessage, GenericMessageHandler } from '../types/message-types.js'; @@ -10,14 +9,12 @@ import { FilterUtility } from '../utils/filter.js'; /** * Base class to extend default subscription behavior. */ -export class SubscriptionHandlerBase implements SubscriptionHandler { +export class SubscriptionHandlerBase { protected eventEmitter: EventEmitter; - protected messageStore: MessageStore; protected filters: Filter[]; protected tenant: string; protected message: GenericMessage; - #unsubscribe: () => Promise; #id: string; protected constructor(options: { @@ -26,19 +23,15 @@ export class SubscriptionHandlerBase implements SubscriptionHandler { id: string, filters: Filter[], eventEmitter: EventEmitter, - messageStore: MessageStore, - unsubscribe: () => Promise; } ) { - const { tenant, id, filters, eventEmitter, message, messageStore, unsubscribe } = options; + const { tenant, id, filters, eventEmitter, message } = options; this.tenant = tenant; this.#id = id; this.filters = filters; this.eventEmitter = eventEmitter; this.message = message; - this.messageStore = messageStore; - this.#unsubscribe = unsubscribe; } get eventChannel(): string { @@ -57,7 +50,7 @@ export class SubscriptionHandlerBase implements SubscriptionHandler { return tenant === this.tenant && FilterUtility.matchAnyFilter(indexes, this.filters); } - public listener: EmitFunction = (tenant, message, indexes):void => { + public listener: EventListener = (tenant, message, indexes):void => { if (this.matchFilters(tenant, indexes)) { this.eventEmitter.emit(this.eventChannel, message); } @@ -79,6 +72,6 @@ export class SubscriptionHandlerBase implements SubscriptionHandler { async close(): Promise { this.eventEmitter.removeAllListeners(this.eventChannel); this.eventEmitter.removeAllListeners(this.errorEventChannel); - await this.#unsubscribe(); + // await this.#unsubscribe(); } } \ No newline at end of file diff --git a/src/handlers/events-subscribe.ts b/src/handlers/events-subscribe.ts index 33dc345cc..c0e046f66 100644 --- a/src/handlers/events-subscribe.ts +++ b/src/handlers/events-subscribe.ts @@ -1,8 +1,7 @@ import type { DidResolver } from '../did/did-resolver.js'; -import type EventEmitter from 'events'; +import EventEmitter from 'events'; import type { EventStream } from '../types/subscriptions.js'; import type { Filter } from '../types/query-types.js'; -import type { MessageStore } from '../types/message-store.js'; import type { MethodHandler } from '../types/method-handler.js'; import type { EventsSubscribeMessage, EventsSubscribeReply } from '../types/events-types.js'; @@ -16,7 +15,6 @@ import { authenticate, authorizeOwner } from '../core/auth.js'; export class EventsSubscribeHandler implements MethodHandler { constructor( private didResolver: DidResolver, - private messageStore: MessageStore, private eventStream: EventStream ) {} @@ -44,8 +42,11 @@ export class EventsSubscribeHandler implements MethodHandler { try { const { filters } = message.descriptor; + const eventEmitter = new EventEmitter(); const eventsFilters = Events.convertFilters(filters); - const subscription = await this.eventStream.subscribe(tenant, message, eventsFilters, this.messageStore); + const subscription = await EventsSubscriptionHandler.create({ tenant, message, filters: eventsFilters, eventEmitter }); + this.eventStream.subscribe(subscription.id, subscription.listener); + const messageReply: EventsSubscribeReply = { status: { code: 200, detail: 'OK' }, subscription, @@ -63,8 +64,6 @@ export class EventsSubscriptionHandler extends SubscriptionHandlerBase { message: EventsSubscribeMessage, filters: Filter[], eventEmitter: EventEmitter, - messageStore: MessageStore, - unsubscribe: () => Promise }): Promise { const id = await Message.getCid(input.message); return new EventsSubscriptionHandler({ ...input, id }); diff --git a/src/types/subscriptions.ts b/src/types/subscriptions.ts index 2ec702cf3..5d388ffff 100644 --- a/src/types/subscriptions.ts +++ b/src/types/subscriptions.ts @@ -1,31 +1,21 @@ -import type { DwnError } from '../core/dwn-error.js'; import type { GenericMessageReply } from '../types/message-types.js'; -import type { MessageStore } from './message-store.js'; -import type { EventsSubscribeMessage, EventsSubscription } from './events-types.js'; -import type { Filter, KeyValues } from './query-types.js'; -import type { GenericMessage, GenericMessageHandler, GenericMessageSubscription } from './message-types.js'; +import type { KeyValues } from './query-types.js'; +import type { GenericMessage, GenericMessageSubscription } from './message-types.js'; -export type EmitFunction = (tenant: string, message: GenericMessage, indexes: KeyValues) => void; +export type EventListener = (tenant: string, message: GenericMessage, indexes: KeyValues) => void; /** * The EventStream interface implements a pub/sub system based on Message filters. */ export interface EventStream { - subscribe(tenant: string, message: EventsSubscribeMessage, filters: Filter[], messageStore: MessageStore): Promise; - subscribe(tenant: string, message: GenericMessage, filters: Filter[], messageStore: MessageStore): Promise; + subscribe(id: string, listener: EventListener): Promise; emit(tenant: string, message: GenericMessage, indexes: KeyValues): void; open(): Promise; close(): Promise; } -/** - * The SubscriptionHandler interface is implemented by specific types of Subscription Handlers. - */ -export interface SubscriptionHandler { +export interface EventSubscription { id: string; - listener: EmitFunction; - on: (handler: GenericMessageHandler) => { off: () => void }; - onError: (handler: (error: DwnError) => void) => void; close: () => Promise; } diff --git a/tests/event-log/event-stream-emitter.spec.ts b/tests/event-log/event-stream-emitter.spec.ts index 7308e0ba0..ff223c403 100644 --- a/tests/event-log/event-stream-emitter.spec.ts +++ b/tests/event-log/event-stream-emitter.spec.ts @@ -1,13 +1,10 @@ -import type { GenericMessage, MessageStore } from '../../src/index.js'; +import type { EventStreamEmitter } from '../../src/event-log/event-stream.js'; +import type { MessageStore } from '../../src/index.js'; -import EventEmitter from 'events'; -import { EventStreamEmitter } from '../../src/event-log/event-stream.js'; -import { TestDataGenerator } from '../utils/test-data-generator.js'; import { TestStores } from '../test-stores.js'; -import { DidKeyResolver, Message } from '../../src/index.js'; +import chai from 'chai'; import chaiAsPromised from 'chai-as-promised'; -import chai, { expect } from 'chai'; chai.use(chaiAsPromised); @@ -29,49 +26,49 @@ describe('EventStreamEmitter', () => { await eventStream.close(); }); - it('should remove listeners when unsubscribe method is used', async () => { - const alice = await DidKeyResolver.generate(); - - const emitter = new EventEmitter(); - eventStream = new EventStreamEmitter({ emitter }); - - // count the `events_bus` listeners, which represents all listeners - expect(emitter.listenerCount('events_bus')).to.equal(0); - - // initiate a subscription, which should add a listener - const { message } = await TestDataGenerator.generateEventsSubscribe({ author: alice }); - const sub = await eventStream.subscribe(alice.did, message, [], messageStore); - expect(emitter.listenerCount('events_bus')).to.equal(1); - - // close the subscription, which should remove the listener - await sub.close(); - expect(emitter.listenerCount('events_bus')).to.equal(0); - }); - - it('should remove listeners when off method is used', async () => { - const alice = await DidKeyResolver.generate(); - const emitter = new EventEmitter(); - eventStream = new EventStreamEmitter({ emitter }); - - // initiate a subscription - const { message } = await TestDataGenerator.generateEventsSubscribe(); - const sub = await eventStream.subscribe(alice.did, message, [], messageStore); - const messageCid = await Message.getCid(message); - - // the listener count for the specific subscription should be at zero - expect(emitter.listenerCount(`${alice.did}_${messageCid}`)).to.equal(0); - const handler = (_:GenericMessage):void => {}; - const on1 = sub.on(handler); - const on2 = sub.on(handler); - - // after registering two handlers, there should be two listeners - expect(emitter.listenerCount(`${alice.did}_${messageCid}`)).to.equal(2); - - // un-register the handlers one by one, checking the listener count after each. - on1.off(); - expect(emitter.listenerCount(`${alice.did}_${messageCid}`)).to.equal(1); - on2.off(); - expect(emitter.listenerCount(`${alice.did}_${messageCid}`)).to.equal(0); - await sub.close(); - }); + // it('should remove listeners when unsubscribe method is used', async () => { + // const alice = await DidKeyResolver.generate(); + + // const emitter = new EventEmitter(); + // eventStream = new EventStreamEmitter({ emitter }); + + // // count the `events_bus` listeners, which represents all listeners + // expect(emitter.listenerCount('events_bus')).to.equal(0); + + // // initiate a subscription, which should add a listener + // const { message } = await TestDataGenerator.generateEventsSubscribe({ author: alice }); + // const sub = await eventStream.subscribe(alice.did, message, [], messageStore); + // expect(emitter.listenerCount('events_bus')).to.equal(1); + + // // close the subscription, which should remove the listener + // await sub.close(); + // expect(emitter.listenerCount('events_bus')).to.equal(0); + // }); + + // it('should remove listeners when off method is used', async () => { + // const alice = await DidKeyResolver.generate(); + // const emitter = new EventEmitter(); + // eventStream = new EventStreamEmitter({ emitter }); + + // // initiate a subscription + // const { message } = await TestDataGenerator.generateEventsSubscribe(); + // const sub = await eventStream.subscribe(alice.did, message, [], messageStore); + // const messageCid = await Message.getCid(message); + + // // the listener count for the specific subscription should be at zero + // expect(emitter.listenerCount(`${alice.did}_${messageCid}`)).to.equal(0); + // const handler = (_:GenericMessage):void => {}; + // const on1 = sub.on(handler); + // const on2 = sub.on(handler); + + // // after registering two handlers, there should be two listeners + // expect(emitter.listenerCount(`${alice.did}_${messageCid}`)).to.equal(2); + + // // un-register the handlers one by one, checking the listener count after each. + // on1.off(); + // expect(emitter.listenerCount(`${alice.did}_${messageCid}`)).to.equal(1); + // on2.off(); + // expect(emitter.listenerCount(`${alice.did}_${messageCid}`)).to.equal(0); + // await sub.close(); + // }); }); From 5eb7dbda991681ebb4461550fbe666c00e73c607 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Fri, 12 Jan 2024 21:10:36 -0500 Subject: [PATCH 19/44] the subsciription message handler should come in through MessageOptions --- src/dwn.ts | 10 ++-- src/handlers/events-subscribe.ts | 61 ++++++++++++++------- src/types/events-types.ts | 10 ++-- src/types/message-types.ts | 1 + src/types/method-handler.ts | 3 +- tests/handlers/events-subscribe.spec.ts | 22 ++++---- tests/scenarios/subscriptions.spec.ts | 71 ++++++++++++------------- 7 files changed, 102 insertions(+), 76 deletions(-) diff --git a/src/dwn.ts b/src/dwn.ts index 82b7bf711..e91e7eebd 100644 --- a/src/dwn.ts +++ b/src/dwn.ts @@ -5,7 +5,7 @@ import type { MessageStore } from './types/message-store.js'; import type { MethodHandler } from './types/method-handler.js'; import type { TenantGate } from './core/tenant-gate.js'; import type { UnionMessageReply } from './core/message-reply.js'; -import type { EventsGetMessage, EventsGetReply, EventsQueryMessage, EventsQueryReply, EventsSubscribeMessage, EventsSubscribeReply } from './types/events-types.js'; +import type { EventsGetMessage, EventsGetReply, EventsQueryMessage, EventsQueryReply, EventsSubscribeMessage, EventsSubscribeMessageOptions, EventsSubscribeReply } from './types/events-types.js'; import type { GenericMessage, GenericMessageReply, MessageOptions } from './types/message-types.js'; import type { MessagesGetMessage, MessagesGetReply } from './types/messages-types.js'; import type { PermissionsGrantMessage, PermissionsRequestMessage, PermissionsRevokeMessage } from './types/permissions-types.js'; @@ -157,7 +157,8 @@ export class Dwn { */ public async processMessage(tenant: string, rawMessage: EventsGetMessage): Promise; public async processMessage(tenant: string, rawMessage: EventsQueryMessage): Promise; - public async processMessage(tenant: string, rawMessage: EventsSubscribeMessage): Promise; + public async processMessage( + tenant: string, rawMessage: EventsSubscribeMessage, options?: EventsSubscribeMessageOptions): Promise; public async processMessage(tenant: string, rawMessage: MessagesGetMessage): Promise; public async processMessage(tenant: string, rawMessage: ProtocolsConfigureMessage): Promise; public async processMessage(tenant: string, rawMessage: ProtocolsQueryMessage): Promise; @@ -175,13 +176,14 @@ export class Dwn { return errorMessageReply; } - const { dataStream } = options; + const { dataStream, handler } = options; const handlerKey = rawMessage.descriptor.interface + rawMessage.descriptor.method; const methodHandlerReply = await this.methodHandlers[handlerKey].handle({ tenant, message: rawMessage as GenericMessage, - dataStream + dataStream, + handler }); return methodHandlerReply; diff --git a/src/handlers/events-subscribe.ts b/src/handlers/events-subscribe.ts index c0e046f66..3686b28b6 100644 --- a/src/handlers/events-subscribe.ts +++ b/src/handlers/events-subscribe.ts @@ -1,15 +1,17 @@ -import type { DidResolver } from '../did/did-resolver.js'; import EventEmitter from 'events'; -import type { EventStream } from '../types/subscriptions.js'; + +import type { DidResolver } from '../did/did-resolver.js'; import type { Filter } from '../types/query-types.js'; +import type { GenericMessageHandler } from '../types/message-types.js'; import type { MethodHandler } from '../types/method-handler.js'; -import type { EventsSubscribeMessage, EventsSubscribeReply } from '../types/events-types.js'; +import type { EventListener, EventStream } from '../types/subscriptions.js'; +import type { EventsSubscribeMessage, EventsSubscribeReply, EventsSubscription } from '../types/events-types.js'; import { Events } from '../utils/events.js'; import { EventsSubscribe } from '../interfaces/events-subscribe.js'; +import { FilterUtility } from '../utils/filter.js'; import { Message } from '../core/message.js'; import { messageReplyFromError } from '../core/message-reply.js'; -import { SubscriptionHandlerBase } from '../event-log/subscription.js'; import { authenticate, authorizeOwner } from '../core/auth.js'; export class EventsSubscribeHandler implements MethodHandler { @@ -21,10 +23,13 @@ export class EventsSubscribeHandler implements MethodHandler { public async handle({ tenant, message, + handler, }: { tenant: string; message: EventsSubscribeMessage; + handler: GenericMessageHandler; }): Promise { + let subscriptionRequest: EventsSubscribe; try { subscriptionRequest = await EventsSubscribe.parse(message); @@ -40,33 +45,51 @@ export class EventsSubscribeHandler implements MethodHandler { } try { - const { filters } = message.descriptor; - const eventEmitter = new EventEmitter(); const eventsFilters = Events.convertFilters(filters); - const subscription = await EventsSubscriptionHandler.create({ tenant, message, filters: eventsFilters, eventEmitter }); - this.eventStream.subscribe(subscription.id, subscription.listener); + const messageCid = await Message.getCid(message); + const subscription = await this.createEventSubscription(tenant, messageCid, handler, eventsFilters); const messageReply: EventsSubscribeReply = { status: { code: 200, detail: 'OK' }, subscription, }; + return messageReply; } catch (error) { return messageReplyFromError(error, 401); } } -} -export class EventsSubscriptionHandler extends SubscriptionHandlerBase { - public static async create(input: { + /** + * Creates an EventStream subscription and assigns the message handler to the listener. + * The listener checks that the incoming message matches the supplied filters, as well as is attributed to the tenant. + */ + private async createEventSubscription( tenant: string, - message: EventsSubscribeMessage, - filters: Filter[], - eventEmitter: EventEmitter, - }): Promise { - const id = await Message.getCid(input.message); - return new EventsSubscriptionHandler({ ...input, id }); - } -}; + messageCid: string, + handler: GenericMessageHandler, + filters: Filter[] + ): Promise { + + const eventEmitter = new EventEmitter(); + const eventChannel = `${tenant}_${messageCid}`; + + const listener: EventListener = (eventTenant, eventMessage, eventIndexes):void => { + if (tenant === eventTenant && FilterUtility.matchAnyFilter(eventIndexes, filters)) { + eventEmitter.emit(eventChannel, eventMessage); + } + }; + const eventsSubscription = await this.eventStream.subscribe(messageCid, listener); + eventEmitter.on(eventChannel, handler); + + return { + id : messageCid, + close : async (): Promise => { + await eventsSubscription.close(); + eventEmitter.off(eventChannel, handler); + }, + }; + } +} \ No newline at end of file diff --git a/src/types/events-types.ts b/src/types/events-types.ts index 7db6f7cc3..8605e5869 100644 --- a/src/types/events-types.ts +++ b/src/types/events-types.ts @@ -1,6 +1,5 @@ -import type { DwnError } from '../core/dwn-error.js'; import type { ProtocolsQueryFilter } from './protocols-types.js'; -import type { AuthorizationModel, GenericMessage, GenericMessageReply } from './message-types.js'; +import type { AuthorizationModel, GenericMessage, GenericMessageHandler, GenericMessageReply } from './message-types.js'; import type { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; import type { PaginationCursor, RangeCriterion, RangeFilter } from './query-types.js'; @@ -43,6 +42,11 @@ export type EventsGetReply = GenericMessageReply & { cursor?: PaginationCursor; }; + +export type EventsSubscribeMessageOptions = { + handler: GenericMessageHandler; +}; + export type EventsSubscribeMessage = { authorization?: AuthorizationModel; descriptor: EventsSubscribeDescriptor; @@ -52,8 +56,6 @@ export type EventsHandler = (message: GenericMessage) => void; export type EventsSubscription = { id: string; - on: (handler: EventsHandler) => { off: () => void }; - onError: (handler: (error: DwnError) => void) => void; close: () => Promise; }; diff --git a/src/types/message-types.ts b/src/types/message-types.ts index 3dfb461c2..c7be8a029 100644 --- a/src/types/message-types.ts +++ b/src/types/message-types.ts @@ -17,6 +17,7 @@ export type GenericMessage = { */ export type MessageOptions = { dataStream?: Readable; + handler?: GenericMessageHandler; }; /** diff --git a/src/types/method-handler.ts b/src/types/method-handler.ts index 7bc9bb07c..bbf273f33 100644 --- a/src/types/method-handler.ts +++ b/src/types/method-handler.ts @@ -1,5 +1,5 @@ import type { Readable } from 'readable-stream'; -import type { GenericMessage, GenericMessageReply } from './message-types.js'; +import type { GenericMessage, GenericMessageHandler, GenericMessageReply } from './message-types.js'; /** * Interface that defines a message handler of a specific method. @@ -12,5 +12,6 @@ export interface MethodHandler { tenant: string; message: GenericMessage; dataStream?: Readable + handler?: GenericMessageHandler; }): Promise; } \ No newline at end of file diff --git a/tests/handlers/events-subscribe.spec.ts b/tests/handlers/events-subscribe.spec.ts index 4f7b88077..e32b3d953 100644 --- a/tests/handlers/events-subscribe.spec.ts +++ b/tests/handlers/events-subscribe.spec.ts @@ -64,26 +64,26 @@ export function testEventsSubscribeHandler(): void { it('should allow tenant to subscribe their own event stream', async () => { const alice = await DidKeyResolver.generate(); + // set up a promise to read later that captures the emitted messageCid + let handler; + const messageSubscriptionPromise: Promise = new Promise((resolve) => { + handler = async (message: GenericMessage):Promise => { + const messageCid = await Message.getCid(message); + resolve(messageCid); + }; + }); + // testing Subscription Request const subscriptionRequest = await EventsSubscribe.create({ signer: Jws.createSigner(alice), }); + const subscriptionReply = await dwn.processMessage(alice.did, subscriptionRequest.message, { handler }); - const subscriptionReply = await dwn.processMessage(alice.did, subscriptionRequest.message); expect(subscriptionReply.status.code).to.equal(200); expect(subscriptionReply.subscription).to.not.be.undefined; - // set up a promise to read later that captures the emitted messageCid - const messageSubscriptionPromise: Promise = new Promise((resolve) => { - const process = async (message: GenericMessage):Promise => { - const messageCid = await Message.getCid(message); - resolve(messageCid); - }; - subscriptionReply.subscription!.on(process); - }); - const messageWrite = await TestDataGenerator.generateRecordsWrite({ author: alice }); - const writeReply = await dwn.processMessage(alice.did, messageWrite.message, messageWrite.dataStream); + const writeReply = await dwn.processMessage(alice.did, messageWrite.message, { dataStream: messageWrite.dataStream }); expect(writeReply.status.code).to.equal(202); const messageCid = await Message.getCid(messageWrite.message); diff --git a/tests/scenarios/subscriptions.spec.ts b/tests/scenarios/subscriptions.spec.ts index df8383b9e..10af7f94c 100644 --- a/tests/scenarios/subscriptions.spec.ts +++ b/tests/scenarios/subscriptions.spec.ts @@ -55,24 +55,23 @@ export function testSubscriptionScenarios(): void { it('all events', async () => { const alice = await DidKeyResolver.generate(); - // subscribe to all messages - const eventsSubscription = await TestDataGenerator.generateEventsSubscribe({ author: alice }); - const eventsSubscriptionReply = await dwn.processMessage(alice.did, eventsSubscription.message); - expect(eventsSubscriptionReply.status.code).to.equal(200); - expect(eventsSubscriptionReply.subscription?.id).to.equal(await Message.getCid(eventsSubscription.message)); - // create a handler that adds the messageCid of each message to an array. const messageCids: string[] = []; - const messageHandler = async (message: GenericMessage): Promise => { + const handler = async (message: GenericMessage): Promise => { const messageCid = await Message.getCid(message); messageCids.push(messageCid); }; - const handler = eventsSubscriptionReply.subscription!.on(messageHandler); + + // subscribe to all messages + const eventsSubscription = await TestDataGenerator.generateEventsSubscribe({ author: alice }); + const eventsSubscriptionReply = await dwn.processMessage(alice.did, eventsSubscription.message, { handler }); + expect(eventsSubscriptionReply.status.code).to.equal(200); + expect(eventsSubscriptionReply.subscription?.id).to.equal(await Message.getCid(eventsSubscription.message)); // generate various messages const write1 = await TestDataGenerator.generateRecordsWrite({ author: alice }); const write1MessageCid = await Message.getCid(write1.message); - const write1Reply = await dwn.processMessage(alice.did, write1.message, write1.dataStream); + const write1Reply = await dwn.processMessage(alice.did, write1.message, { dataStream: write1.dataStream }); expect(write1Reply.status.code).to.equal(202); const grant1 = await TestDataGenerator.generatePermissionsGrant({ author: alice }); @@ -91,11 +90,11 @@ export function testSubscriptionScenarios(): void { expect(deleteWrite1Reply.status.code).to.equal(202); // unregister the handler - handler.off(); + await eventsSubscriptionReply.subscription?.close(); // create a message after const write2 = await TestDataGenerator.generateRecordsWrite({ author: alice }); - const write2Reply = await dwn.processMessage(alice.did, write2.message, write2.dataStream); + const write2Reply = await dwn.processMessage(alice.did, write2.message, { dataStream: write2.dataStream }); expect(write2Reply.status.code).to.equal(202); await Time.minimalSleep(); @@ -138,44 +137,43 @@ export function testSubscriptionScenarios(): void { const proto1Messages:string[] = []; const proto2Messages:string[] = []; + // we add a handler to the subscription and add the messageCid to the appropriate array + const proto1Handler = async (message:GenericMessage):Promise => { + const messageCid = await Message.getCid(message); + proto1Messages.push(messageCid); + }; + // subscribe to proto1 messages const proto1Subscription = await TestDataGenerator.generateEventsSubscribe({ author: alice, filters: [{ protocol: proto1 }] }); - const proto1SubscriptionReply = await dwn.processMessage(alice.did, proto1Subscription.message); + const proto1SubscriptionReply = await dwn.processMessage(alice.did, proto1Subscription.message, { handler: proto1Handler }); expect(proto1SubscriptionReply.status.code).to.equal(200); expect(proto1SubscriptionReply.subscription?.id).to.equal(await Message.getCid(proto1Subscription.message)); // we add a handler to the subscription and add the messageCid to the appropriate array - const proto1Handler = async (message:GenericMessage):Promise => { + const proto2Handler = async (message:GenericMessage):Promise => { const messageCid = await Message.getCid(message); - proto1Messages.push(messageCid); + proto2Messages.push(messageCid); }; - const proto1Sub = proto1SubscriptionReply.subscription!.on(proto1Handler); // subscribe to proto2 messages const proto2Subscription = await TestDataGenerator.generateEventsSubscribe({ author: alice, filters: [{ protocol: proto2 }] }); - const proto2SubscriptionReply = await dwn.processMessage(alice.did, proto2Subscription.message); + const proto2SubscriptionReply = await dwn.processMessage(alice.did, proto2Subscription.message, { handler: proto2Handler }); expect(proto2SubscriptionReply.status.code).to.equal(200); expect(proto2SubscriptionReply.subscription?.id).to.equal(await Message.getCid(proto2Subscription.message)); - // we add a handler to the subscription and add the messageCid to the appropriate array - const proto2Handler = async (message:GenericMessage):Promise => { - const messageCid = await Message.getCid(message); - proto2Messages.push(messageCid); - }; - proto2SubscriptionReply.subscription!.on(proto2Handler); // create some random record, will not show up in records subscription const write1Random = await TestDataGenerator.generateRecordsWrite({ author: alice }); - const write1RandomResponse = await dwn.processMessage(alice.did, write1Random.message, write1Random.dataStream); + const write1RandomResponse = await dwn.processMessage(alice.did, write1Random.message, { dataStream: write1Random.dataStream }); expect(write1RandomResponse.status.code).to.equal(202); // create a record for proto1 const write1proto1 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto1, ...postProperties }); - const write1Response = await dwn.processMessage(alice.did, write1proto1.message, write1proto1.dataStream); + const write1Response = await dwn.processMessage(alice.did, write1proto1.message, { dataStream: write1proto1.dataStream }); expect(write1Response.status.code).equals(202); // create a record for proto2 const write1proto2 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto2, ...postProperties }); - const write1Proto2Response = await dwn.processMessage(alice.did, write1proto2.message, write1proto2.dataStream); + const write1Proto2Response = await dwn.processMessage(alice.did, write1proto2.message, { dataStream: write1proto2.dataStream }); expect(write1Proto2Response.status.code).equals(202); expect(proto1Messages.length).to.equal(1, 'proto1'); @@ -184,16 +182,16 @@ export function testSubscriptionScenarios(): void { expect(proto2Messages).to.include(await Message.getCid(write1proto2.message)); // remove listener for proto1 - proto1Sub.off(); + proto1SubscriptionReply.subscription?.close(); // create another record for proto1 const write2proto1 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto1, ...postProperties }); - const write2Response = await dwn.processMessage(alice.did, write2proto1.message, write2proto1.dataStream); + const write2Response = await dwn.processMessage(alice.did, write2proto1.message, { dataStream: write2proto1.dataStream }); expect(write2Response.status.code).equals(202); // create another record for proto2 const write2proto2 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto2, ...postProperties }); - const write2Proto2Response = await dwn.processMessage(alice.did, write2proto2.message, write2proto2.dataStream); + const write2Proto2Response = await dwn.processMessage(alice.did, write2proto2.message, { dataStream: write2proto2.dataStream }); expect(write2Proto2Response.status.code).equals(202); // proto1 messages from handler do not change. @@ -208,24 +206,23 @@ export function testSubscriptionScenarios(): void { it('unsubscribes', async () => { const alice = await DidKeyResolver.generate(); - // subscribe to all events - const eventsSubscription = await TestDataGenerator.generateEventsSubscribe({ author: alice }); - const eventsSubscriptionReply = await dwn.processMessage(alice.did, eventsSubscription.message); - expect(eventsSubscriptionReply.status.code).to.equal(200); - // messageCids of events const messageCids:string[] = []; - const eventsHandler = async (message: GenericMessage): Promise => { + const handler = async (message: GenericMessage): Promise => { const messageCid = await Message.getCid(message); messageCids.push(messageCid); }; - eventsSubscriptionReply.subscription!.on(eventsHandler); + + // subscribe to all events + const eventsSubscription = await TestDataGenerator.generateEventsSubscribe({ author: alice }); + const eventsSubscriptionReply = await dwn.processMessage(alice.did, eventsSubscription.message, { handler }); + expect(eventsSubscriptionReply.status.code).to.equal(200); expect(messageCids.length).to.equal(0); // no events exist yet const record1 = await TestDataGenerator.generateRecordsWrite({ author: alice }); - const record1Reply = await dwn.processMessage(alice.did, record1.message, record1.dataStream); + const record1Reply = await dwn.processMessage(alice.did, record1.message, { dataStream: record1.dataStream }); expect(record1Reply.status.code).to.equal(202); const record1MessageCid = await Message.getCid(record1.message); @@ -237,7 +234,7 @@ export function testSubscriptionScenarios(): void { // write another message. const record2 = await TestDataGenerator.generateRecordsWrite({ author: alice }); - const record2Reply = await dwn.processMessage(alice.did, record2.message, record2.dataStream); + const record2Reply = await dwn.processMessage(alice.did, record2.message, { dataStream: record2.dataStream }); expect(record2Reply.status.code).to.equal(202); // sleep to make sure events have some time to emit. From c82c6e45a30da3d47bf1b8bd77ae55460fe559b2 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Sat, 13 Jan 2024 15:15:40 -0500 Subject: [PATCH 20/44] should add the latest write published status to the delete index --- src/handlers/records-delete.ts | 3 ++- src/interfaces/records-delete.ts | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/handlers/records-delete.ts b/src/handlers/records-delete.ts index 98ba7a591..b047cb863 100644 --- a/src/handlers/records-delete.ts +++ b/src/handlers/records-delete.ts @@ -91,7 +91,8 @@ export class RecordsDeleteHandler implements MethodHandler { } const recordsWrite = await RecordsWrite.getInitialWrite(existingMessages); - const indexes = recordsDelete.constructIndexes(recordsWrite); + const published = (newestExistingMessage as RecordsWriteMessage).descriptor.published; + const indexes = recordsDelete.constructIndexes(recordsWrite, published); const messageCid = await Message.getCid(message); await this.messageStore.put(tenant, message, indexes); await this.eventLog.append(tenant, messageCid, indexes); diff --git a/src/interfaces/records-delete.ts b/src/interfaces/records-delete.ts index a632d1f72..367839738 100644 --- a/src/interfaces/records-delete.ts +++ b/src/interfaces/records-delete.ts @@ -75,7 +75,8 @@ export class RecordsDelete extends AbstractMessage { * Indexed properties needed for MessageStore indexing. */ public constructIndexes( - initialWrite: RecordsWriteMessage + initialWrite: RecordsWriteMessage, + published?: boolean, ): KeyValues { const message = this.message; const descriptor = { ...message.descriptor }; @@ -87,9 +88,10 @@ export class RecordsDelete extends AbstractMessage { // we intentionally not add index for `isLatestBaseState` at all, this means that upon a successful delete, // no messages with the record ID will match any query because queries by design filter by `isLatestBaseState = true`, // `isLatestBaseState` for the initial delete would have been toggled to `false` - const indexes: { [key:string]: string | undefined } = { + const indexes: { [key:string]: string | boolean | undefined } = { // isLatestBaseState : "true", // intentionally showing that this index is omitted protocol, protocolPath, recipient, schema, parentId, dataFormat, dateCreated, + published : !!published, contextId : initialWrite.contextId, author : this.author!, ...descriptor From 7cc34b6a54698df6053392ecdc418c8e43919f85 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Sat, 13 Jan 2024 17:37:04 -0500 Subject: [PATCH 21/44] revert an invisible change --- build/compile-validators.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build/compile-validators.js b/build/compile-validators.js index 5f84b7a5c..9d9bc0c43 100644 --- a/build/compile-validators.js +++ b/build/compile-validators.js @@ -60,8 +60,8 @@ const schemas = { RecordsWriteUnidentified, EventsFilter, EventsGet, - EventsSubscribe, EventsQuery, + EventsSubscribe, Definitions, GeneralJwk, GeneralJws, @@ -96,4 +96,4 @@ const moduleCode = standaloneCode(ajv); const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); await mkdirp(path.join(__dirname, '../generated')); -fs.writeFileSync(path.join(__dirname, '../generated/precompiled-validators.js'), moduleCode); \ No newline at end of file +fs.writeFileSync(path.join(__dirname, '../generated/precompiled-validators.js'), moduleCode); From 256d89cd4f7c70888473a07a8a33715b424bddd3 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Sat, 13 Jan 2024 21:30:34 -0500 Subject: [PATCH 22/44] remove unecessary class --- src/event-log/subscription.ts | 77 ----------------------------------- 1 file changed, 77 deletions(-) delete mode 100644 src/event-log/subscription.ts diff --git a/src/event-log/subscription.ts b/src/event-log/subscription.ts deleted file mode 100644 index 48df6ea93..000000000 --- a/src/event-log/subscription.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { DwnError } from '../core/dwn-error.js'; -import type { EventEmitter } from 'events'; -import type { EventListener } from '../types/subscriptions.js'; -import type { Filter, KeyValues } from '../types/query-types.js'; -import type { GenericMessage, GenericMessageHandler } from '../types/message-types.js'; - -import { FilterUtility } from '../utils/filter.js'; - -/** - * Base class to extend default subscription behavior. - */ -export class SubscriptionHandlerBase { - protected eventEmitter: EventEmitter; - protected filters: Filter[]; - protected tenant: string; - protected message: GenericMessage; - - #id: string; - - protected constructor(options: { - tenant: string, - message: GenericMessage, - id: string, - filters: Filter[], - eventEmitter: EventEmitter, - } - ) { - const { tenant, id, filters, eventEmitter, message } = options; - - this.tenant = tenant; - this.#id = id; - this.filters = filters; - this.eventEmitter = eventEmitter; - this.message = message; - } - - get eventChannel(): string { - return `${this.tenant}_${this.#id}`; - } - - get errorEventChannel(): string { - return `${this.tenant}_${this.#id}_error`; - } - - get id(): string { - return this.#id; - } - - protected matchFilters(tenant: string, indexes: KeyValues): boolean { - return tenant === this.tenant && FilterUtility.matchAnyFilter(indexes, this.filters); - } - - public listener: EventListener = (tenant, message, indexes):void => { - if (this.matchFilters(tenant, indexes)) { - this.eventEmitter.emit(this.eventChannel, message); - } - }; - - on(handler: GenericMessageHandler): { off: () => void } { - this.eventEmitter.on(this.eventChannel, handler); - return { - off: (): void => { - this.eventEmitter.off(this.eventChannel, handler); - } - }; - } - - onError(handler: (error: DwnError) => void): void { - this.eventEmitter.on(this.errorEventChannel, handler); - } - - async close(): Promise { - this.eventEmitter.removeAllListeners(this.eventChannel); - this.eventEmitter.removeAllListeners(this.errorEventChannel); - // await this.#unsubscribe(); - } -} \ No newline at end of file From e04dc1243ef149d21a5dafed51a343040ba7292c Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Sat, 13 Jan 2024 22:27:05 -0500 Subject: [PATCH 23/44] rename to EventEmitterStream --- json-schemas/interface-methods/events-subscribe.json | 3 ++- src/dwn.ts | 4 ++-- .../{event-stream.ts => event-emitter-stream.ts} | 9 ++++----- src/index.ts | 2 +- tests/event-log/event-stream-emitter.spec.ts | 4 ++-- 5 files changed, 11 insertions(+), 11 deletions(-) rename src/event-log/{event-stream.ts => event-emitter-stream.ts} (90%) diff --git a/json-schemas/interface-methods/events-subscribe.json b/json-schemas/interface-methods/events-subscribe.json index 4e5b9259f..99bbc25b3 100644 --- a/json-schemas/interface-methods/events-subscribe.json +++ b/json-schemas/interface-methods/events-subscribe.json @@ -4,7 +4,8 @@ "type": "object", "additionalProperties": false, "required": [ - "descriptor" + "descriptor", + "authorization" ], "properties": { "authorization": { diff --git a/src/dwn.ts b/src/dwn.ts index e91e7eebd..86da1950c 100644 --- a/src/dwn.ts +++ b/src/dwn.ts @@ -17,7 +17,7 @@ import { DidResolver } from './did/did-resolver.js'; import { EventsGetHandler } from './handlers/events-get.js'; import { EventsQueryHandler } from './handlers/events-query.js'; import { EventsSubscribeHandler } from './handlers/events-subscribe.js'; -import { EventStreamEmitter } from './event-log/event-stream.js'; +import { EventEmitterEventStream } from './event-log/event-emitter-stream.js'; import { Message } from './core/message.js'; import { messageReplyFromError } from './core/message-reply.js'; import { MessagesGetHandler } from './handlers/messages-get.js'; @@ -129,7 +129,7 @@ export class Dwn { public static async create(config: DwnConfig): Promise { config.didResolver ??= new DidResolver(); config.tenantGate ??= new AllowAllTenantGate(); - config.eventStream ??= new EventStreamEmitter(); + config.eventStream ??= new EventEmitterEventStream(); const dwn = new Dwn(config); await dwn.open(); diff --git a/src/event-log/event-stream.ts b/src/event-log/event-emitter-stream.ts similarity index 90% rename from src/event-log/event-stream.ts rename to src/event-log/event-emitter-stream.ts index e5eaa8661..0b2cf6914 100644 --- a/src/event-log/event-stream.ts +++ b/src/event-log/event-emitter-stream.ts @@ -6,16 +6,15 @@ import { EventEmitter } from 'events'; const EVENTS_LISTENER_CHANNEL = 'events'; -type EventStreamConfig = { +type EventStreamEmitterConfig = { emitter?: EventEmitter; }; -export class EventStreamEmitter implements EventStream { +export class EventEmitterStream implements EventStream { private eventEmitter: EventEmitter; - private isOpen: boolean = false; - constructor(config?: EventStreamConfig) { + constructor(config?: EventStreamEmitterConfig) { // we capture the rejections and currently just log the errors that are produced this.eventEmitter = config?.emitter || new EventEmitter({ captureRejections: true }); this.eventEmitter.on('error', this.eventError); @@ -46,7 +45,7 @@ export class EventStreamEmitter implements EventStream { emit(tenant: string, message: GenericMessage, indexes: KeyValues): void { if (!this.isOpen) { - // silently ignore. + // silently ignore return; } try { diff --git a/src/index.ts b/src/index.ts index a00723658..e1c86c713 100644 --- a/src/index.ts +++ b/src/index.ts @@ -58,7 +58,7 @@ export { EventLogLevel } from './event-log/event-log-level.js'; export { MessageStoreLevel } from './store/message-store-level.js'; // eventing implementations -export { EventStreamEmitter } from './event-log/event-stream.js'; +export { EventEmitterEventStream as EventStreamEmitter } from './event-log/event-emitter-stream.js'; export { EventsSubscribe , EventsSubscribeOptions } from './interfaces/events-subscribe.js'; // test library exports diff --git a/tests/event-log/event-stream-emitter.spec.ts b/tests/event-log/event-stream-emitter.spec.ts index ff223c403..d888a228c 100644 --- a/tests/event-log/event-stream-emitter.spec.ts +++ b/tests/event-log/event-stream-emitter.spec.ts @@ -1,4 +1,4 @@ -import type { EventStreamEmitter } from '../../src/event-log/event-stream.js'; +import type { EventEmitterEventStream } from '../../src/event-log/event-emitter-stream.js'; import type { MessageStore } from '../../src/index.js'; import { TestStores } from '../test-stores.js'; @@ -9,7 +9,7 @@ import chaiAsPromised from 'chai-as-promised'; chai.use(chaiAsPromised); describe('EventStreamEmitter', () => { - let eventStream: EventStreamEmitter; + let eventStream: EventEmitterEventStream; let messageStore: MessageStore; before(() => { From 22cbbb41d7a216eea43c93c5253d8da793f50304 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Tue, 16 Jan 2024 11:12:35 -0500 Subject: [PATCH 24/44] scaffold testing --- src/dwn.ts | 4 +- src/index.ts | 2 +- tests/event-log/event-emitter-stream.spec.ts | 61 ++++++++++++++ tests/event-log/event-stream-emitter.spec.ts | 74 ----------------- tests/event-log/event-stream.spec.ts | 85 +++++++++++++++++--- tests/handlers/events-subscribe.spec.ts | 7 +- tests/test-event-stream.ts | 5 +- 7 files changed, 145 insertions(+), 93 deletions(-) create mode 100644 tests/event-log/event-emitter-stream.spec.ts delete mode 100644 tests/event-log/event-stream-emitter.spec.ts diff --git a/src/dwn.ts b/src/dwn.ts index 86da1950c..ad86cc38f 100644 --- a/src/dwn.ts +++ b/src/dwn.ts @@ -14,10 +14,10 @@ import type { RecordsDeleteMessage, RecordsQueryMessage, RecordsQueryReply, Reco import { AllowAllTenantGate } from './core/tenant-gate.js'; import { DidResolver } from './did/did-resolver.js'; +import { EventEmitterStream } from './event-log/event-emitter-stream.js'; import { EventsGetHandler } from './handlers/events-get.js'; import { EventsQueryHandler } from './handlers/events-query.js'; import { EventsSubscribeHandler } from './handlers/events-subscribe.js'; -import { EventEmitterEventStream } from './event-log/event-emitter-stream.js'; import { Message } from './core/message.js'; import { messageReplyFromError } from './core/message-reply.js'; import { MessagesGetHandler } from './handlers/messages-get.js'; @@ -129,7 +129,7 @@ export class Dwn { public static async create(config: DwnConfig): Promise { config.didResolver ??= new DidResolver(); config.tenantGate ??= new AllowAllTenantGate(); - config.eventStream ??= new EventEmitterEventStream(); + config.eventStream ??= new EventEmitterStream(); const dwn = new Dwn(config); await dwn.open(); diff --git a/src/index.ts b/src/index.ts index e1c86c713..d23fb9b40 100644 --- a/src/index.ts +++ b/src/index.ts @@ -58,7 +58,7 @@ export { EventLogLevel } from './event-log/event-log-level.js'; export { MessageStoreLevel } from './store/message-store-level.js'; // eventing implementations -export { EventEmitterEventStream as EventStreamEmitter } from './event-log/event-emitter-stream.js'; +export { EventEmitterStream } from './event-log/event-emitter-stream.js'; export { EventsSubscribe , EventsSubscribeOptions } from './interfaces/events-subscribe.js'; // test library exports diff --git a/tests/event-log/event-emitter-stream.spec.ts b/tests/event-log/event-emitter-stream.spec.ts new file mode 100644 index 000000000..fade9ecb5 --- /dev/null +++ b/tests/event-log/event-emitter-stream.spec.ts @@ -0,0 +1,61 @@ +import { EventEmitter } from 'events'; + +import type { MessageStore } from '../../src/index.js'; + +import { EventEmitterStream } from '../../src/event-log/event-emitter-stream.js'; +import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestStores } from '../test-stores.js'; + +import sinon from 'sinon'; + +import chaiAsPromised from 'chai-as-promised'; +import chai, { expect } from 'chai'; + +chai.use(chaiAsPromised); + +describe('EventStreamEmitter', () => { + let eventStream: EventEmitterStream; + let messageStore: MessageStore; + + before(() => { + ({ messageStore } = TestStores.get()); + }); + + beforeEach(async () => { + messageStore.clear(); + }); + + after(async () => { + // Clean up after each test by closing and clearing the event stream + await messageStore.close(); + await eventStream.close(); + }); + + it('should remove listeners when unsubscribe method is used', async () => { + const emitter = new EventEmitter(); + eventStream = new EventEmitterStream({ emitter }); + + // count the `events_bus` listeners, which represents all listeners + expect(emitter.listenerCount('events')).to.equal(0); + + const sub = await eventStream.subscribe('id', () => {}); + expect(emitter.listenerCount('events')).to.equal(1); + + // close the subscription, which should remove the listener + await sub.close(); + expect(emitter.listenerCount('events')).to.equal(0); + }); + + xit('logs message when the emitter experiences an error', async () => { + const emitter = new EventEmitter({ captureRejections: true }); + sinon.stub(emitter, 'emit').rejects('unknown error'); + eventStream = new EventEmitterStream({ emitter }); + await eventStream.open(); + + const eventErrorSpy = sinon.spy(eventStream as any, 'eventError'); + await eventStream.subscribe('id', () => {}); + const { message } = await TestDataGenerator.generateRecordsWrite(); + eventStream.emit('alice', message, {}); + expect(eventErrorSpy.callCount).to.equal(1); + }); +}); diff --git a/tests/event-log/event-stream-emitter.spec.ts b/tests/event-log/event-stream-emitter.spec.ts deleted file mode 100644 index d888a228c..000000000 --- a/tests/event-log/event-stream-emitter.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { EventEmitterEventStream } from '../../src/event-log/event-emitter-stream.js'; -import type { MessageStore } from '../../src/index.js'; - -import { TestStores } from '../test-stores.js'; - -import chai from 'chai'; -import chaiAsPromised from 'chai-as-promised'; - -chai.use(chaiAsPromised); - -describe('EventStreamEmitter', () => { - let eventStream: EventEmitterEventStream; - let messageStore: MessageStore; - - before(() => { - ({ messageStore } = TestStores.get()); - }); - - beforeEach(async () => { - messageStore.clear(); - }); - - after(async () => { - // Clean up after each test by closing and clearing the event stream - await messageStore.close(); - await eventStream.close(); - }); - - // it('should remove listeners when unsubscribe method is used', async () => { - // const alice = await DidKeyResolver.generate(); - - // const emitter = new EventEmitter(); - // eventStream = new EventStreamEmitter({ emitter }); - - // // count the `events_bus` listeners, which represents all listeners - // expect(emitter.listenerCount('events_bus')).to.equal(0); - - // // initiate a subscription, which should add a listener - // const { message } = await TestDataGenerator.generateEventsSubscribe({ author: alice }); - // const sub = await eventStream.subscribe(alice.did, message, [], messageStore); - // expect(emitter.listenerCount('events_bus')).to.equal(1); - - // // close the subscription, which should remove the listener - // await sub.close(); - // expect(emitter.listenerCount('events_bus')).to.equal(0); - // }); - - // it('should remove listeners when off method is used', async () => { - // const alice = await DidKeyResolver.generate(); - // const emitter = new EventEmitter(); - // eventStream = new EventStreamEmitter({ emitter }); - - // // initiate a subscription - // const { message } = await TestDataGenerator.generateEventsSubscribe(); - // const sub = await eventStream.subscribe(alice.did, message, [], messageStore); - // const messageCid = await Message.getCid(message); - - // // the listener count for the specific subscription should be at zero - // expect(emitter.listenerCount(`${alice.did}_${messageCid}`)).to.equal(0); - // const handler = (_:GenericMessage):void => {}; - // const on1 = sub.on(handler); - // const on2 = sub.on(handler); - - // // after registering two handlers, there should be two listeners - // expect(emitter.listenerCount(`${alice.did}_${messageCid}`)).to.equal(2); - - // // un-register the handlers one by one, checking the listener count after each. - // on1.off(); - // expect(emitter.listenerCount(`${alice.did}_${messageCid}`)).to.equal(1); - // on2.off(); - // expect(emitter.listenerCount(`${alice.did}_${messageCid}`)).to.equal(0); - // await sub.close(); - // }); -}); diff --git a/tests/event-log/event-stream.spec.ts b/tests/event-log/event-stream.spec.ts index 9e2c23942..ef4cd4e1d 100644 --- a/tests/event-log/event-stream.spec.ts +++ b/tests/event-log/event-stream.spec.ts @@ -1,29 +1,92 @@ -import type { EventStream, MessageStore } from '../../src/index.js'; +import type { KeyValues } from '../../src/types/query-types.js'; +import type { EventStream, GenericMessage } from '../../src/index.js'; import { TestEventStream } from '../test-event-stream.js'; -import { TestStores } from '../test-stores.js'; +import { Message, TestDataGenerator, Time } from '../../src/index.js'; -import chai from 'chai'; import chaiAsPromised from 'chai-as-promised'; +import chai, { expect } from 'chai'; chai.use(chaiAsPromised); describe('EventStream', () => { let eventStream: EventStream; - let messageStore: MessageStore; - before(() => { - ({ messageStore } = TestStores.get()); + before(async () => { eventStream = TestEventStream.get(); - }); - - beforeEach(async () => { - messageStore.clear(); + await eventStream.open(); }); after(async () => { // Clean up after each test by closing and clearing the event stream - await messageStore.close(); await eventStream.close(); }); + + it('emits all messages to each subscriptions', async () => { + const messageCids: string[] = []; + const handler = async (_tenant: string, message: GenericMessage, _indexes: KeyValues): Promise => { + const messageCid = await Message.getCid(message); + messageCids.push(messageCid); + }; + const subcription = await eventStream.subscribe('sub-1', handler); + + const message1 = await TestDataGenerator.generateRecordsWrite({}); + const message1Cid = await Message.getCid(message1.message); + eventStream.emit('did:alice', message1.message, {}); + const message2 = await TestDataGenerator.generateRecordsWrite({}); + const message2Cid = await Message.getCid(message2.message); + eventStream.emit('did:alice', message2.message, {}); + const message3 = await TestDataGenerator.generateRecordsWrite({}); + const message3Cid = await Message.getCid(message3.message); + eventStream.emit('did:alice', message3.message, {}); + + await subcription.close(); + + await Time.minimalSleep(); + + expect(messageCids).to.have.members([ message1Cid, message2Cid, message3Cid ]); + }); + + it('does not emit messages if subscription is closed', async () => { + const messageCids: string[] = []; + const handler = async (_tenant: string, message: GenericMessage, _indexes: KeyValues): Promise => { + const messageCid = await Message.getCid(message); + messageCids.push(messageCid); + }; + const subcription = await eventStream.subscribe('sub-1', handler); + + const message1 = await TestDataGenerator.generateRecordsWrite({}); + const message1Cid = await Message.getCid(message1.message); + eventStream.emit('did:alice', message1.message, {}); + await subcription.close(); + + const message2 = await TestDataGenerator.generateRecordsWrite({}); + eventStream.emit('did:alice', message2.message, {}); + + await Time.minimalSleep(); + + expect(messageCids).to.have.members([ message1Cid ]); + }); + + it('does not emit messages if emitter is closed', async () => { + const messageCids: string[] = []; + const handler = async (_tenant: string, message: GenericMessage, _indexes: KeyValues): Promise => { + const messageCid = await Message.getCid(message); + messageCids.push(messageCid); + }; + const subcription = await eventStream.subscribe('sub-1', handler); + + // close eventEmitter + await eventStream.close(); + + const message1 = await TestDataGenerator.generateRecordsWrite({}); + eventStream.emit('did:alice', message1.message, {}); + const message2 = await TestDataGenerator.generateRecordsWrite({}); + eventStream.emit('did:alice', message2.message, {}); + + await subcription.close(); + + await Time.minimalSleep(); + expect(messageCids).to.have.length(0); + }); }); diff --git a/tests/handlers/events-subscribe.spec.ts b/tests/handlers/events-subscribe.spec.ts index e32b3d953..6276f71ec 100644 --- a/tests/handlers/events-subscribe.spec.ts +++ b/tests/handlers/events-subscribe.spec.ts @@ -101,11 +101,12 @@ export function testEventsSubscribeHandler(): void { const bob = await DidKeyResolver.generate(); // test anonymous request - const anonymousSubscription = await EventsSubscribe.create({}); - expect(anonymousSubscription.message.authorization).to.be.undefined; + const anonymousSubscription = await TestDataGenerator.generateEventsSubscribe(); + delete anonymousSubscription.message.authorization; // delete the authorization const anonymousReply = await dwn.processMessage(alice.did, anonymousSubscription.message); - expect(anonymousReply.status.code).to.equal(401); + expect(anonymousReply.status.code).to.equal(400); + expect(anonymousReply.status.detail).to.include(`EventsSubscribe: must have required property 'authorization'`); expect(anonymousReply.subscription).to.be.undefined; // testing Subscription Request diff --git a/tests/test-event-stream.ts b/tests/test-event-stream.ts index 830c9dab2..6cd4eb6e0 100644 --- a/tests/test-event-stream.ts +++ b/tests/test-event-stream.ts @@ -1,5 +1,6 @@ import type { EventStream } from '../src/index.js'; -import { EventStreamEmitter } from '../src/index.js'; + +import { EventEmitterStream } from '../src/index.js'; /** * Class that manages store implementations for testing. @@ -22,7 +23,7 @@ export class TestEventStream { * Initializes and return the stores used for running the test suite. */ public static get(): EventStream { - TestEventStream.eventStream ??= new EventStreamEmitter(); + TestEventStream.eventStream ??= new EventEmitterStream(); return TestEventStream.eventStream; } } \ No newline at end of file From 904e299172d0df2cc509f3c270a6449efa29e845 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Tue, 16 Jan 2024 14:09:28 -0500 Subject: [PATCH 25/44] clean up handler functionality --- src/handlers/events-subscribe.ts | 52 ++++++------------------- src/types/events-types.ts | 4 +- src/types/message-types.ts | 7 +--- src/types/method-handler.ts | 4 +- tests/handlers/events-subscribe.spec.ts | 3 +- 5 files changed, 18 insertions(+), 52 deletions(-) diff --git a/src/handlers/events-subscribe.ts b/src/handlers/events-subscribe.ts index 3686b28b6..ca826e8b5 100644 --- a/src/handlers/events-subscribe.ts +++ b/src/handlers/events-subscribe.ts @@ -1,11 +1,8 @@ -import EventEmitter from 'events'; - import type { DidResolver } from '../did/did-resolver.js'; -import type { Filter } from '../types/query-types.js'; -import type { GenericMessageHandler } from '../types/message-types.js'; +import type { GenericMessageSubscriptionHandler } from '../types/message-types.js'; import type { MethodHandler } from '../types/method-handler.js'; import type { EventListener, EventStream } from '../types/subscriptions.js'; -import type { EventsSubscribeMessage, EventsSubscribeReply, EventsSubscription } from '../types/events-types.js'; +import type { EventsSubscribeMessage, EventsSubscribeReply } from '../types/events-types.js'; import { Events } from '../utils/events.js'; import { EventsSubscribe } from '../interfaces/events-subscribe.js'; @@ -23,11 +20,11 @@ export class EventsSubscribeHandler implements MethodHandler { public async handle({ tenant, message, - handler, + handler }: { tenant: string; message: EventsSubscribeMessage; - handler: GenericMessageHandler; + handler: GenericMessageSubscriptionHandler; }): Promise { let subscriptionRequest: EventsSubscribe; @@ -48,7 +45,12 @@ export class EventsSubscribeHandler implements MethodHandler { const { filters } = message.descriptor; const eventsFilters = Events.convertFilters(filters); const messageCid = await Message.getCid(message); - const subscription = await this.createEventSubscription(tenant, messageCid, handler, eventsFilters); + const listener: EventListener = (eventTenant, eventMessage, eventIndexes):void => { + if (tenant === eventTenant && FilterUtility.matchAnyFilter(eventIndexes, eventsFilters)) { + handler(eventMessage); + } + }; + const subscription = await this.eventStream.subscribe(messageCid, listener); const messageReply: EventsSubscribeReply = { status: { code: 200, detail: 'OK' }, @@ -57,39 +59,7 @@ export class EventsSubscribeHandler implements MethodHandler { return messageReply; } catch (error) { - return messageReplyFromError(error, 401); + return messageReplyFromError(error, 400); } } - - /** - * Creates an EventStream subscription and assigns the message handler to the listener. - * The listener checks that the incoming message matches the supplied filters, as well as is attributed to the tenant. - */ - private async createEventSubscription( - tenant: string, - messageCid: string, - handler: GenericMessageHandler, - filters: Filter[] - ): Promise { - - const eventEmitter = new EventEmitter(); - const eventChannel = `${tenant}_${messageCid}`; - - const listener: EventListener = (eventTenant, eventMessage, eventIndexes):void => { - if (tenant === eventTenant && FilterUtility.matchAnyFilter(eventIndexes, filters)) { - eventEmitter.emit(eventChannel, eventMessage); - } - }; - - const eventsSubscription = await this.eventStream.subscribe(messageCid, listener); - eventEmitter.on(eventChannel, handler); - - return { - id : messageCid, - close : async (): Promise => { - await eventsSubscription.close(); - eventEmitter.off(eventChannel, handler); - }, - }; - } } \ No newline at end of file diff --git a/src/types/events-types.ts b/src/types/events-types.ts index 8605e5869..c58a92efb 100644 --- a/src/types/events-types.ts +++ b/src/types/events-types.ts @@ -1,5 +1,5 @@ import type { ProtocolsQueryFilter } from './protocols-types.js'; -import type { AuthorizationModel, GenericMessage, GenericMessageHandler, GenericMessageReply } from './message-types.js'; +import type { AuthorizationModel, GenericMessage, GenericMessageReply, GenericMessageSubscriptionHandler } from './message-types.js'; import type { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; import type { PaginationCursor, RangeCriterion, RangeFilter } from './query-types.js'; @@ -44,7 +44,7 @@ export type EventsGetReply = GenericMessageReply & { export type EventsSubscribeMessageOptions = { - handler: GenericMessageHandler; + handler: GenericMessageSubscriptionHandler; }; export type EventsSubscribeMessage = { diff --git a/src/types/message-types.ts b/src/types/message-types.ts index c7be8a029..dd1458a20 100644 --- a/src/types/message-types.ts +++ b/src/types/message-types.ts @@ -1,5 +1,4 @@ import type { DelegatedGrantMessage } from '../types/delegated-grant-message.js'; -import type { DwnError } from '../core/dwn-error.js'; import type { GeneralJws } from './jws-types.js'; import type { Readable } from 'readable-stream'; import type { PaginationCursor, SortDirection } from './query-types.js'; @@ -17,7 +16,7 @@ export type GenericMessage = { */ export type MessageOptions = { dataStream?: Readable; - handler?: GenericMessageHandler; + handler?: GenericMessageSubscriptionHandler; }; /** @@ -77,12 +76,10 @@ export type QueryResultEntry = GenericMessage & { encodedData?: string; }; -export type GenericMessageHandler = (message: GenericMessage) => void; +export type GenericMessageSubscriptionHandler = (message: GenericMessage) => void; export type GenericMessageSubscription = { id: string; - on: (handler: GenericMessageHandler) => { off: () => void }; - onError: (handler: (error: DwnError) => void) => void; close: () => Promise; }; diff --git a/src/types/method-handler.ts b/src/types/method-handler.ts index bbf273f33..ec0a20546 100644 --- a/src/types/method-handler.ts +++ b/src/types/method-handler.ts @@ -1,5 +1,5 @@ import type { Readable } from 'readable-stream'; -import type { GenericMessage, GenericMessageHandler, GenericMessageReply } from './message-types.js'; +import type { GenericMessage, GenericMessageReply, GenericMessageSubscriptionHandler } from './message-types.js'; /** * Interface that defines a message handler of a specific method. @@ -12,6 +12,6 @@ export interface MethodHandler { tenant: string; message: GenericMessage; dataStream?: Readable - handler?: GenericMessageHandler; + handler?: GenericMessageSubscriptionHandler; }): Promise; } \ No newline at end of file diff --git a/tests/handlers/events-subscribe.spec.ts b/tests/handlers/events-subscribe.spec.ts index 6276f71ec..6686741c7 100644 --- a/tests/handlers/events-subscribe.spec.ts +++ b/tests/handlers/events-subscribe.spec.ts @@ -78,8 +78,7 @@ export function testEventsSubscribeHandler(): void { signer: Jws.createSigner(alice), }); const subscriptionReply = await dwn.processMessage(alice.did, subscriptionRequest.message, { handler }); - - expect(subscriptionReply.status.code).to.equal(200); + expect(subscriptionReply.status.code).to.equal(200, subscriptionReply.status.detail); expect(subscriptionReply.subscription).to.not.be.undefined; const messageWrite = await TestDataGenerator.generateRecordsWrite({ author: alice }); From 3e4e0247a7c901c24bcfdb0ecc09dd1c520866ba Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Tue, 16 Jan 2024 15:28:39 -0500 Subject: [PATCH 26/44] event emitter stream tests --- src/event-log/event-emitter-stream.ts | 8 ++------ tests/event-log/event-emitter-stream.spec.ts | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/src/event-log/event-emitter-stream.ts b/src/event-log/event-emitter-stream.ts index 0b2cf6914..bc358cf87 100644 --- a/src/event-log/event-emitter-stream.ts +++ b/src/event-log/event-emitter-stream.ts @@ -22,7 +22,7 @@ export class EventEmitterStream implements EventStream { // we subscribe to the general `EventEmitter` error events with this handler. // this handler is also called when there is a caught error upon emitting an event from a handler. - private eventError = (error: any): void => { + private eventError(error: any): void { console.error('event emitter error', error); }; @@ -48,10 +48,6 @@ export class EventEmitterStream implements EventStream { // silently ignore return; } - try { - this.eventEmitter.emit(EVENTS_LISTENER_CHANNEL, tenant, message, indexes); - } catch (error) { - this.eventError(error); - } + this.eventEmitter.emit(EVENTS_LISTENER_CHANNEL, tenant, message, indexes); } } \ No newline at end of file diff --git a/tests/event-log/event-emitter-stream.spec.ts b/tests/event-log/event-emitter-stream.spec.ts index fade9ecb5..00e4a332f 100644 --- a/tests/event-log/event-emitter-stream.spec.ts +++ b/tests/event-log/event-emitter-stream.spec.ts @@ -3,7 +3,6 @@ import { EventEmitter } from 'events'; import type { MessageStore } from '../../src/index.js'; import { EventEmitterStream } from '../../src/event-log/event-emitter-stream.js'; -import { TestDataGenerator } from '../utils/test-data-generator.js'; import { TestStores } from '../test-stores.js'; import sinon from 'sinon'; @@ -13,12 +12,17 @@ import chai, { expect } from 'chai'; chai.use(chaiAsPromised); -describe('EventStreamEmitter', () => { +describe('EventEmitterStream', () => { + // saving the original `console.error` function to re-assign after tests complete + const originalConsoleErrorFunction = console.error; let eventStream: EventEmitterStream; let messageStore: MessageStore; before(() => { ({ messageStore } = TestStores.get()); + + // do not print the console error statements from the emitter error + console.error = (_):void => { }; }); beforeEach(async () => { @@ -26,6 +30,7 @@ describe('EventStreamEmitter', () => { }); after(async () => { + console.error = originalConsoleErrorFunction; // Clean up after each test by closing and clearing the event stream await messageStore.close(); await eventStream.close(); @@ -46,16 +51,11 @@ describe('EventStreamEmitter', () => { expect(emitter.listenerCount('events')).to.equal(0); }); - xit('logs message when the emitter experiences an error', async () => { + it('logs message when the emitter experiences an error', async () => { + const eventErrorSpy = sinon.spy(EventEmitterStream.prototype as any, 'eventError'); const emitter = new EventEmitter({ captureRejections: true }); - sinon.stub(emitter, 'emit').rejects('unknown error'); eventStream = new EventEmitterStream({ emitter }); - await eventStream.open(); - - const eventErrorSpy = sinon.spy(eventStream as any, 'eventError'); - await eventStream.subscribe('id', () => {}); - const { message } = await TestDataGenerator.generateRecordsWrite(); - eventStream.emit('alice', message, {}); + emitter.emit('error', new Error('random error')); expect(eventErrorSpy.callCount).to.equal(1); }); }); From 95cb72a0bd34b88bca31e1785588486defbcc873 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Tue, 16 Jan 2024 16:34:05 -0500 Subject: [PATCH 27/44] EventStream is optional --- src/core/dwn-error.ts | 2 +- src/dwn.ts | 20 +-- src/handlers/events-subscribe.ts | 9 +- src/handlers/permissions-grant.ts | 6 +- src/handlers/permissions-request.ts | 6 +- src/handlers/permissions-revoke.ts | 6 +- src/handlers/protocols-configure.ts | 7 +- src/handlers/records-delete.ts | 7 +- src/handlers/records-write.ts | 6 +- src/interfaces/events-subscribe.ts | 2 +- tests/handlers/events-subscribe.spec.ts | 217 +++++++++++++++--------- 11 files changed, 180 insertions(+), 108 deletions(-) diff --git a/src/core/dwn-error.ts b/src/core/dwn-error.ts index d06ab77b0..bbe2e9440 100644 --- a/src/core/dwn-error.ts +++ b/src/core/dwn-error.ts @@ -25,7 +25,7 @@ export enum DwnErrorCode { DidNotValid = 'DidNotValid', DidResolutionFailed = 'DidResolutionFailed', Ed25519InvalidJwk = 'Ed25519InvalidJwk', - EventStreamSubscriptionNotSupported = 'EventStreamSubscriptionNotSupported', + EventsSubscribeEventStreamUnimplemented = 'EventsSubscribeEventStreamUnimplemented', GeneralJwsVerifierGetPublicKeyNotFound = 'GeneralJwsVerifierGetPublicKeyNotFound', GeneralJwsVerifierInvalidSignature = 'GeneralJwsVerifierInvalidSignature', GrantAuthorizationGrantExpired = 'GrantAuthorizationGrantExpired', diff --git a/src/dwn.ts b/src/dwn.ts index ad86cc38f..6a96eda71 100644 --- a/src/dwn.ts +++ b/src/dwn.ts @@ -14,7 +14,6 @@ import type { RecordsDeleteMessage, RecordsQueryMessage, RecordsQueryReply, Reco import { AllowAllTenantGate } from './core/tenant-gate.js'; import { DidResolver } from './did/did-resolver.js'; -import { EventEmitterStream } from './event-log/event-emitter-stream.js'; import { EventsGetHandler } from './handlers/events-get.js'; import { EventsQueryHandler } from './handlers/events-query.js'; import { EventsSubscribeHandler } from './handlers/events-subscribe.js'; @@ -38,16 +37,16 @@ export class Dwn { private messageStore: MessageStore; private dataStore: DataStore; private eventLog: EventLog; - private eventStream: EventStream; private tenantGate: TenantGate; + private eventStream?: EventStream; private constructor(config: DwnConfig) { this.didResolver = config.didResolver!; this.tenantGate = config.tenantGate!; - this.eventStream = config.eventStream!; this.messageStore = config.messageStore; this.dataStore = config.dataStore; this.eventLog = config.eventLog; + this.eventStream = config.eventStream; this.methodHandlers = { [DwnInterfaceName.Events + DwnMethodName.Get]: new EventsGetHandler( @@ -129,7 +128,6 @@ export class Dwn { public static async create(config: DwnConfig): Promise { config.didResolver ??= new DidResolver(); config.tenantGate ??= new AllowAllTenantGate(); - config.eventStream ??= new EventEmitterStream(); const dwn = new Dwn(config); await dwn.open(); @@ -141,14 +139,14 @@ export class Dwn { await this.messageStore.open(); await this.dataStore.open(); await this.eventLog.open(); - await this.eventStream.open(); + await this.eventStream?.open(); } public async close(): Promise { - this.eventStream.close(); - this.messageStore.close(); - this.dataStore.close(); - this.eventLog.close(); + await this.eventStream?.close(); + await this.messageStore.close(); + await this.dataStore.close(); + await this.eventLog.close(); } /** @@ -240,9 +238,11 @@ export class Dwn { */ export type DwnConfig = { didResolver?: DidResolver; - eventStream?: EventStream; tenantGate?: TenantGate; + // event stream is optional if a DWN does not wish to provide subscription services. + eventStream?: EventStream; + messageStore: MessageStore; dataStore: DataStore; eventLog: EventLog; diff --git a/src/handlers/events-subscribe.ts b/src/handlers/events-subscribe.ts index ca826e8b5..a59d99ce1 100644 --- a/src/handlers/events-subscribe.ts +++ b/src/handlers/events-subscribe.ts @@ -10,11 +10,12 @@ import { FilterUtility } from '../utils/filter.js'; import { Message } from '../core/message.js'; import { messageReplyFromError } from '../core/message-reply.js'; import { authenticate, authorizeOwner } from '../core/auth.js'; +import { DwnError, DwnErrorCode } from '../index.js'; export class EventsSubscribeHandler implements MethodHandler { constructor( private didResolver: DidResolver, - private eventStream: EventStream + private eventStream?: EventStream ) {} public async handle({ @@ -26,6 +27,12 @@ export class EventsSubscribeHandler implements MethodHandler { message: EventsSubscribeMessage; handler: GenericMessageSubscriptionHandler; }): Promise { + if (this.eventStream === undefined) { + return messageReplyFromError(new DwnError( + DwnErrorCode.EventsSubscribeEventStreamUnimplemented, + 'Subscriptions are not supported' + ), 501); + } let subscriptionRequest: EventsSubscribe; try { diff --git a/src/handlers/permissions-grant.ts b/src/handlers/permissions-grant.ts index 59394010b..69db855fb 100644 --- a/src/handlers/permissions-grant.ts +++ b/src/handlers/permissions-grant.ts @@ -18,7 +18,7 @@ export class PermissionsGrantHandler implements MethodHandler { private didResolver: DidResolver, private messageStore: MessageStore, private eventLog: EventLog, - private eventStream: EventStream + private eventStream?: EventStream ) { } public async handle({ @@ -47,7 +47,9 @@ export class PermissionsGrantHandler implements MethodHandler { if (existingMessage === undefined) { await this.messageStore.put(tenant, message, indexes); await this.eventLog.append(tenant, messageCid, indexes); - this.eventStream.emit(tenant, message, indexes); + + // only emit if the event stream is set + this.eventStream?.emit(tenant, message, indexes); } return { diff --git a/src/handlers/permissions-request.ts b/src/handlers/permissions-request.ts index 7cb1d9468..98b9690cb 100644 --- a/src/handlers/permissions-request.ts +++ b/src/handlers/permissions-request.ts @@ -17,7 +17,7 @@ export class PermissionsRequestHandler implements MethodHandler { private didResolver: DidResolver, private messageStore: MessageStore, private eventLog: EventLog, - private eventStream: EventStream + private eventStream?: EventStream ) { } public async handle({ @@ -51,7 +51,9 @@ export class PermissionsRequestHandler implements MethodHandler { if (existingMessage === undefined) { await this.messageStore.put(tenant, message, indexes); await this.eventLog.append(tenant, messageCid, indexes); - this.eventStream.emit(tenant, message, indexes); + + // only emit if the event stream is set + this.eventStream?.emit(tenant, message, indexes); } return { diff --git a/src/handlers/permissions-revoke.ts b/src/handlers/permissions-revoke.ts index a529b5b2e..4d7be7cda 100644 --- a/src/handlers/permissions-revoke.ts +++ b/src/handlers/permissions-revoke.ts @@ -18,7 +18,7 @@ export class PermissionsRevokeHandler implements MethodHandler { private didResolver: DidResolver, private messageStore: MessageStore, private eventLog: EventLog, - private eventStream: EventStream + private eventStream?: EventStream ) { } public async handle({ @@ -95,8 +95,8 @@ export class PermissionsRevokeHandler implements MethodHandler { await this.messageStore.put(tenant, message, indexes); await this.eventLog.append(tenant, await Message.getCid(message), indexes); - // emit revoke and exercise any revocation necessary within the event stream - this.eventStream.emit(tenant, message, indexes); + // only emit if the event stream is set + this.eventStream?.emit(tenant, message, indexes); // Delete existing revokes which are all newer than the incoming message const removedRevokeCids: string[] = []; diff --git a/src/handlers/protocols-configure.ts b/src/handlers/protocols-configure.ts index 573cda1ec..e569e3a69 100644 --- a/src/handlers/protocols-configure.ts +++ b/src/handlers/protocols-configure.ts @@ -18,7 +18,7 @@ export class ProtocolsConfigureHandler implements MethodHandler { private didResolver: DidResolver, private messageStore: MessageStore, private eventLog: EventLog, - private eventStream: EventStream + private eventStream?: EventStream ) { } public async handle({ @@ -26,7 +26,6 @@ export class ProtocolsConfigureHandler implements MethodHandler { message, dataStream: _dataStream }: {tenant: string, message: ProtocolsConfigureMessage, dataStream: _Readable.Readable}): Promise { - let protocolsConfigure: ProtocolsConfigure; try { protocolsConfigure = await ProtocolsConfigure.parse(message); @@ -66,7 +65,9 @@ export class ProtocolsConfigureHandler implements MethodHandler { await this.messageStore.put(tenant, message, indexes); const messageCid = await Message.getCid(message); await this.eventLog.append(tenant, messageCid, indexes); - this.eventStream.emit(tenant, message, indexes); + + // only emit if the event stream is set + this.eventStream?.emit(tenant, message, indexes); messageReply = { status: { code: 202, detail: 'Accepted' } diff --git a/src/handlers/records-delete.ts b/src/handlers/records-delete.ts index b047cb863..b609707b4 100644 --- a/src/handlers/records-delete.ts +++ b/src/handlers/records-delete.ts @@ -24,14 +24,13 @@ export class RecordsDeleteHandler implements MethodHandler { private messageStore: MessageStore, private dataStore: DataStore, private eventLog: EventLog, - private eventStream: EventStream + private eventStream?: EventStream ) { } public async handle({ tenant, message }: { tenant: string, message: RecordsDeleteMessage}): Promise { - let recordsDelete: RecordsDelete; try { recordsDelete = await RecordsDelete.parse(message); @@ -96,7 +95,9 @@ export class RecordsDeleteHandler implements MethodHandler { const messageCid = await Message.getCid(message); await this.messageStore.put(tenant, message, indexes); await this.eventLog.append(tenant, messageCid, indexes); - this.eventStream.emit(tenant, message, indexes); + + // only emit if the event stream is set + this.eventStream?.emit(tenant, message, indexes); // delete all existing messages that are not newest, except for the initial write await StorageController.deleteAllOlderMessagesButKeepInitialWrite( diff --git a/src/handlers/records-write.ts b/src/handlers/records-write.ts index b852b3fd4..e84e842ec 100644 --- a/src/handlers/records-write.ts +++ b/src/handlers/records-write.ts @@ -31,7 +31,7 @@ export class RecordsWriteHandler implements MethodHandler { private messageStore: MessageStore, private dataStore: DataStore, private eventLog: EventLog, - private eventStream: EventStream + private eventStream?: EventStream ) { } public async handle({ @@ -130,7 +130,9 @@ export class RecordsWriteHandler implements MethodHandler { const indexes = await recordsWrite.constructIndexes(isLatestBaseState); await this.messageStore.put(tenant, messageWithOptionalEncodedData, indexes); await this.eventLog.append(tenant, await Message.getCid(message), indexes); - this.eventStream.emit(tenant, message, indexes); + + // only emit if the event stream is set + this.eventStream?.emit(tenant, message, indexes); } catch (error) { const e = error as any; if (e.code === DwnErrorCode.RecordsWriteMissingEncodedDataInPrevious || diff --git a/src/interfaces/events-subscribe.ts b/src/interfaces/events-subscribe.ts index a9b365703..abbea3bbc 100644 --- a/src/interfaces/events-subscribe.ts +++ b/src/interfaces/events-subscribe.ts @@ -10,8 +10,8 @@ import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.j export type EventsSubscribeOptions = { + signer: Signer; messageTimestamp?: string; - signer?: Signer; filters?: EventsFilter[] permissionsGrantId?: string; }; diff --git a/tests/handlers/events-subscribe.spec.ts b/tests/handlers/events-subscribe.spec.ts index 6686741c7..5d947ee90 100644 --- a/tests/handlers/events-subscribe.spec.ts +++ b/tests/handlers/events-subscribe.spec.ts @@ -4,6 +4,7 @@ import type { DataStore, EventLog, GenericMessage, MessageStore } from '../../sr import { DidKeyResolver } from '../../src/did/did-key-resolver.js'; import { DidResolver } from '../../src/did/did-resolver.js'; import { Dwn } from '../../src/dwn.js'; +import { DwnErrorCode } from '../../src/core/dwn-error.js'; import { EventsSubscribe } from '../../src/interfaces/events-subscribe.js'; import { Jws } from '../../src/utils/jws.js'; import { Message } from '../../src/core/message.js'; @@ -20,102 +21,158 @@ chai.use(chaiAsPromised); export function testEventsSubscribeHandler(): void { describe('EventsSubscribe.handle()', () => { - let didResolver: DidResolver; - let messageStore: MessageStore; - let dataStore: DataStore; - let eventLog: EventLog; - let eventStream: EventStream; - let dwn: Dwn; - - // important to follow the `before` and `after` pattern to initialize and clean the stores in tests - // so that different test suites can reuse the same backend store for testing - before(async () => { - didResolver = new DidResolver([new DidKeyResolver()]); - - const stores = TestStores.get(); - messageStore = stores.messageStore; - dataStore = stores.dataStore; - eventLog = stores.eventLog; - eventStream = TestEventStream.get(); - - dwn = await Dwn.create({ - didResolver, - messageStore, - dataStore, - eventLog, - eventStream, + describe('EventStream disabled',() => { + let didResolver: DidResolver; + let messageStore: MessageStore; + let dataStore: DataStore; + let eventLog: EventLog; + let dwn: Dwn; + + // important to follow the `before` and `after` pattern to initialize and clean the stores in tests + // so that different test suites can reuse the same backend store for testing + before(async () => { + didResolver = new DidResolver([new DidKeyResolver()]); + + const stores = TestStores.get(); + messageStore = stores.messageStore; + dataStore = stores.dataStore; + eventLog = stores.eventLog; + + dwn = await Dwn.create({ + didResolver, + messageStore, + dataStore, + eventLog, + }); + }); - }); - beforeEach(async () => { - sinon.restore(); // wipe all previous stubs/spies/mocks/fakes + beforeEach(async () => { + sinon.restore(); // wipe all previous stubs/spies/mocks/fakes - // clean up before each test rather than after so that a test does not depend on other tests to do the clean up - await messageStore.clear(); - await dataStore.clear(); - await eventLog.clear(); - }); + // clean up before each test rather than after so that a test does not depend on other tests to do the clean up + await messageStore.clear(); + await dataStore.clear(); + await eventLog.clear(); + }); - after(async () => { - await dwn.close(); - }); + after(async () => { + await dwn.close(); + }); - it('should allow tenant to subscribe their own event stream', async () => { - const alice = await DidKeyResolver.generate(); + it('should respond with a 501 if subscriptions are not supported', async () => { + await dwn.close(); // close the original dwn instance + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); // leave out eventStream - // set up a promise to read later that captures the emitted messageCid - let handler; - const messageSubscriptionPromise: Promise = new Promise((resolve) => { - handler = async (message: GenericMessage):Promise => { - const messageCid = await Message.getCid(message); - resolve(messageCid); - }; + const alice = await DidKeyResolver.generate(); + // attempt to subscribe + const { message } = await EventsSubscribe.create({ signer: Jws.createSigner(alice) }); + const subscriptionMessageReply = await dwn.processMessage(alice.did, message, { handler: (_) => {} }); + expect(subscriptionMessageReply.status.code).to.equal(501, subscriptionMessageReply.status.detail); + expect(subscriptionMessageReply.status.detail).to.include(DwnErrorCode.EventsSubscribeEventStreamUnimplemented); }); - // testing Subscription Request - const subscriptionRequest = await EventsSubscribe.create({ - signer: Jws.createSigner(alice), - }); - const subscriptionReply = await dwn.processMessage(alice.did, subscriptionRequest.message, { handler }); - expect(subscriptionReply.status.code).to.equal(200, subscriptionReply.status.detail); - expect(subscriptionReply.subscription).to.not.be.undefined; - - const messageWrite = await TestDataGenerator.generateRecordsWrite({ author: alice }); - const writeReply = await dwn.processMessage(alice.did, messageWrite.message, { dataStream: messageWrite.dataStream }); - expect(writeReply.status.code).to.equal(202); - const messageCid = await Message.getCid(messageWrite.message); - - // control: ensure that the event exists - const events = await eventLog.getEvents(alice.did); - expect(events.length).to.equal(1); - expect(events[0]).to.equal(messageCid); - - // await the event - await expect(messageSubscriptionPromise).to.eventually.equal(messageCid); }); - it('should not allow non-tenant to subscribe to an event stream they are not authorized for', async () => { - const alice = await DidKeyResolver.generate(); - const bob = await DidKeyResolver.generate(); + describe('EventStream enabled', () => { + let didResolver: DidResolver; + let messageStore: MessageStore; + let dataStore: DataStore; + let eventLog: EventLog; + let eventStream: EventStream; + let dwn: Dwn; + + // important to follow the `before` and `after` pattern to initialize and clean the stores in tests + // so that different test suites can reuse the same backend store for testing + before(async () => { + didResolver = new DidResolver([new DidKeyResolver()]); + + const stores = TestStores.get(); + messageStore = stores.messageStore; + dataStore = stores.dataStore; + eventLog = stores.eventLog; + eventStream = TestEventStream.get(); + + dwn = await Dwn.create({ + didResolver, + messageStore, + dataStore, + eventLog, + eventStream, + }); - // test anonymous request - const anonymousSubscription = await TestDataGenerator.generateEventsSubscribe(); - delete anonymousSubscription.message.authorization; // delete the authorization + }); - const anonymousReply = await dwn.processMessage(alice.did, anonymousSubscription.message); - expect(anonymousReply.status.code).to.equal(400); - expect(anonymousReply.status.detail).to.include(`EventsSubscribe: must have required property 'authorization'`); - expect(anonymousReply.subscription).to.be.undefined; + beforeEach(async () => { + sinon.restore(); // wipe all previous stubs/spies/mocks/fakes - // testing Subscription Request - const subscriptionRequest = await EventsSubscribe.create({ - signer: Jws.createSigner(bob), + // clean up before each test rather than after so that a test does not depend on other tests to do the clean up + await messageStore.clear(); + await dataStore.clear(); + await eventLog.clear(); }); - const subscriptionReply = await dwn.processMessage(alice.did, subscriptionRequest.message); - expect(subscriptionReply.status.code).to.equal(401); - expect(subscriptionReply.subscription).to.be.undefined; + after(async () => { + await dwn.close(); + }); + + it('should allow tenant to subscribe their own event stream', async () => { + const alice = await DidKeyResolver.generate(); + + // set up a promise to read later that captures the emitted messageCid + let handler; + const messageSubscriptionPromise: Promise = new Promise((resolve) => { + handler = async (message: GenericMessage):Promise => { + const messageCid = await Message.getCid(message); + resolve(messageCid); + }; + }); + + // testing Subscription Request + const subscriptionRequest = await EventsSubscribe.create({ + signer: Jws.createSigner(alice), + }); + const subscriptionReply = await dwn.processMessage(alice.did, subscriptionRequest.message, { handler }); + expect(subscriptionReply.status.code).to.equal(200, subscriptionReply.status.detail); + expect(subscriptionReply.subscription).to.not.be.undefined; + + const messageWrite = await TestDataGenerator.generateRecordsWrite({ author: alice }); + const writeReply = await dwn.processMessage(alice.did, messageWrite.message, { dataStream: messageWrite.dataStream }); + expect(writeReply.status.code).to.equal(202); + const messageCid = await Message.getCid(messageWrite.message); + + // control: ensure that the event exists + const events = await eventLog.getEvents(alice.did); + expect(events.length).to.equal(1); + expect(events[0]).to.equal(messageCid); + + // await the event + await expect(messageSubscriptionPromise).to.eventually.equal(messageCid); + }); + + it('should not allow non-tenant to subscribe to an event stream they are not authorized for', async () => { + const alice = await DidKeyResolver.generate(); + const bob = await DidKeyResolver.generate(); + + // test anonymous request + const anonymousSubscription = await TestDataGenerator.generateEventsSubscribe(); + delete anonymousSubscription.message.authorization; // delete the authorization + + const anonymousReply = await dwn.processMessage(alice.did, anonymousSubscription.message); + expect(anonymousReply.status.code).to.equal(400); + expect(anonymousReply.status.detail).to.include(`EventsSubscribe: must have required property 'authorization'`); + expect(anonymousReply.subscription).to.be.undefined; + + // testing Subscription Request + const subscriptionRequest = await EventsSubscribe.create({ + signer: Jws.createSigner(bob), + }); + + const subscriptionReply = await dwn.processMessage(alice.did, subscriptionRequest.message); + expect(subscriptionReply.status.code).to.equal(401); + expect(subscriptionReply.subscription).to.be.undefined; + }); }); }); } \ No newline at end of file From cbeca319816b72892330d276c556f93841e7f168 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Tue, 16 Jan 2024 17:01:41 -0500 Subject: [PATCH 28/44] simplify events subscribe handler and add test coverage --- src/handlers/events-subscribe.ts | 33 +++++++++++-------------- src/interfaces/events-subscribe.ts | 20 +++++++-------- src/types/events-types.ts | 2 +- tests/handlers/events-subscribe.spec.ts | 17 ++++++++++++- 4 files changed, 40 insertions(+), 32 deletions(-) diff --git a/src/handlers/events-subscribe.ts b/src/handlers/events-subscribe.ts index a59d99ce1..17be4e69d 100644 --- a/src/handlers/events-subscribe.ts +++ b/src/handlers/events-subscribe.ts @@ -10,7 +10,7 @@ import { FilterUtility } from '../utils/filter.js'; import { Message } from '../core/message.js'; import { messageReplyFromError } from '../core/message-reply.js'; import { authenticate, authorizeOwner } from '../core/auth.js'; -import { DwnError, DwnErrorCode } from '../index.js'; +import { DwnError, DwnErrorCode } from '../core/dwn-error.js'; export class EventsSubscribeHandler implements MethodHandler { constructor( @@ -48,25 +48,20 @@ export class EventsSubscribeHandler implements MethodHandler { return messageReplyFromError(error, 401); } - try { - const { filters } = message.descriptor; - const eventsFilters = Events.convertFilters(filters); - const messageCid = await Message.getCid(message); - const listener: EventListener = (eventTenant, eventMessage, eventIndexes):void => { - if (tenant === eventTenant && FilterUtility.matchAnyFilter(eventIndexes, eventsFilters)) { - handler(eventMessage); - } - }; - const subscription = await this.eventStream.subscribe(messageCid, listener); + const { filters } = message.descriptor; + const eventsFilters = Events.convertFilters(filters); + const messageCid = await Message.getCid(message); - const messageReply: EventsSubscribeReply = { - status: { code: 200, detail: 'OK' }, - subscription, - }; + const listener: EventListener = (eventTenant, eventMessage, eventIndexes):void => { + if (tenant === eventTenant && FilterUtility.matchAnyFilter(eventIndexes, eventsFilters)) { + handler(eventMessage); + } + }; + const subscription = await this.eventStream.subscribe(messageCid, listener); - return messageReply; - } catch (error) { - return messageReplyFromError(error, 400); - } + return { + status: { code: 200, detail: 'OK' }, + subscription, + }; } } \ No newline at end of file diff --git a/src/interfaces/events-subscribe.ts b/src/interfaces/events-subscribe.ts index abbea3bbc..a222a1fd1 100644 --- a/src/interfaces/events-subscribe.ts +++ b/src/interfaces/events-subscribe.ts @@ -18,9 +18,9 @@ export type EventsSubscribeOptions = { export class EventsSubscribe extends AbstractMessage { public static async parse(message: EventsSubscribeMessage): Promise { - if (message.authorization !== undefined) { - await Message.validateSignatureStructure(message.authorization.signature, message.descriptor); - } + Message.validateJsonSchema(message); + await Message.validateSignatureStructure(message.authorization.signature, message.descriptor); + Time.validateTimestamp(message.descriptor.messageTimestamp); return new EventsSubscribe(message); } @@ -46,14 +46,12 @@ export class EventsSubscribe extends AbstractMessage { removeUndefinedProperties(descriptor); // only generate the `authorization` property if signature input is given - let authorization = undefined; - if (options.signer !== undefined) { - authorization = await Message.createAuthorization({ - descriptor, - permissionsGrantId, - signer: options.signer - }); - } + const authorization = await Message.createAuthorization({ + descriptor, + permissionsGrantId, + signer: options.signer + }); + const message: EventsSubscribeMessage = { descriptor, authorization }; Message.validateJsonSchema(message); return new EventsSubscribe(message); diff --git a/src/types/events-types.ts b/src/types/events-types.ts index c58a92efb..d0b71abd1 100644 --- a/src/types/events-types.ts +++ b/src/types/events-types.ts @@ -48,7 +48,7 @@ export type EventsSubscribeMessageOptions = { }; export type EventsSubscribeMessage = { - authorization?: AuthorizationModel; + authorization: AuthorizationModel; descriptor: EventsSubscribeDescriptor; }; diff --git a/tests/handlers/events-subscribe.spec.ts b/tests/handlers/events-subscribe.spec.ts index 5d947ee90..1444f9490 100644 --- a/tests/handlers/events-subscribe.spec.ts +++ b/tests/handlers/events-subscribe.spec.ts @@ -16,6 +16,7 @@ import sinon from 'sinon'; import chai, { expect } from 'chai'; import chaiAsPromised from 'chai-as-promised'; +import { EventsSubscribeHandler } from '../../src/handlers/events-subscribe.js'; chai.use(chaiAsPromised); export function testEventsSubscribeHandler(): void { @@ -117,6 +118,20 @@ export function testEventsSubscribeHandler(): void { await dwn.close(); }); + it('returns a 400 if message is invalid', async () => { + const alice = await DidKeyResolver.generate(); + const { message } = await TestDataGenerator.generateEventsSubscribe({ author: alice }); + + // add an invalid property to the descriptor + (message['descriptor'] as any)['invalid'] = 'invalid'; + + const eventsSubscribeHandler = new EventsSubscribeHandler(didResolver, eventStream); + + const reply = await eventsSubscribeHandler.handle({ tenant: alice.did, message, handler: (_) => {} }); + expect(reply.status.code).to.equal(400); + }); + + it('should allow tenant to subscribe their own event stream', async () => { const alice = await DidKeyResolver.generate(); @@ -157,7 +172,7 @@ export function testEventsSubscribeHandler(): void { // test anonymous request const anonymousSubscription = await TestDataGenerator.generateEventsSubscribe(); - delete anonymousSubscription.message.authorization; // delete the authorization + delete (anonymousSubscription.message as any).authorization; const anonymousReply = await dwn.processMessage(alice.did, anonymousSubscription.message); expect(anonymousReply.status.code).to.equal(400); From a5a6cac5e63d06655e4109fe02e61fb4c5f6340f Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Tue, 16 Jan 2024 17:09:40 -0500 Subject: [PATCH 29/44] clean up interface and increase coverage --- src/interfaces/events-subscribe.ts | 5 ----- tests/interfaces/events-subscribe.spec.ts | 8 +++++--- tests/utils/test-data-generator.ts | 4 +++- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/interfaces/events-subscribe.ts b/src/interfaces/events-subscribe.ts index a222a1fd1..177a42b76 100644 --- a/src/interfaces/events-subscribe.ts +++ b/src/interfaces/events-subscribe.ts @@ -1,4 +1,3 @@ -import type { GenericMessage } from '../types/message-types.js'; import type { Signer } from '../types/signer.js'; import type { EventsFilter, EventsSubscribeDescriptor, EventsSubscribeMessage } from '../types/events-types.js'; @@ -56,8 +55,4 @@ export class EventsSubscribe extends AbstractMessage { Message.validateJsonSchema(message); return new EventsSubscribe(message); } - - public static isEventsSubscribeMessage(message: GenericMessage): message is EventsSubscribeMessage { - return message.descriptor.interface === DwnInterfaceName.Events && message.descriptor.method === DwnMethodName.Subscribe; - } } diff --git a/tests/interfaces/events-subscribe.spec.ts b/tests/interfaces/events-subscribe.spec.ts index a3e422551..e971aa6df 100644 --- a/tests/interfaces/events-subscribe.spec.ts +++ b/tests/interfaces/events-subscribe.spec.ts @@ -1,5 +1,5 @@ import { EventsSubscribe } from '../../src/interfaces/events-subscribe.js'; -import { DidKeyResolver, DwnInterfaceName, DwnMethodName, Jws } from '../../src/index.js'; +import { DidKeyResolver, DwnInterfaceName, DwnMethodName, Jws, Time } from '../../src/index.js'; import { expect } from 'chai'; @@ -7,14 +7,16 @@ describe('EventsSubscribe', () => { describe('create()', () => { it('should be able to create and authorize EventsSubscribe', async () => { const alice = await DidKeyResolver.generate(); + const timestamp = Time.getCurrentTimestamp(); const { message } = await EventsSubscribe.create({ - signer: Jws.createSigner(alice) + signer : Jws.createSigner(alice), + messageTimestamp : timestamp, }); expect(message.descriptor.interface).to.eql(DwnInterfaceName.Events); expect(message.descriptor.method).to.eql(DwnMethodName.Subscribe); expect(message.authorization).to.exist; + expect(message.descriptor.messageTimestamp).to.equal(timestamp); }); - }); }); diff --git a/tests/utils/test-data-generator.ts b/tests/utils/test-data-generator.ts index ac8c3cbd4..146877f48 100644 --- a/tests/utils/test-data-generator.ts +++ b/tests/utils/test-data-generator.ts @@ -252,6 +252,7 @@ export type GenerateEventsQueryOutput = { export type GenerateEventsSubscribeInput = { author: Persona; filters?: EventsFilter[]; + messageTimestamp?: string; }; export type GenerateEventsSubscribeOutput = { @@ -787,7 +788,8 @@ export class TestDataGenerator { const signer = Jws.createSigner(author); const options: EventsSubscribeOptions = { - filters: input?.filters, + filters : input?.filters, + messageTimestamp : input?.messageTimestamp, signer, }; removeUndefinedProperties(options); From 12c067e628b7da14062223dfdfbd817466545b23 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Tue, 16 Jan 2024 18:47:21 -0500 Subject: [PATCH 30/44] add more filter tests --- src/handlers/events-subscribe.ts | 2 + tests/scenarios/subscriptions.spec.ts | 501 +++++++++++++++----------- 2 files changed, 296 insertions(+), 207 deletions(-) diff --git a/src/handlers/events-subscribe.ts b/src/handlers/events-subscribe.ts index 17be4e69d..e7b06b213 100644 --- a/src/handlers/events-subscribe.ts +++ b/src/handlers/events-subscribe.ts @@ -53,10 +53,12 @@ export class EventsSubscribeHandler implements MethodHandler { const messageCid = await Message.getCid(message); const listener: EventListener = (eventTenant, eventMessage, eventIndexes):void => { + console.log('got from listener', eventMessage, eventIndexes, eventsFilters); if (tenant === eventTenant && FilterUtility.matchAnyFilter(eventIndexes, eventsFilters)) { handler(eventMessage); } }; + const subscription = await this.eventStream.subscribe(messageCid, listener); return { diff --git a/tests/scenarios/subscriptions.spec.ts b/tests/scenarios/subscriptions.spec.ts index 10af7f94c..9a29602fa 100644 --- a/tests/scenarios/subscriptions.spec.ts +++ b/tests/scenarios/subscriptions.spec.ts @@ -18,233 +18,320 @@ import { expect } from 'chai'; export function testSubscriptionScenarios(): void { describe('subscriptions', () => { - describe('without reauthorization', () => { - let didResolver: DidResolver; - let messageStore: MessageStore; - let dataStore: DataStore; - let eventLog: EventLog; - let eventStream: EventStream; - let dwn: Dwn; - - // important to follow the `before` and `after` pattern to initialize and clean the stores in tests - // so that different test suites can reuse the same backend store for testing - before(async () => { - didResolver = new DidResolver([new DidKeyResolver()]); - - const stores = TestStores.get(); - messageStore = stores.messageStore; - dataStore = stores.dataStore; - eventLog = stores.eventLog; - eventStream = TestEventStream.get(); - - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); - }); + let didResolver: DidResolver; + let messageStore: MessageStore; + let dataStore: DataStore; + let eventLog: EventLog; + let eventStream: EventStream; + let dwn: Dwn; + + // important to follow the `before` and `after` pattern to initialize and clean the stores in tests + // so that different test suites can reuse the same backend store for testing + before(async () => { + didResolver = new DidResolver([new DidKeyResolver()]); + + const stores = TestStores.get(); + messageStore = stores.messageStore; + dataStore = stores.dataStore; + eventLog = stores.eventLog; + eventStream = TestEventStream.get(); + + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); + }); + + beforeEach(async () => { + // clean up before each test rather than after so that a test does not depend on other tests to do the clean up + await messageStore.clear(); + await dataStore.clear(); + await eventLog.clear(); + }); + + after(async () => { + await dwn.close(); + }); - beforeEach(async () => { - // clean up before each test rather than after so that a test does not depend on other tests to do the clean up - await messageStore.clear(); - await dataStore.clear(); - await eventLog.clear(); + describe('events subscribe', () => { + it('all events', async () => { + const alice = await DidKeyResolver.generate(); + + // create a handler that adds the messageCid of each message to an array. + const messageCids: string[] = []; + const handler = async (message: GenericMessage): Promise => { + const messageCid = await Message.getCid(message); + messageCids.push(messageCid); + }; + + // subscribe to all messages + const eventsSubscription = await TestDataGenerator.generateEventsSubscribe({ author: alice }); + const eventsSubscriptionReply = await dwn.processMessage(alice.did, eventsSubscription.message, { handler }); + expect(eventsSubscriptionReply.status.code).to.equal(200); + expect(eventsSubscriptionReply.subscription?.id).to.equal(await Message.getCid(eventsSubscription.message)); + + // generate various messages + const write1 = await TestDataGenerator.generateRecordsWrite({ author: alice }); + const write1MessageCid = await Message.getCid(write1.message); + const write1Reply = await dwn.processMessage(alice.did, write1.message, { dataStream: write1.dataStream }); + expect(write1Reply.status.code).to.equal(202); + + const grant1 = await TestDataGenerator.generatePermissionsGrant({ author: alice }); + const grant1MessageCid = await Message.getCid(grant1.message); + const grant1Reply = await dwn.processMessage(alice.did, grant1.message); + expect(grant1Reply.status.code).to.equal(202); + + const protocol1 = await TestDataGenerator.generateProtocolsConfigure({ author: alice }); + const protocol1MessageCid = await Message.getCid(protocol1.message); + const protocol1Reply = await dwn.processMessage(alice.did, protocol1.message); + expect(protocol1Reply.status.code).to.equal(202); + + const deleteWrite1 = await TestDataGenerator.generateRecordsDelete({ author: alice, recordId: write1.message.recordId }); + const delete1MessageCid = await Message.getCid(deleteWrite1.message); + const deleteWrite1Reply = await dwn.processMessage(alice.did, deleteWrite1.message); + expect(deleteWrite1Reply.status.code).to.equal(202); + + // unregister the handler + await eventsSubscriptionReply.subscription?.close(); + + // create a message after + const write2 = await TestDataGenerator.generateRecordsWrite({ author: alice }); + const write2Reply = await dwn.processMessage(alice.did, write2.message, { dataStream: write2.dataStream }); + expect(write2Reply.status.code).to.equal(202); + + await Time.minimalSleep(); + + // test the messageCids array for the appropriate messages + expect(messageCids.length).to.equal(4); + expect(messageCids).to.eql([ write1MessageCid, grant1MessageCid, protocol1MessageCid, delete1MessageCid ]); }); - after(async () => { - await dwn.close(); + it('filters by protocol', async () => { + const alice = await DidKeyResolver.generate(); + + // create a proto1 + const protoConf1 = await TestDataGenerator.generateProtocolsConfigure({ + author : alice, + protocolDefinition : { ...freeForAll, protocol: 'proto1' } + }); + + const postProperties = { + protocolPath : 'post', + schema : freeForAll.types.post.schema, + dataFormat : freeForAll.types.post.dataFormats[0], + }; + + // create a proto1 + const proto1 = protoConf1.message.descriptor.definition.protocol; + const protoConf1Response = await dwn.processMessage(alice.did, protoConf1.message); + expect(protoConf1Response.status.code).equals(202); + + // create a proto2 + const protoConf2 = await TestDataGenerator.generateProtocolsConfigure({ + author : alice, + protocolDefinition : { ...freeForAll, protocol: 'proto2' } + }); + const proto2 = protoConf2.message.descriptor.definition.protocol; + const protoConf2Response = await dwn.processMessage(alice.did, protoConf2.message); + expect(protoConf2Response.status.code).equals(202); + + // we will add messageCids to these arrays as they are received by their handler to check against later + const proto1Messages:string[] = []; + const proto2Messages:string[] = []; + + // we add a handler to the subscription and add the messageCid to the appropriate array + const proto1Handler = async (message:GenericMessage):Promise => { + const messageCid = await Message.getCid(message); + proto1Messages.push(messageCid); + }; + + // subscribe to proto1 messages + const proto1Subscription = await TestDataGenerator.generateEventsSubscribe({ author: alice, filters: [{ protocol: proto1 }] }); + const proto1SubscriptionReply = await dwn.processMessage(alice.did, proto1Subscription.message, { handler: proto1Handler }); + expect(proto1SubscriptionReply.status.code).to.equal(200); + expect(proto1SubscriptionReply.subscription?.id).to.equal(await Message.getCid(proto1Subscription.message)); + + // we add a handler to the subscription and add the messageCid to the appropriate array + const proto2Handler = async (message:GenericMessage):Promise => { + const messageCid = await Message.getCid(message); + proto2Messages.push(messageCid); + }; + + // subscribe to proto2 messages + const proto2Subscription = await TestDataGenerator.generateEventsSubscribe({ author: alice, filters: [{ protocol: proto2 }] }); + const proto2SubscriptionReply = await dwn.processMessage(alice.did, proto2Subscription.message, { handler: proto2Handler }); + expect(proto2SubscriptionReply.status.code).to.equal(200); + expect(proto2SubscriptionReply.subscription?.id).to.equal(await Message.getCid(proto2Subscription.message)); + + // create some random record, will not show up in records subscription + const write1Random = await TestDataGenerator.generateRecordsWrite({ author: alice }); + const write1RandomResponse = await dwn.processMessage(alice.did, write1Random.message, { dataStream: write1Random.dataStream }); + expect(write1RandomResponse.status.code).to.equal(202); + + // create a record for proto1 + const write1proto1 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto1, ...postProperties }); + const write1Response = await dwn.processMessage(alice.did, write1proto1.message, { dataStream: write1proto1.dataStream }); + expect(write1Response.status.code).equals(202); + + // create a record for proto2 + const write1proto2 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto2, ...postProperties }); + const write1Proto2Response = await dwn.processMessage(alice.did, write1proto2.message, { dataStream: write1proto2.dataStream }); + expect(write1Proto2Response.status.code).equals(202); + + expect(proto1Messages.length).to.equal(1, 'proto1'); + expect(proto1Messages).to.include(await Message.getCid(write1proto1.message)); + expect(proto2Messages.length).to.equal(1, 'proto2'); + expect(proto2Messages).to.include(await Message.getCid(write1proto2.message)); + + // remove listener for proto1 + proto1SubscriptionReply.subscription?.close(); + + // create another record for proto1 + const write2proto1 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto1, ...postProperties }); + const write2Response = await dwn.processMessage(alice.did, write2proto1.message, { dataStream: write2proto1.dataStream }); + expect(write2Response.status.code).equals(202); + + // create another record for proto2 + const write2proto2 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto2, ...postProperties }); + const write2Proto2Response = await dwn.processMessage(alice.did, write2proto2.message, { dataStream: write2proto2.dataStream }); + expect(write2Proto2Response.status.code).equals(202); + + // proto1 messages from handler do not change. + expect(proto1Messages.length).to.equal(1, 'proto1 after subscription.off()'); + expect(proto1Messages).to.include(await Message.getCid(write1proto1.message)); + + //proto2 messages from handler have the new message. + expect(proto2Messages.length).to.equal(2, 'proto2 after subscription.off()'); + expect(proto2Messages).to.have.members([await Message.getCid(write1proto2.message), await Message.getCid(write2proto2.message)]); }); - describe('events subscribe', () => { - it('all events', async () => { - const alice = await DidKeyResolver.generate(); - - // create a handler that adds the messageCid of each message to an array. - const messageCids: string[] = []; - const handler = async (message: GenericMessage): Promise => { - const messageCid = await Message.getCid(message); - messageCids.push(messageCid); - }; - - // subscribe to all messages - const eventsSubscription = await TestDataGenerator.generateEventsSubscribe({ author: alice }); - const eventsSubscriptionReply = await dwn.processMessage(alice.did, eventsSubscription.message, { handler }); - expect(eventsSubscriptionReply.status.code).to.equal(200); - expect(eventsSubscriptionReply.subscription?.id).to.equal(await Message.getCid(eventsSubscription.message)); - - // generate various messages - const write1 = await TestDataGenerator.generateRecordsWrite({ author: alice }); - const write1MessageCid = await Message.getCid(write1.message); - const write1Reply = await dwn.processMessage(alice.did, write1.message, { dataStream: write1.dataStream }); - expect(write1Reply.status.code).to.equal(202); - - const grant1 = await TestDataGenerator.generatePermissionsGrant({ author: alice }); - const grant1MessageCid = await Message.getCid(grant1.message); - const grant1Reply = await dwn.processMessage(alice.did, grant1.message); - expect(grant1Reply.status.code).to.equal(202); - - const protocol1 = await TestDataGenerator.generateProtocolsConfigure({ author: alice }); - const protocol1MessageCid = await Message.getCid(protocol1.message); - const protocol1Reply = await dwn.processMessage(alice.did, protocol1.message); - expect(protocol1Reply.status.code).to.equal(202); - - const deleteWrite1 = await TestDataGenerator.generateRecordsDelete({ author: alice, recordId: write1.message.recordId }); - const delete1MessageCid = await Message.getCid(deleteWrite1.message); - const deleteWrite1Reply = await dwn.processMessage(alice.did, deleteWrite1.message); - expect(deleteWrite1Reply.status.code).to.equal(202); - - // unregister the handler - await eventsSubscriptionReply.subscription?.close(); - - // create a message after - const write2 = await TestDataGenerator.generateRecordsWrite({ author: alice }); - const write2Reply = await dwn.processMessage(alice.did, write2.message, { dataStream: write2.dataStream }); - expect(write2Reply.status.code).to.equal(202); - - await Time.minimalSleep(); - - // test the messageCids array for the appropriate messages - expect(messageCids.length).to.equal(4); - expect(messageCids).to.eql([ write1MessageCid, grant1MessageCid, protocol1MessageCid, delete1MessageCid ]); + it('filters by recipient', async () => { + const alice = await DidKeyResolver.generate(); + const bob = await DidKeyResolver.generate(); + const carol = await DidKeyResolver.generate(); + + const receivedMessages:string[] = []; + + const handler = async (message:GenericMessage): Promise => { + console.log('got message??', message); + const messageCid = await Message.getCid(message); + receivedMessages.push(messageCid); + }; + + const recipientSubscription = await TestDataGenerator.generateEventsSubscribe({ + author : alice, + filters : [{ recipient: alice.did }] }); + const authorQueryReply = await dwn.processMessage(alice.did, recipientSubscription.message, { handler }); + expect(authorQueryReply.status.code).to.equal(200); - it('filters by protocol', async () => { - const alice = await DidKeyResolver.generate(); - - // create a proto1 - const protoConf1 = await TestDataGenerator.generateProtocolsConfigure({ - author : alice, - protocolDefinition : { ...freeForAll, protocol: 'proto1' } - }); - - const postProperties = { - protocolPath : 'post', - schema : freeForAll.types.post.schema, - dataFormat : freeForAll.types.post.dataFormats[0], - }; - - // create a proto1 - const proto1 = protoConf1.message.descriptor.definition.protocol; - const protoConf1Response = await dwn.processMessage(alice.did, protoConf1.message); - expect(protoConf1Response.status.code).equals(202); - - // create a proto2 - const protoConf2 = await TestDataGenerator.generateProtocolsConfigure({ - author : alice, - protocolDefinition : { ...freeForAll, protocol: 'proto2' } - }); - const proto2 = protoConf2.message.descriptor.definition.protocol; - const protoConf2Response = await dwn.processMessage(alice.did, protoConf2.message); - expect(protoConf2Response.status.code).equals(202); - - // we will add messageCids to these arrays as they are received by their handler to check against later - const proto1Messages:string[] = []; - const proto2Messages:string[] = []; - - // we add a handler to the subscription and add the messageCid to the appropriate array - const proto1Handler = async (message:GenericMessage):Promise => { - const messageCid = await Message.getCid(message); - proto1Messages.push(messageCid); - }; - - // subscribe to proto1 messages - const proto1Subscription = await TestDataGenerator.generateEventsSubscribe({ author: alice, filters: [{ protocol: proto1 }] }); - const proto1SubscriptionReply = await dwn.processMessage(alice.did, proto1Subscription.message, { handler: proto1Handler }); - expect(proto1SubscriptionReply.status.code).to.equal(200); - expect(proto1SubscriptionReply.subscription?.id).to.equal(await Message.getCid(proto1Subscription.message)); - - // we add a handler to the subscription and add the messageCid to the appropriate array - const proto2Handler = async (message:GenericMessage):Promise => { - const messageCid = await Message.getCid(message); - proto2Messages.push(messageCid); - }; - - // subscribe to proto2 messages - const proto2Subscription = await TestDataGenerator.generateEventsSubscribe({ author: alice, filters: [{ protocol: proto2 }] }); - const proto2SubscriptionReply = await dwn.processMessage(alice.did, proto2Subscription.message, { handler: proto2Handler }); - expect(proto2SubscriptionReply.status.code).to.equal(200); - expect(proto2SubscriptionReply.subscription?.id).to.equal(await Message.getCid(proto2Subscription.message)); - - // create some random record, will not show up in records subscription - const write1Random = await TestDataGenerator.generateRecordsWrite({ author: alice }); - const write1RandomResponse = await dwn.processMessage(alice.did, write1Random.message, { dataStream: write1Random.dataStream }); - expect(write1RandomResponse.status.code).to.equal(202); - - // create a record for proto1 - const write1proto1 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto1, ...postProperties }); - const write1Response = await dwn.processMessage(alice.did, write1proto1.message, { dataStream: write1proto1.dataStream }); - expect(write1Response.status.code).equals(202); - - // create a record for proto2 - const write1proto2 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto2, ...postProperties }); - const write1Proto2Response = await dwn.processMessage(alice.did, write1proto2.message, { dataStream: write1proto2.dataStream }); - expect(write1Proto2Response.status.code).equals(202); - - expect(proto1Messages.length).to.equal(1, 'proto1'); - expect(proto1Messages).to.include(await Message.getCid(write1proto1.message)); - expect(proto2Messages.length).to.equal(1, 'proto2'); - expect(proto2Messages).to.include(await Message.getCid(write1proto2.message)); - - // remove listener for proto1 - proto1SubscriptionReply.subscription?.close(); - - // create another record for proto1 - const write2proto1 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto1, ...postProperties }); - const write2Response = await dwn.processMessage(alice.did, write2proto1.message, { dataStream: write2proto1.dataStream }); - expect(write2Response.status.code).equals(202); - - // create another record for proto2 - const write2proto2 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto2, ...postProperties }); - const write2Proto2Response = await dwn.processMessage(alice.did, write2proto2.message, { dataStream: write2proto2.dataStream }); - expect(write2Proto2Response.status.code).equals(202); - - // proto1 messages from handler do not change. - expect(proto1Messages.length).to.equal(1, 'proto1 after subscription.off()'); - expect(proto1Messages).to.include(await Message.getCid(write1proto1.message)); - - //proto2 messages from handler have the new message. - expect(proto2Messages.length).to.equal(2, 'proto2 after subscription.off()'); - expect(proto2Messages).to.have.members([await Message.getCid(write1proto2.message), await Message.getCid(write2proto2.message)]); + const protocolConfigure = await TestDataGenerator.generateProtocolsConfigure({ + author : alice, + protocolDefinition : { ...freeForAll } + }); + const protocolConfigureReply = await dwn.processMessage(alice.did, protocolConfigure.message); + expect(protocolConfigureReply.status.code).to.equal(202); + const protocol = protocolConfigure.message.descriptor.definition.protocol; + + const postProperties = { + protocol : protocol, + protocolPath : 'post', + schema : freeForAll.types.post.schema, + dataFormat : freeForAll.types.post.dataFormats[0], + }; + + const messageFromBobToAlice = await TestDataGenerator.generateRecordsWrite({ + ...postProperties, + author : bob, + recipient : alice.did, + }); + const messageFromBobToAliceReply = + await dwn.processMessage(alice.did, messageFromBobToAlice.message, { dataStream: messageFromBobToAlice.dataStream }); + expect(messageFromBobToAliceReply.status.code).to.equal(202); + + const messageFromCarolToAlice = await TestDataGenerator.generateRecordsWrite({ + ...postProperties, + author : carol, + recipient : alice.did, + }); + const messageFromCarolToAliceReply = + await dwn.processMessage(alice.did, messageFromCarolToAlice.message, { dataStream: messageFromCarolToAlice.dataStream }); + expect(messageFromCarolToAliceReply.status.code).to.equal(202); + + const messageFromAliceToBob = await TestDataGenerator.generateRecordsWrite({ + ...postProperties, + author : alice, + recipient : bob.did, + }); + const messageFromAliceToBobReply = + await dwn.processMessage(alice.did, messageFromAliceToBob.message, { dataStream: messageFromAliceToBob.dataStream }); + expect(messageFromAliceToBobReply.status.code).to.equal(202); + + const messageFromAliceToCarol = await TestDataGenerator.generateRecordsWrite({ + ...postProperties, + author : alice, + recipient : carol.did, + }); + const messageFromAliceToCarolReply = + await dwn.processMessage(alice.did, messageFromAliceToCarol.message, { dataStream: messageFromAliceToCarol.dataStream }); + expect(messageFromAliceToCarolReply.status.code).to.equal(202); + + expect(receivedMessages).to.have.members([ + await Message.getCid(messageFromBobToAlice.message), + await Message.getCid(messageFromCarolToAlice.message) + ]); + + // add another message + const messageFromAliceToBob2 = await TestDataGenerator.generateRecordsWrite({ + ...postProperties, + author : alice, + recipient : bob.did, }); + const messageFromAliceToBob2Reply = + await dwn.processMessage(alice.did, messageFromAliceToBob2.message, { dataStream: messageFromAliceToBob2.dataStream }); + expect(messageFromAliceToBob2Reply.status.code).to.equal(202); - it('unsubscribes', async () => { - const alice = await DidKeyResolver.generate(); + expect(receivedMessages).to.not.include.members([ await Message.getCid(messageFromAliceToBob2.message)]); + }); - // messageCids of events - const messageCids:string[] = []; - const handler = async (message: GenericMessage): Promise => { - const messageCid = await Message.getCid(message); - messageCids.push(messageCid); - }; + it('does not emit events after subscription is closed', async () => { + const alice = await DidKeyResolver.generate(); - // subscribe to all events - const eventsSubscription = await TestDataGenerator.generateEventsSubscribe({ author: alice }); - const eventsSubscriptionReply = await dwn.processMessage(alice.did, eventsSubscription.message, { handler }); - expect(eventsSubscriptionReply.status.code).to.equal(200); + // messageCids of events + const messageCids:string[] = []; - expect(messageCids.length).to.equal(0); // no events exist yet + const handler = async (message: GenericMessage): Promise => { + const messageCid = await Message.getCid(message); + messageCids.push(messageCid); + }; - const record1 = await TestDataGenerator.generateRecordsWrite({ author: alice }); - const record1Reply = await dwn.processMessage(alice.did, record1.message, { dataStream: record1.dataStream }); - expect(record1Reply.status.code).to.equal(202); - const record1MessageCid = await Message.getCid(record1.message); + // subscribe to all events + const eventsSubscription = await TestDataGenerator.generateEventsSubscribe({ author: alice }); + const eventsSubscriptionReply = await dwn.processMessage(alice.did, eventsSubscription.message, { handler }); + expect(eventsSubscriptionReply.status.code).to.equal(200); - expect(messageCids.length).to.equal(1); // message exists - expect(messageCids).to.eql([ record1MessageCid ]); + expect(messageCids.length).to.equal(0); // no events exist yet - // unsubscribe, this should be used as clean up. - await eventsSubscriptionReply.subscription!.close(); + const record1 = await TestDataGenerator.generateRecordsWrite({ author: alice }); + const record1Reply = await dwn.processMessage(alice.did, record1.message, { dataStream: record1.dataStream }); + expect(record1Reply.status.code).to.equal(202); + const record1MessageCid = await Message.getCid(record1.message); - // write another message. - const record2 = await TestDataGenerator.generateRecordsWrite({ author: alice }); - const record2Reply = await dwn.processMessage(alice.did, record2.message, { dataStream: record2.dataStream }); - expect(record2Reply.status.code).to.equal(202); + expect(messageCids.length).to.equal(1); // message exists + expect(messageCids).to.eql([ record1MessageCid ]); - // sleep to make sure events have some time to emit. - await Time.minimalSleep(); + // unsubscribe, this should be used as clean up. + await eventsSubscriptionReply.subscription!.close(); - expect(messageCids.length).to.equal(1); // same as before - expect(messageCids).to.eql([ record1MessageCid ]); - }); + // write another message. + const record2 = await TestDataGenerator.generateRecordsWrite({ author: alice }); + const record2Reply = await dwn.processMessage(alice.did, record2.message, { dataStream: record2.dataStream }); + expect(record2Reply.status.code).to.equal(202); + + // sleep to make sure events have some time to emit. + await Time.minimalSleep(); + + expect(messageCids.length).to.equal(1); // same as before + expect(messageCids).to.eql([ record1MessageCid ]); }); }); - }); } \ No newline at end of file From 27c110f28a0adfc465071c82568cb7f46070fd6b Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Tue, 16 Jan 2024 18:58:11 -0500 Subject: [PATCH 31/44] remove console logs --- src/handlers/events-subscribe.ts | 1 - tests/scenarios/subscriptions.spec.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/handlers/events-subscribe.ts b/src/handlers/events-subscribe.ts index e7b06b213..52d6ce24a 100644 --- a/src/handlers/events-subscribe.ts +++ b/src/handlers/events-subscribe.ts @@ -53,7 +53,6 @@ export class EventsSubscribeHandler implements MethodHandler { const messageCid = await Message.getCid(message); const listener: EventListener = (eventTenant, eventMessage, eventIndexes):void => { - console.log('got from listener', eventMessage, eventIndexes, eventsFilters); if (tenant === eventTenant && FilterUtility.matchAnyFilter(eventIndexes, eventsFilters)) { handler(eventMessage); } diff --git a/tests/scenarios/subscriptions.spec.ts b/tests/scenarios/subscriptions.spec.ts index 9a29602fa..d75a8d640 100644 --- a/tests/scenarios/subscriptions.spec.ts +++ b/tests/scenarios/subscriptions.spec.ts @@ -210,7 +210,6 @@ export function testSubscriptionScenarios(): void { const receivedMessages:string[] = []; const handler = async (message:GenericMessage): Promise => { - console.log('got message??', message); const messageCid = await Message.getCid(message); receivedMessages.push(messageCid); }; From de7a73c8d0db1d8be811c868130bd6706a684411 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Tue, 16 Jan 2024 22:03:27 -0500 Subject: [PATCH 32/44] update after rbase --- tests/handlers/events-subscribe.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/handlers/events-subscribe.spec.ts b/tests/handlers/events-subscribe.spec.ts index 1444f9490..ae89321ef 100644 --- a/tests/handlers/events-subscribe.spec.ts +++ b/tests/handlers/events-subscribe.spec.ts @@ -158,7 +158,7 @@ export function testEventsSubscribeHandler(): void { const messageCid = await Message.getCid(messageWrite.message); // control: ensure that the event exists - const events = await eventLog.getEvents(alice.did); + const { events } = await eventLog.getEvents(alice.did); expect(events.length).to.equal(1); expect(events[0]).to.equal(messageCid); From fcdbf7067a95fb8503a10eb993c6b83a80f75893 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 17 Jan 2024 10:06:08 -0500 Subject: [PATCH 33/44] remove unecessary duplicate type/interface --- src/types/events-types.ts | 11 ++--------- src/types/message-types.ts | 2 +- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/types/events-types.ts b/src/types/events-types.ts index d0b71abd1..f925de637 100644 --- a/src/types/events-types.ts +++ b/src/types/events-types.ts @@ -1,5 +1,5 @@ import type { ProtocolsQueryFilter } from './protocols-types.js'; -import type { AuthorizationModel, GenericMessage, GenericMessageReply, GenericMessageSubscriptionHandler } from './message-types.js'; +import type { AuthorizationModel, GenericMessage, GenericMessageReply, GenericMessageSubscription, GenericMessageSubscriptionHandler } from './message-types.js'; import type { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; import type { PaginationCursor, RangeCriterion, RangeFilter } from './query-types.js'; @@ -52,15 +52,8 @@ export type EventsSubscribeMessage = { descriptor: EventsSubscribeDescriptor; }; -export type EventsHandler = (message: GenericMessage) => void; - -export type EventsSubscription = { - id: string; - close: () => Promise; -}; - export type EventsSubscribeReply = GenericMessageReply & { - subscription?: EventsSubscription; + subscription?: GenericMessageSubscription; }; export type EventsSubscribeDescriptor = { diff --git a/src/types/message-types.ts b/src/types/message-types.ts index dd1458a20..21b75c055 100644 --- a/src/types/message-types.ts +++ b/src/types/message-types.ts @@ -78,7 +78,7 @@ export type QueryResultEntry = GenericMessage & { export type GenericMessageSubscriptionHandler = (message: GenericMessage) => void; -export type GenericMessageSubscription = { +export interface GenericMessageSubscription { id: string; close: () => Promise; }; From bd6d341cb01b4e93e78e84feff4340663eff4c31 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 17 Jan 2024 15:12:17 -0500 Subject: [PATCH 34/44] review updates --- src/handlers/events-subscribe.ts | 4 ++-- src/types/events-types.ts | 4 ++-- src/types/message-types.ts | 4 ++-- src/types/method-handler.ts | 4 ++-- tests/event-log/event-emitter-stream.spec.ts | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/handlers/events-subscribe.ts b/src/handlers/events-subscribe.ts index 52d6ce24a..6dd55a737 100644 --- a/src/handlers/events-subscribe.ts +++ b/src/handlers/events-subscribe.ts @@ -1,5 +1,5 @@ import type { DidResolver } from '../did/did-resolver.js'; -import type { GenericMessageSubscriptionHandler } from '../types/message-types.js'; +import type { MessageSubscriptionHandler } from '../types/message-types.js'; import type { MethodHandler } from '../types/method-handler.js'; import type { EventListener, EventStream } from '../types/subscriptions.js'; import type { EventsSubscribeMessage, EventsSubscribeReply } from '../types/events-types.js'; @@ -25,7 +25,7 @@ export class EventsSubscribeHandler implements MethodHandler { }: { tenant: string; message: EventsSubscribeMessage; - handler: GenericMessageSubscriptionHandler; + handler: MessageSubscriptionHandler; }): Promise { if (this.eventStream === undefined) { return messageReplyFromError(new DwnError( diff --git a/src/types/events-types.ts b/src/types/events-types.ts index f925de637..30423ede6 100644 --- a/src/types/events-types.ts +++ b/src/types/events-types.ts @@ -1,5 +1,5 @@ import type { ProtocolsQueryFilter } from './protocols-types.js'; -import type { AuthorizationModel, GenericMessage, GenericMessageReply, GenericMessageSubscription, GenericMessageSubscriptionHandler } from './message-types.js'; +import type { AuthorizationModel, GenericMessage, GenericMessageReply, GenericMessageSubscription, MessageSubscriptionHandler } from './message-types.js'; import type { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; import type { PaginationCursor, RangeCriterion, RangeFilter } from './query-types.js'; @@ -44,7 +44,7 @@ export type EventsGetReply = GenericMessageReply & { export type EventsSubscribeMessageOptions = { - handler: GenericMessageSubscriptionHandler; + handler: MessageSubscriptionHandler; }; export type EventsSubscribeMessage = { diff --git a/src/types/message-types.ts b/src/types/message-types.ts index 21b75c055..06b5497f4 100644 --- a/src/types/message-types.ts +++ b/src/types/message-types.ts @@ -16,7 +16,7 @@ export type GenericMessage = { */ export type MessageOptions = { dataStream?: Readable; - handler?: GenericMessageSubscriptionHandler; + handler?: MessageSubscriptionHandler; }; /** @@ -76,7 +76,7 @@ export type QueryResultEntry = GenericMessage & { encodedData?: string; }; -export type GenericMessageSubscriptionHandler = (message: GenericMessage) => void; +export type MessageSubscriptionHandler = (message: GenericMessage) => void; export interface GenericMessageSubscription { id: string; diff --git a/src/types/method-handler.ts b/src/types/method-handler.ts index ec0a20546..be34c798e 100644 --- a/src/types/method-handler.ts +++ b/src/types/method-handler.ts @@ -1,5 +1,5 @@ import type { Readable } from 'readable-stream'; -import type { GenericMessage, GenericMessageReply, GenericMessageSubscriptionHandler } from './message-types.js'; +import type { GenericMessage, GenericMessageReply, MessageSubscriptionHandler } from './message-types.js'; /** * Interface that defines a message handler of a specific method. @@ -12,6 +12,6 @@ export interface MethodHandler { tenant: string; message: GenericMessage; dataStream?: Readable - handler?: GenericMessageSubscriptionHandler; + handler?: MessageSubscriptionHandler; }): Promise; } \ No newline at end of file diff --git a/tests/event-log/event-emitter-stream.spec.ts b/tests/event-log/event-emitter-stream.spec.ts index 00e4a332f..da4340c3f 100644 --- a/tests/event-log/event-emitter-stream.spec.ts +++ b/tests/event-log/event-emitter-stream.spec.ts @@ -36,11 +36,11 @@ describe('EventEmitterStream', () => { await eventStream.close(); }); - it('should remove listeners when unsubscribe method is used', async () => { + it('should remove listeners when `close` method is used', async () => { const emitter = new EventEmitter(); eventStream = new EventEmitterStream({ emitter }); - // count the `events_bus` listeners, which represents all listeners + // count the `events` listeners, which represents all listeners expect(emitter.listenerCount('events')).to.equal(0); const sub = await eventStream.subscribe('id', () => {}); From 50612f9a470bb0fb0cd0f41e7af357cdbf938317 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 17 Jan 2024 16:04:11 -0500 Subject: [PATCH 35/44] rename classes and properties suggested by review comments --- src/core/message-reply.ts | 4 ++-- src/dwn.ts | 4 ++-- src/handlers/events-subscribe.ts | 6 +++--- src/types/events-types.ts | 6 +++--- src/types/message-types.ts | 4 ++-- src/types/method-handler.ts | 2 +- src/types/subscriptions.ts | 4 ++-- tests/handlers/events-subscribe.spec.ts | 6 +++--- tests/scenarios/subscriptions.spec.ts | 10 +++++----- 9 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/core/message-reply.ts b/src/core/message-reply.ts index a29e0bf89..decd58758 100644 --- a/src/core/message-reply.ts +++ b/src/core/message-reply.ts @@ -3,7 +3,7 @@ import type { PaginationCursor } from '../types/query-types.js'; import type { ProtocolsConfigureMessage } from '../types/protocols-types.js'; import type { Readable } from 'readable-stream'; import type { RecordsWriteMessage } from '../types/records-types.js'; -import type { GenericMessageReply, GenericMessageSubscription, QueryResultEntry } from '../types/message-types.js'; +import type { GenericMessageReply, MessageSubscription, QueryResultEntry } from '../types/message-types.js'; export function messageReplyFromError(e: unknown, code: number): GenericMessageReply { @@ -44,5 +44,5 @@ export type UnionMessageReply = GenericMessageReply & { /** * A subscription object if a subscription was requested. */ - subscription?: GenericMessageSubscription; + subscription?: MessageSubscription; }; \ No newline at end of file diff --git a/src/dwn.ts b/src/dwn.ts index 6a96eda71..efab692d7 100644 --- a/src/dwn.ts +++ b/src/dwn.ts @@ -174,14 +174,14 @@ export class Dwn { return errorMessageReply; } - const { dataStream, handler } = options; + const { dataStream, subscriptionHandler } = options; const handlerKey = rawMessage.descriptor.interface + rawMessage.descriptor.method; const methodHandlerReply = await this.methodHandlers[handlerKey].handle({ tenant, message: rawMessage as GenericMessage, dataStream, - handler + subscriptionHandler }); return methodHandlerReply; diff --git a/src/handlers/events-subscribe.ts b/src/handlers/events-subscribe.ts index 6dd55a737..180405137 100644 --- a/src/handlers/events-subscribe.ts +++ b/src/handlers/events-subscribe.ts @@ -21,11 +21,11 @@ export class EventsSubscribeHandler implements MethodHandler { public async handle({ tenant, message, - handler + subscriptionHandler }: { tenant: string; message: EventsSubscribeMessage; - handler: MessageSubscriptionHandler; + subscriptionHandler: MessageSubscriptionHandler; }): Promise { if (this.eventStream === undefined) { return messageReplyFromError(new DwnError( @@ -54,7 +54,7 @@ export class EventsSubscribeHandler implements MethodHandler { const listener: EventListener = (eventTenant, eventMessage, eventIndexes):void => { if (tenant === eventTenant && FilterUtility.matchAnyFilter(eventIndexes, eventsFilters)) { - handler(eventMessage); + subscriptionHandler(eventMessage); } }; diff --git a/src/types/events-types.ts b/src/types/events-types.ts index 30423ede6..fb40bf32c 100644 --- a/src/types/events-types.ts +++ b/src/types/events-types.ts @@ -1,5 +1,5 @@ import type { ProtocolsQueryFilter } from './protocols-types.js'; -import type { AuthorizationModel, GenericMessage, GenericMessageReply, GenericMessageSubscription, MessageSubscriptionHandler } from './message-types.js'; +import type { AuthorizationModel, GenericMessage, GenericMessageReply, MessageSubscription, MessageSubscriptionHandler } from './message-types.js'; import type { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; import type { PaginationCursor, RangeCriterion, RangeFilter } from './query-types.js'; @@ -44,7 +44,7 @@ export type EventsGetReply = GenericMessageReply & { export type EventsSubscribeMessageOptions = { - handler: MessageSubscriptionHandler; + subscriptionHandler: MessageSubscriptionHandler; }; export type EventsSubscribeMessage = { @@ -53,7 +53,7 @@ export type EventsSubscribeMessage = { }; export type EventsSubscribeReply = GenericMessageReply & { - subscription?: GenericMessageSubscription; + subscription?: MessageSubscription; }; export type EventsSubscribeDescriptor = { diff --git a/src/types/message-types.ts b/src/types/message-types.ts index 06b5497f4..122083b40 100644 --- a/src/types/message-types.ts +++ b/src/types/message-types.ts @@ -16,7 +16,7 @@ export type GenericMessage = { */ export type MessageOptions = { dataStream?: Readable; - handler?: MessageSubscriptionHandler; + subscriptionHandler?: MessageSubscriptionHandler; }; /** @@ -78,7 +78,7 @@ export type QueryResultEntry = GenericMessage & { export type MessageSubscriptionHandler = (message: GenericMessage) => void; -export interface GenericMessageSubscription { +export interface MessageSubscription { id: string; close: () => Promise; }; diff --git a/src/types/method-handler.ts b/src/types/method-handler.ts index be34c798e..7a3accdfc 100644 --- a/src/types/method-handler.ts +++ b/src/types/method-handler.ts @@ -12,6 +12,6 @@ export interface MethodHandler { tenant: string; message: GenericMessage; dataStream?: Readable - handler?: MessageSubscriptionHandler; + subscriptionHandler?: MessageSubscriptionHandler; }): Promise; } \ No newline at end of file diff --git a/src/types/subscriptions.ts b/src/types/subscriptions.ts index 5d388ffff..72ea93a39 100644 --- a/src/types/subscriptions.ts +++ b/src/types/subscriptions.ts @@ -1,6 +1,6 @@ import type { GenericMessageReply } from '../types/message-types.js'; import type { KeyValues } from './query-types.js'; -import type { GenericMessage, GenericMessageSubscription } from './message-types.js'; +import type { GenericMessage, MessageSubscription } from './message-types.js'; export type EventListener = (tenant: string, message: GenericMessage, indexes: KeyValues) => void; @@ -20,5 +20,5 @@ export interface EventSubscription { } export type SubscriptionReply = GenericMessageReply & { - subscription?: GenericMessageSubscription; + subscription?: MessageSubscription; }; \ No newline at end of file diff --git a/tests/handlers/events-subscribe.spec.ts b/tests/handlers/events-subscribe.spec.ts index ae89321ef..761d8b7ee 100644 --- a/tests/handlers/events-subscribe.spec.ts +++ b/tests/handlers/events-subscribe.spec.ts @@ -69,7 +69,7 @@ export function testEventsSubscribeHandler(): void { const alice = await DidKeyResolver.generate(); // attempt to subscribe const { message } = await EventsSubscribe.create({ signer: Jws.createSigner(alice) }); - const subscriptionMessageReply = await dwn.processMessage(alice.did, message, { handler: (_) => {} }); + const subscriptionMessageReply = await dwn.processMessage(alice.did, message, { subscriptionHandler: (_) => {} }); expect(subscriptionMessageReply.status.code).to.equal(501, subscriptionMessageReply.status.detail); expect(subscriptionMessageReply.status.detail).to.include(DwnErrorCode.EventsSubscribeEventStreamUnimplemented); }); @@ -127,7 +127,7 @@ export function testEventsSubscribeHandler(): void { const eventsSubscribeHandler = new EventsSubscribeHandler(didResolver, eventStream); - const reply = await eventsSubscribeHandler.handle({ tenant: alice.did, message, handler: (_) => {} }); + const reply = await eventsSubscribeHandler.handle({ tenant: alice.did, message, subscriptionHandler: (_) => {} }); expect(reply.status.code).to.equal(400); }); @@ -148,7 +148,7 @@ export function testEventsSubscribeHandler(): void { const subscriptionRequest = await EventsSubscribe.create({ signer: Jws.createSigner(alice), }); - const subscriptionReply = await dwn.processMessage(alice.did, subscriptionRequest.message, { handler }); + const subscriptionReply = await dwn.processMessage(alice.did, subscriptionRequest.message, { subscriptionHandler: handler }); expect(subscriptionReply.status.code).to.equal(200, subscriptionReply.status.detail); expect(subscriptionReply.subscription).to.not.be.undefined; diff --git a/tests/scenarios/subscriptions.spec.ts b/tests/scenarios/subscriptions.spec.ts index d75a8d640..8917ba131 100644 --- a/tests/scenarios/subscriptions.spec.ts +++ b/tests/scenarios/subscriptions.spec.ts @@ -63,7 +63,7 @@ export function testSubscriptionScenarios(): void { // subscribe to all messages const eventsSubscription = await TestDataGenerator.generateEventsSubscribe({ author: alice }); - const eventsSubscriptionReply = await dwn.processMessage(alice.did, eventsSubscription.message, { handler }); + const eventsSubscriptionReply = await dwn.processMessage(alice.did, eventsSubscription.message, { subscriptionHandler: handler }); expect(eventsSubscriptionReply.status.code).to.equal(200); expect(eventsSubscriptionReply.subscription?.id).to.equal(await Message.getCid(eventsSubscription.message)); @@ -144,7 +144,7 @@ export function testSubscriptionScenarios(): void { // subscribe to proto1 messages const proto1Subscription = await TestDataGenerator.generateEventsSubscribe({ author: alice, filters: [{ protocol: proto1 }] }); - const proto1SubscriptionReply = await dwn.processMessage(alice.did, proto1Subscription.message, { handler: proto1Handler }); + const proto1SubscriptionReply = await dwn.processMessage(alice.did, proto1Subscription.message, { subscriptionHandler: proto1Handler }); expect(proto1SubscriptionReply.status.code).to.equal(200); expect(proto1SubscriptionReply.subscription?.id).to.equal(await Message.getCid(proto1Subscription.message)); @@ -156,7 +156,7 @@ export function testSubscriptionScenarios(): void { // subscribe to proto2 messages const proto2Subscription = await TestDataGenerator.generateEventsSubscribe({ author: alice, filters: [{ protocol: proto2 }] }); - const proto2SubscriptionReply = await dwn.processMessage(alice.did, proto2Subscription.message, { handler: proto2Handler }); + const proto2SubscriptionReply = await dwn.processMessage(alice.did, proto2Subscription.message, { subscriptionHandler: proto2Handler }); expect(proto2SubscriptionReply.status.code).to.equal(200); expect(proto2SubscriptionReply.subscription?.id).to.equal(await Message.getCid(proto2Subscription.message)); @@ -218,7 +218,7 @@ export function testSubscriptionScenarios(): void { author : alice, filters : [{ recipient: alice.did }] }); - const authorQueryReply = await dwn.processMessage(alice.did, recipientSubscription.message, { handler }); + const authorQueryReply = await dwn.processMessage(alice.did, recipientSubscription.message, { subscriptionHandler: handler }); expect(authorQueryReply.status.code).to.equal(200); const protocolConfigure = await TestDataGenerator.generateProtocolsConfigure({ @@ -304,7 +304,7 @@ export function testSubscriptionScenarios(): void { // subscribe to all events const eventsSubscription = await TestDataGenerator.generateEventsSubscribe({ author: alice }); - const eventsSubscriptionReply = await dwn.processMessage(alice.did, eventsSubscription.message, { handler }); + const eventsSubscriptionReply = await dwn.processMessage(alice.did, eventsSubscription.message, { subscriptionHandler: handler }); expect(eventsSubscriptionReply.status.code).to.equal(200); expect(messageCids.length).to.equal(0); // no events exist yet From 9a8a0dc54ff42d19ed5f482074e29dfbe02b689c Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 17 Jan 2024 19:26:57 -0500 Subject: [PATCH 36/44] fix isRecordsFilter method and conversion/normalization --- src/interfaces/protocols-query.ts | 9 +++------ src/utils/events.ts | 32 +++++++++++++++++++------------ 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/interfaces/protocols-query.ts b/src/interfaces/protocols-query.ts index 4df419cd8..488001485 100644 --- a/src/interfaces/protocols-query.ts +++ b/src/interfaces/protocols-query.ts @@ -36,11 +36,12 @@ export class ProtocolsQuery extends AbstractMessage { } public static async create(options: ProtocolsQueryOptions): Promise { + const descriptor: ProtocolsQueryDescriptor = { interface : DwnInterfaceName.Protocols, method : DwnMethodName.Query, messageTimestamp : options.messageTimestamp ?? Time.getCurrentTimestamp(), - filter : ProtocolsQuery.normalizeFilter(options.filter), + filter : options.filter ? ProtocolsQuery.normalizeFilter(options.filter) : undefined, }; // delete all descriptor properties that are `undefined` else the code will encounter the following IPLD issue when attempting to generate CID: @@ -65,11 +66,7 @@ export class ProtocolsQuery extends AbstractMessage { return protocolsQuery; } - static normalizeFilter(filter: ProtocolsQueryFilter | undefined): ProtocolsQueryFilter | undefined { - if (filter === undefined) { - return undefined; - } - + static normalizeFilter(filter: ProtocolsQueryFilter): ProtocolsQueryFilter { return { ...filter, protocol: normalizeProtocolUrl(filter.protocol), diff --git a/src/utils/events.ts b/src/utils/events.ts index 5dc87ae16..d28266aab 100644 --- a/src/utils/events.ts +++ b/src/utils/events.ts @@ -17,13 +17,13 @@ export class Events { // normalize each filter individually by the type of filter it is. for (const filter of filters) { - if (this.isMessagesFilter(filter)) { - eventsQueryFilters.push(filter); - } else if (this.isRecordsFilter(filter)) { + if (this.isRecordsFilter(filter)) { eventsQueryFilters.push(Records.normalizeFilter(filter)); } else if (this.isProtocolFilter(filter)) { - const protocolFilter = ProtocolsQuery.normalizeFilter(filter); - eventsQueryFilters.push(protocolFilter!); + eventsQueryFilters.push(ProtocolsQuery.normalizeFilter(filter)); + } else { + // no normalization needed + eventsQueryFilters.push(filter); } } @@ -40,13 +40,18 @@ export class Events { const eventsQueryFilters: Filter[] = []; - // normalize each filter individually by the type of filter it is. + // convert each filter individually by the specific type of filter it is + // we must check for the type of filter in a specific order to make a reductive decision as to which filters need converting + // first we check for `EventsRecordsFilter` fields for conversion + // then we check for the `EventsMessageFilter` fields for conversion + // finally we pass through the filters as `ProtocolQueryFilter` does not require conversion for (const filter of filters) { - if (this.isMessagesFilter(filter)) { - eventsQueryFilters.push(this.convertFilter(filter)); - } else if (this.isRecordsFilter(filter)) { + if (this.isRecordsFilter(filter)) { eventsQueryFilters.push(Records.convertFilter(filter)); - } else if (this.isProtocolFilter(filter)) { + } else if (this.isMessagesFilter(filter)) { + eventsQueryFilters.push(this.convertFilter(filter)); + } else { + // protocol filters do not need any conversion eventsQueryFilters.push(filter); } } @@ -67,11 +72,14 @@ export class Events { } private static isMessagesFilter(filter: EventsFilter): filter is EventsMessageFilter { - return 'method' in filter || 'interface' in filter || 'dateUpdated' in filter || 'author' in filter; + return 'method' in filter || 'interface' in filter || 'dateUpdated' in filter; } + // we deliberately do not check for `dateUpdated` in this filter. + // if it were the only property that matched, it could be handled by `EventMessageFilter` private static isRecordsFilter(filter: EventsFilter): filter is EventsRecordsFilter { - return 'dateCreated' in filter || + return 'author' in filter || + 'dateCreated' in filter || 'dataFormat' in filter || 'dataSize' in filter || 'parentId' in filter || From 4256e8dcebea1e37f77880511e4b413d50109852 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 17 Jan 2024 20:08:45 -0500 Subject: [PATCH 37/44] review updates --- src/handlers/records-delete.ts | 5 ++--- src/index.ts | 6 ++---- src/interfaces/records-delete.ts | 8 -------- src/interfaces/records-write.ts | 4 ---- src/utils/events.ts | 15 ++++++++++++--- 5 files changed, 16 insertions(+), 22 deletions(-) diff --git a/src/handlers/records-delete.ts b/src/handlers/records-delete.ts index b609707b4..a4d06b7a0 100644 --- a/src/handlers/records-delete.ts +++ b/src/handlers/records-delete.ts @@ -89,9 +89,8 @@ export class RecordsDeleteHandler implements MethodHandler { return messageReplyFromError(e, 401); } - const recordsWrite = await RecordsWrite.getInitialWrite(existingMessages); - const published = (newestExistingMessage as RecordsWriteMessage).descriptor.published; - const indexes = recordsDelete.constructIndexes(recordsWrite, published); + const initialWrite = await RecordsWrite.getInitialWrite(existingMessages); + const indexes = recordsDelete.constructIndexes(initialWrite); const messageCid = await Message.getCid(message); await this.messageStore.put(tenant, message, indexes); await this.eventLog.append(tenant, messageCid, indexes); diff --git a/src/index.ts b/src/index.ts index d23fb9b40..c5d5cf728 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,7 @@ export { Encoder } from './utils/encoder.js'; export { EventsGet, EventsGetOptions } from './interfaces/events-get.js'; export { Encryption, EncryptionAlgorithm } from './utils/encryption.js'; export { EncryptionInput, KeyEncryptionInput, RecordsWrite, RecordsWriteOptions, CreateFromOptions } from './interfaces/records-write.js'; +export { EventsSubscribe , EventsSubscribeOptions } from './interfaces/events-subscribe.js'; export { executeUnlessAborted } from './utils/abort.js'; export { Jws } from './utils/jws.js'; export { KeyMaterial, PrivateJwk, PublicJwk } from './types/jose-types.js'; @@ -52,14 +53,11 @@ export { Signer } from './types/signer.js'; export { SortDirection } from './types/query-types.js'; export { Time } from './utils/time.js'; -// store implementations +// concrete implementations of stores and event stream export { DataStoreLevel } from './store/data-store-level.js'; export { EventLogLevel } from './event-log/event-log-level.js'; export { MessageStoreLevel } from './store/message-store-level.js'; - -// eventing implementations export { EventEmitterStream } from './event-log/event-emitter-stream.js'; -export { EventsSubscribe , EventsSubscribeOptions } from './interfaces/events-subscribe.js'; // test library exports export { Persona, TestDataGenerator } from '../tests/utils/test-data-generator.js'; \ No newline at end of file diff --git a/src/interfaces/records-delete.ts b/src/interfaces/records-delete.ts index 367839738..44c34f019 100644 --- a/src/interfaces/records-delete.ts +++ b/src/interfaces/records-delete.ts @@ -1,5 +1,4 @@ import type { DelegatedGrantMessage } from '../types/delegated-grant-message.js'; -import type { GenericMessage } from '../types/message-types.js'; import type { KeyValues } from '../types/query-types.js'; import type { MessageStore } from '../types//message-store.js'; import type { Signer } from '../types/signer.js'; @@ -71,12 +70,10 @@ export class RecordsDelete extends AbstractMessage { } /** - * Authorizes the delegate who signed this message. * Indexed properties needed for MessageStore indexing. */ public constructIndexes( initialWrite: RecordsWriteMessage, - published?: boolean, ): KeyValues { const message = this.message; const descriptor = { ...message.descriptor }; @@ -91,7 +88,6 @@ export class RecordsDelete extends AbstractMessage { const indexes: { [key:string]: string | boolean | undefined } = { // isLatestBaseState : "true", // intentionally showing that this index is omitted protocol, protocolPath, recipient, schema, parentId, dataFormat, dateCreated, - published : !!published, contextId : initialWrite.contextId, author : this.author!, ...descriptor @@ -116,8 +112,4 @@ export class RecordsDelete extends AbstractMessage { messageStore }); } - - public static isRecordsDeleteMessage(message: GenericMessage): message is RecordsDeleteMessage { - return message.descriptor.interface === DwnInterfaceName.Records && message.descriptor.method === DwnMethodName.Delete; - } } diff --git a/src/interfaces/records-write.ts b/src/interfaces/records-write.ts index 0178f9199..610352978 100644 --- a/src/interfaces/records-write.ts +++ b/src/interfaces/records-write.ts @@ -932,8 +932,4 @@ export class RecordsWrite implements MessageInterface { const attesters = attestationSignatures.map((signature) => Jws.getSignerDid(signature)); return attesters; } - - public static isRecordsWriteMessage(message: GenericMessage): message is RecordsWriteMessage { - return message.descriptor.interface === DwnInterfaceName.Records && message.descriptor.method === DwnMethodName.Write; - } } diff --git a/src/utils/events.ts b/src/utils/events.ts index d28266aab..199263849 100644 --- a/src/utils/events.ts +++ b/src/utils/events.ts @@ -5,6 +5,7 @@ import type { EventsFilter, EventsMessageFilter, EventsRecordsFilter } from '../ import { FilterUtility } from '../utils/filter.js'; import { ProtocolsQuery } from '../interfaces/protocols-query.js'; import { Records } from '../utils/records.js'; +import { isEmptyObject, removeUndefinedProperties } from './object.js'; /** @@ -17,16 +18,24 @@ export class Events { // normalize each filter individually by the type of filter it is. for (const filter of filters) { + let eventsFilter: EventsFilter; if (this.isRecordsFilter(filter)) { - eventsQueryFilters.push(Records.normalizeFilter(filter)); + eventsFilter = Records.normalizeFilter(filter); } else if (this.isProtocolFilter(filter)) { - eventsQueryFilters.push(ProtocolsQuery.normalizeFilter(filter)); + eventsFilter = ProtocolsQuery.normalizeFilter(filter); } else { // no normalization needed - eventsQueryFilters.push(filter); + eventsFilter = filter; + } + + // remove any empty filter properties and do not add if empty + removeUndefinedProperties(eventsFilter); + if (!isEmptyObject(eventsFilter)) { + eventsQueryFilters.push(eventsFilter); } } + return eventsQueryFilters; } From d92f3ad82559101a13cc89d074914dffb6977ab0 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 17 Jan 2024 20:39:49 -0500 Subject: [PATCH 38/44] review suggestions, added more validation to parse --- src/handlers/events-subscribe.ts | 6 +- src/interfaces/events-query.ts | 10 ++++ src/interfaces/events-subscribe.ts | 18 ++++-- src/interfaces/records-delete.ts | 4 +- tests/handlers/events-subscribe.spec.ts | 12 ++-- tests/scenarios/subscriptions.spec.ts | 74 ++++++++++++++++++++++++- 6 files changed, 106 insertions(+), 18 deletions(-) diff --git a/src/handlers/events-subscribe.ts b/src/handlers/events-subscribe.ts index 180405137..8bad408c1 100644 --- a/src/handlers/events-subscribe.ts +++ b/src/handlers/events-subscribe.ts @@ -34,16 +34,16 @@ export class EventsSubscribeHandler implements MethodHandler { ), 501); } - let subscriptionRequest: EventsSubscribe; + let eventsSubscribe: EventsSubscribe; try { - subscriptionRequest = await EventsSubscribe.parse(message); + eventsSubscribe = await EventsSubscribe.parse(message); } catch (e) { return messageReplyFromError(e, 400); } try { await authenticate(message.authorization, this.didResolver); - await authorizeOwner(tenant, subscriptionRequest); + await authorizeOwner(tenant, eventsSubscribe); } catch (error) { return messageReplyFromError(error, 401); } diff --git a/src/interfaces/events-query.ts b/src/interfaces/events-query.ts index a30389f34..9f7fc39f1 100644 --- a/src/interfaces/events-query.ts +++ b/src/interfaces/events-query.ts @@ -8,6 +8,7 @@ import { Message } from '../core/message.js'; import { removeUndefinedProperties } from '../utils/object.js'; import { Time } from '../utils/time.js'; import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; +import { validateProtocolUrlNormalized, validateSchemaUrlNormalized } from '../utils/url.js'; export type EventsQueryOptions = { signer: Signer; @@ -22,6 +23,15 @@ export class EventsQuery extends AbstractMessage{ Message.validateJsonSchema(message); await Message.validateSignatureStructure(message.authorization.signature, message.descriptor); + for (const filter of message.descriptor.filters) { + if ('protocol' in filter && filter.protocol !== undefined) { + validateProtocolUrlNormalized(filter.protocol); + } + if ('schema' in filter && filter.schema !== undefined) { + validateSchemaUrlNormalized(filter.schema); + } + } + return new EventsQuery(message); } diff --git a/src/interfaces/events-subscribe.ts b/src/interfaces/events-subscribe.ts index 177a42b76..fe82ace71 100644 --- a/src/interfaces/events-subscribe.ts +++ b/src/interfaces/events-subscribe.ts @@ -6,13 +6,13 @@ import { Message } from '../core/message.js'; import { removeUndefinedProperties } from '../utils/object.js'; import { Time } from '../utils/time.js'; import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; +import { validateProtocolUrlNormalized, validateSchemaUrlNormalized } from '../utils/url.js'; export type EventsSubscribeOptions = { signer: Signer; messageTimestamp?: string; filters?: EventsFilter[] - permissionsGrantId?: string; }; export class EventsSubscribe extends AbstractMessage { @@ -20,19 +20,27 @@ export class EventsSubscribe extends AbstractMessage { Message.validateJsonSchema(message); await Message.validateSignatureStructure(message.authorization.signature, message.descriptor); + for (const filter of message.descriptor.filters) { + if ('protocol' in filter && filter.protocol !== undefined) { + validateProtocolUrlNormalized(filter.protocol); + } + if ('schema' in filter && filter.schema !== undefined) { + validateSchemaUrlNormalized(filter.schema); + } + } + Time.validateTimestamp(message.descriptor.messageTimestamp); return new EventsSubscribe(message); } /** - * Creates a SubscriptionRequest message. + * Creates a EventsSubscribe message. * - * @throws {DwnError} when a combination of required SubscriptionRequestOptions are missing + * @throws {DwnError} if json schema validation fails. */ public static async create( options: EventsSubscribeOptions ): Promise { - const { permissionsGrantId } = options; const currentTime = Time.getCurrentTimestamp(); const descriptor: EventsSubscribeDescriptor = { @@ -44,10 +52,8 @@ export class EventsSubscribe extends AbstractMessage { removeUndefinedProperties(descriptor); - // only generate the `authorization` property if signature input is given const authorization = await Message.createAuthorization({ descriptor, - permissionsGrantId, signer: options.signer }); diff --git a/src/interfaces/records-delete.ts b/src/interfaces/records-delete.ts index 44c34f019..17b0b1ef9 100644 --- a/src/interfaces/records-delete.ts +++ b/src/interfaces/records-delete.ts @@ -70,8 +70,8 @@ export class RecordsDelete extends AbstractMessage { } /** - * Indexed properties needed for MessageStore indexing. - */ + * Indexed properties needed for MessageStore indexing. + */ public constructIndexes( initialWrite: RecordsWriteMessage, ): KeyValues { diff --git a/tests/handlers/events-subscribe.spec.ts b/tests/handlers/events-subscribe.spec.ts index 761d8b7ee..9f52fc1bd 100644 --- a/tests/handlers/events-subscribe.spec.ts +++ b/tests/handlers/events-subscribe.spec.ts @@ -144,11 +144,11 @@ export function testEventsSubscribeHandler(): void { }; }); - // testing Subscription Request - const subscriptionRequest = await EventsSubscribe.create({ + // testing EventsSubscribe + const eventsSubscribe = await EventsSubscribe.create({ signer: Jws.createSigner(alice), }); - const subscriptionReply = await dwn.processMessage(alice.did, subscriptionRequest.message, { subscriptionHandler: handler }); + const subscriptionReply = await dwn.processMessage(alice.did, eventsSubscribe.message, { subscriptionHandler: handler }); expect(subscriptionReply.status.code).to.equal(200, subscriptionReply.status.detail); expect(subscriptionReply.subscription).to.not.be.undefined; @@ -179,12 +179,12 @@ export function testEventsSubscribeHandler(): void { expect(anonymousReply.status.detail).to.include(`EventsSubscribe: must have required property 'authorization'`); expect(anonymousReply.subscription).to.be.undefined; - // testing Subscription Request - const subscriptionRequest = await EventsSubscribe.create({ + // testing EventsSubscribe + const eventsSubscribe = await EventsSubscribe.create({ signer: Jws.createSigner(bob), }); - const subscriptionReply = await dwn.processMessage(alice.did, subscriptionRequest.message); + const subscriptionReply = await dwn.processMessage(alice.did, eventsSubscribe.message); expect(subscriptionReply.status.code).to.equal(401); expect(subscriptionReply.subscription).to.be.undefined; }); diff --git a/tests/scenarios/subscriptions.spec.ts b/tests/scenarios/subscriptions.spec.ts index 8917ba131..e7cfed24a 100644 --- a/tests/scenarios/subscriptions.spec.ts +++ b/tests/scenarios/subscriptions.spec.ts @@ -103,6 +103,79 @@ export function testSubscriptionScenarios(): void { expect(messageCids).to.eql([ write1MessageCid, grant1MessageCid, protocol1MessageCid, delete1MessageCid ]); }); + it('filters by schema', async () => { + const alice = await DidKeyResolver.generate(); + + // we will add messageCids to these arrays as they are received by their handler to check against later + const schema1Messages:string[] = []; + const schema2Messages:string[] = []; + + // we add a handler to the subscription and add the messageCid to the appropriate array + const schema1Handler = async (message:GenericMessage):Promise => { + const messageCid = await Message.getCid(message); + schema1Messages.push(messageCid); + }; + + // subscribe to schema1 messages + const schema1Subscription = await TestDataGenerator.generateEventsSubscribe({ author: alice, filters: [{ schema: 'http://schema1' }] }); + const schema1SubscriptionReply = await dwn.processMessage(alice.did, schema1Subscription.message, { subscriptionHandler: schema1Handler }); + expect(schema1SubscriptionReply.status.code).to.equal(200); + expect(schema1SubscriptionReply.subscription?.id).to.equal(await Message.getCid(schema1Subscription.message)); + + // we add a handler to the subscription and add the messageCid to the appropriate array + const schema2Handler = async (message:GenericMessage):Promise => { + const messageCid = await Message.getCid(message); + schema2Messages.push(messageCid); + }; + + // subscribe to schema2 messages + const schema2Subscription = await TestDataGenerator.generateEventsSubscribe({ author: alice, filters: [{ schema: 'http://schema2' }] }); + const schema2SubscriptionReply = await dwn.processMessage(alice.did, schema2Subscription.message, { subscriptionHandler: schema2Handler }); + expect(schema2SubscriptionReply.status.code).to.equal(200); + expect(schema2SubscriptionReply.subscription?.id).to.equal(await Message.getCid(schema2Subscription.message)); + + // create some random record, will not show up in records subscription + const write1Random = await TestDataGenerator.generateRecordsWrite({ author: alice }); + const write1RandomResponse = await dwn.processMessage(alice.did, write1Random.message, { dataStream: write1Random.dataStream }); + expect(write1RandomResponse.status.code).to.equal(202); + + // create a record for schema1 + const write1schema1 = await TestDataGenerator.generateRecordsWrite({ author: alice, schema: 'http://schema1' }); + const write1Response = await dwn.processMessage(alice.did, write1schema1.message, { dataStream: write1schema1.dataStream }); + expect(write1Response.status.code).equals(202); + + // create a record for schema2 + const write1schema2 = await TestDataGenerator.generateRecordsWrite({ author: alice, schema: 'http://schema2' }); + const write1Proto2Response = await dwn.processMessage(alice.did, write1schema2.message, { dataStream: write1schema2.dataStream }); + expect(write1Proto2Response.status.code).equals(202); + + expect(schema1Messages.length).to.equal(1, 'schema1'); + expect(schema1Messages).to.include(await Message.getCid(write1schema1.message)); + expect(schema2Messages.length).to.equal(1, 'schema2'); + expect(schema2Messages).to.include(await Message.getCid(write1schema2.message)); + + // remove listener for schema1 + schema1SubscriptionReply.subscription?.close(); + + // create another record for schema1 + const write2proto1 = await TestDataGenerator.generateRecordsWrite({ author: alice, schema: 'http://schema1' }); + const write2Response = await dwn.processMessage(alice.did, write2proto1.message, { dataStream: write2proto1.dataStream }); + expect(write2Response.status.code).equals(202); + + // create another record for schema2 + const write2schema2 = await TestDataGenerator.generateRecordsWrite({ author: alice, schema: 'http://schema2' }); + const write2Schema2Response = await dwn.processMessage(alice.did, write2schema2.message, { dataStream: write2schema2.dataStream }); + expect(write2Schema2Response.status.code).equals(202); + + // schema1 messages from handler do not change. + expect(schema1Messages.length).to.equal(1, 'schema1 after close()'); + expect(schema1Messages).to.include(await Message.getCid(write1schema1.message)); + + // schema2 messages from handler have the new message. + expect(schema2Messages.length).to.equal(2, 'schema2 after close()'); + expect(schema2Messages).to.have.members([await Message.getCid(write1schema2.message), await Message.getCid(write2schema2.message)]); + }); + it('filters by protocol', async () => { const alice = await DidKeyResolver.generate(); @@ -290,7 +363,6 @@ export function testSubscriptionScenarios(): void { expect(receivedMessages).to.not.include.members([ await Message.getCid(messageFromAliceToBob2.message)]); }); - it('does not emit events after subscription is closed', async () => { const alice = await DidKeyResolver.generate(); From 041df8a41783f33741efd8847e8560df21ab099c Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 17 Jan 2024 21:41:49 -0500 Subject: [PATCH 39/44] add coverage --- src/handlers/protocols-configure.ts | 3 +-- tests/handlers/permissions-grant.spec.ts | 16 ++++++++++++ tests/handlers/permissions-revoke.spec.ts | 29 ++++++++++++++++++++++ tests/handlers/protocols-configure.spec.ts | 17 +++++++++++++ tests/handlers/records-delete.spec.ts | 19 ++++++++++++++ tests/handlers/records-write.spec.ts | 11 ++++++++ tests/scenarios/subscriptions.spec.ts | 1 - 7 files changed, 93 insertions(+), 3 deletions(-) diff --git a/src/handlers/protocols-configure.ts b/src/handlers/protocols-configure.ts index e569e3a69..606ad88bc 100644 --- a/src/handlers/protocols-configure.ts +++ b/src/handlers/protocols-configure.ts @@ -24,8 +24,7 @@ export class ProtocolsConfigureHandler implements MethodHandler { public async handle({ tenant, message, - dataStream: _dataStream - }: {tenant: string, message: ProtocolsConfigureMessage, dataStream: _Readable.Readable}): Promise { + }: {tenant: string, message: ProtocolsConfigureMessage }): Promise { let protocolsConfigure: ProtocolsConfigure; try { protocolsConfigure = await ProtocolsConfigure.parse(message); diff --git a/tests/handlers/permissions-grant.spec.ts b/tests/handlers/permissions-grant.spec.ts index b2c9c7328..1feb4d8bf 100644 --- a/tests/handlers/permissions-grant.spec.ts +++ b/tests/handlers/permissions-grant.spec.ts @@ -60,6 +60,22 @@ export function testPermissionsGrantHandler(): void { await dwn.close(); }); + it('should process a PermissionsGrant with EventStream not set', async () => { + // eventStream not defined + const permissionsGrantHandler = new PermissionsGrantHandler(didResolver, messageStore, eventLog); + + const alice = await DidKeyResolver.generate(); + + const { message } = await TestDataGenerator.generatePermissionsGrant({ + author : alice, + grantedBy : alice.did, + grantedFor : alice.did, + }); + + const reply = await permissionsGrantHandler.handle({ tenant: alice.did, message }); + expect(reply.status.code).to.equal(202); + }); + it('should accept a PermissionsGrant with permissionsRequestId omitted', async () => { const alice = await DidKeyResolver.generate(); diff --git a/tests/handlers/permissions-revoke.spec.ts b/tests/handlers/permissions-revoke.spec.ts index 73be4efe1..a134ff3d7 100644 --- a/tests/handlers/permissions-revoke.spec.ts +++ b/tests/handlers/permissions-revoke.spec.ts @@ -9,6 +9,7 @@ import { Dwn } from '../../src/dwn.js'; import { DwnErrorCode } from '../../src/core/dwn-error.js'; import { Message } from '../../src/core/message.js'; import { PermissionsRevoke } from '../../src/interfaces/permissions-revoke.js'; +import { PermissionsRevokeHandler } from '../../src/handlers/permissions-revoke.js'; import { TestDataGenerator } from '../utils/test-data-generator.js'; import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; @@ -51,6 +52,34 @@ describe('PermissionsRevokeHandler.handle()', () => { await dwn.close(); }); + it('should process a PermissionsRevoke with EventStream not set', async () => { + // eventStream not defined + const permissionsRevokeHandler = new PermissionsRevokeHandler(didResolver, messageStore, eventLog); + + // scenario: Alice issues a grant to Bob, then she revokes the grant. + const alice = await DidKeyResolver.generate(); + const bob = await DidKeyResolver.generate(); + + + // Alice issues a grant + const { permissionsGrant } = await TestDataGenerator.generatePermissionsGrant({ + author : alice, + grantedBy : alice.did, + grantedTo : bob.did, + grantedFor : alice.did, + }); + const permissionsGrantReply = await dwn.processMessage(alice.did, permissionsGrant.message); + expect(permissionsGrantReply.status.code).to.eq(202); + + // Alice revokes the grant + const { permissionsRevoke } = await TestDataGenerator.generatePermissionsRevoke({ + author : alice, + permissionsGrantId : await Message.getCid(permissionsGrant.message) + }); + const permissionsRevokeReply = await permissionsRevokeHandler.handle({ tenant: alice.did, message: permissionsRevoke.message }); + expect(permissionsRevokeReply.status.code).to.eq(202); + }); + it('should accept a PermissionsRevoke that revokes an existing grant', async () => { // scenario: Alice issues a grant to Bob, then she revokes the grant. const alice = await DidKeyResolver.generate(); diff --git a/tests/handlers/protocols-configure.spec.ts b/tests/handlers/protocols-configure.spec.ts index 7d56d2dc8..2d46afd0c 100644 --- a/tests/handlers/protocols-configure.spec.ts +++ b/tests/handlers/protocols-configure.spec.ts @@ -18,6 +18,7 @@ import { DidKeyResolver } from '../../src/did/did-key-resolver.js'; import { GeneralJwsBuilder } from '../../src/jose/jws/general/builder.js'; import { lexicographicalCompare } from '../../src/utils/string.js'; import { Message } from '../../src/core/message.js'; +import { ProtocolsConfigureHandler } from '../../src/handlers/protocols-configure.js'; import { TestDataGenerator } from '../utils/test-data-generator.js'; import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; @@ -67,6 +68,22 @@ export function testProtocolsConfigureHandler(): void { await dwn.close(); }); + it('should process a ProtocolsConfigure with EventStream not set', async () => { + // eventStream not defined + const protocolsConfigureHandler = new ProtocolsConfigureHandler(didResolver, messageStore, eventLog); + + const alice = await DidKeyResolver.generate(); + + const protocolDefinition = minimalProtocolDefinition; + const { message } = await TestDataGenerator.generateProtocolsConfigure({ + author: alice, + protocolDefinition, + }); + + const reply = await protocolsConfigureHandler.handle({ tenant: alice.did, message }); + expect(reply.status.code).to.equal(202); + }); + it('should allow a protocol definition with schema or dataFormat omitted', async () => { const alice = await DidKeyResolver.generate(); diff --git a/tests/handlers/records-delete.spec.ts b/tests/handlers/records-delete.spec.ts index d5d192310..a3d1ce018 100644 --- a/tests/handlers/records-delete.spec.ts +++ b/tests/handlers/records-delete.spec.ts @@ -71,6 +71,25 @@ export function testRecordsDeleteHandler(): void { await dwn.close(); }); + it('should process a RecordsDelete with EventStream not set', async () => { + // eventStream not defined + const recordsDeleteHandler = new RecordsDeleteHandler(didResolver, messageStore, dataStore, eventLog); + + const alice = await DidKeyResolver.generate(); + + const { message: record, dataStream } = await TestDataGenerator.generateRecordsWrite({ author: alice }); + const writeReply = await dwn.processMessage(alice.did, record, { dataStream }); + expect(writeReply.status.code).to.equal(202); + + const { message } = await RecordsDelete.create({ + recordId : record.recordId, + signer : Jws.createSigner(alice) + }); + + const deleteReply = await recordsDeleteHandler.handle({ tenant: alice.did, message }); + expect(deleteReply.status.code).to.equal(202); + }); + it('should handle RecordsDelete successfully and return 404 if deleting a deleted record', async () => { const alice = await DidKeyResolver.generate(); diff --git a/tests/handlers/records-write.spec.ts b/tests/handlers/records-write.spec.ts index b5d7509a6..9f6a622ae 100644 --- a/tests/handlers/records-write.spec.ts +++ b/tests/handlers/records-write.spec.ts @@ -88,6 +88,17 @@ export function testRecordsWriteHandler(): void { await dwn.close(); }); + it('should process a RecordsWrite with EventStream not set', async () => { + // eventStream not defined + const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog); + + const alice = await DidKeyResolver.generate(); + + const { message, dataStream } = await TestDataGenerator.generateRecordsWrite({ author: alice }); + const writeReply = await recordsWriteHandler.handle({ tenant: alice.did, message, dataStream }); + expect(writeReply.status.code).to.equal(202); + }); + it('should only be able to overwrite existing record if new record has a later `messageTimestamp` value', async () => { // write a message into DB const author = await DidKeyResolver.generate(); diff --git a/tests/scenarios/subscriptions.spec.ts b/tests/scenarios/subscriptions.spec.ts index e7cfed24a..43ab294ca 100644 --- a/tests/scenarios/subscriptions.spec.ts +++ b/tests/scenarios/subscriptions.spec.ts @@ -24,7 +24,6 @@ export function testSubscriptionScenarios(): void { let eventLog: EventLog; let eventStream: EventStream; let dwn: Dwn; - // important to follow the `before` and `after` pattern to initialize and clean the stores in tests // so that different test suites can reuse the same backend store for testing before(async () => { From 8e5ecb5ed22333c5cc2b71390a9b7c15b2e05c59 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 17 Jan 2024 22:22:59 -0500 Subject: [PATCH 40/44] simplified more code, added comments, updated test --- src/event-log/event-emitter-stream.ts | 8 ++----- src/interfaces/records-delete.ts | 4 ++-- src/types/events-types.ts | 3 +++ tests/event-log/event-emitter-stream.spec.ts | 11 +++++----- tests/event-log/event-stream.spec.ts | 22 ++++++++++++++------ tests/interfaces/events-subscribe.spec.ts | 7 ++++++- tests/scenarios/subscriptions.spec.ts | 4 ++-- tests/test-event-stream.ts | 10 ++++----- 8 files changed, 41 insertions(+), 28 deletions(-) diff --git a/src/event-log/event-emitter-stream.ts b/src/event-log/event-emitter-stream.ts index bc358cf87..924032322 100644 --- a/src/event-log/event-emitter-stream.ts +++ b/src/event-log/event-emitter-stream.ts @@ -6,17 +6,13 @@ import { EventEmitter } from 'events'; const EVENTS_LISTENER_CHANNEL = 'events'; -type EventStreamEmitterConfig = { - emitter?: EventEmitter; -}; - export class EventEmitterStream implements EventStream { private eventEmitter: EventEmitter; private isOpen: boolean = false; - constructor(config?: EventStreamEmitterConfig) { + constructor() { // we capture the rejections and currently just log the errors that are produced - this.eventEmitter = config?.emitter || new EventEmitter({ captureRejections: true }); + this.eventEmitter = new EventEmitter({ captureRejections: true }); this.eventEmitter.on('error', this.eventError); } diff --git a/src/interfaces/records-delete.ts b/src/interfaces/records-delete.ts index 17b0b1ef9..702cbee0f 100644 --- a/src/interfaces/records-delete.ts +++ b/src/interfaces/records-delete.ts @@ -70,8 +70,8 @@ export class RecordsDelete extends AbstractMessage { } /** - * Indexed properties needed for MessageStore indexing. - */ + * Indexed properties needed for MessageStore indexing. + */ public constructIndexes( initialWrite: RecordsWriteMessage, ): KeyValues { diff --git a/src/types/events-types.ts b/src/types/events-types.ts index fb40bf32c..ecba9f913 100644 --- a/src/types/events-types.ts +++ b/src/types/events-types.ts @@ -3,6 +3,7 @@ import type { AuthorizationModel, GenericMessage, GenericMessageReply, MessageSu import type { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; import type { PaginationCursor, RangeCriterion, RangeFilter } from './query-types.js'; +// filters used when filtering for any type of Message across interfaces export type EventsMessageFilter = { interface?: string; method?: string; @@ -23,6 +24,8 @@ export type EventsRecordsFilter = { dateCreated?: RangeCriterion; }; +// a union type of the different types of filters a user can use when issuing an EventsQuery or EventsSubscribe +//TODO: simplify the EventsFilters to only the necessary in order to reduce complexity https://github.com/TBD54566975/dwn-sdk-js/issues/663 export type EventsFilter = EventsMessageFilter | EventsRecordsFilter | ProtocolsQueryFilter; export type EventsGetDescriptor = { diff --git a/tests/event-log/event-emitter-stream.spec.ts b/tests/event-log/event-emitter-stream.spec.ts index da4340c3f..9141a284b 100644 --- a/tests/event-log/event-emitter-stream.spec.ts +++ b/tests/event-log/event-emitter-stream.spec.ts @@ -1,5 +1,4 @@ -import { EventEmitter } from 'events'; - +import type { EventEmitter } from 'events'; import type { MessageStore } from '../../src/index.js'; import { EventEmitterStream } from '../../src/event-log/event-emitter-stream.js'; @@ -37,8 +36,8 @@ describe('EventEmitterStream', () => { }); it('should remove listeners when `close` method is used', async () => { - const emitter = new EventEmitter(); - eventStream = new EventEmitterStream({ emitter }); + eventStream = new EventEmitterStream(); + const emitter = (eventStream as any).eventEmitter as EventEmitter; // count the `events` listeners, which represents all listeners expect(emitter.listenerCount('events')).to.equal(0); @@ -53,8 +52,8 @@ describe('EventEmitterStream', () => { it('logs message when the emitter experiences an error', async () => { const eventErrorSpy = sinon.spy(EventEmitterStream.prototype as any, 'eventError'); - const emitter = new EventEmitter({ captureRejections: true }); - eventStream = new EventEmitterStream({ emitter }); + eventStream = new EventEmitterStream(); + const emitter = (eventStream as any).eventEmitter as EventEmitter; emitter.emit('error', new Error('random error')); expect(eventErrorSpy.callCount).to.equal(1); }); diff --git a/tests/event-log/event-stream.spec.ts b/tests/event-log/event-stream.spec.ts index ef4cd4e1d..48b2eaa3c 100644 --- a/tests/event-log/event-stream.spec.ts +++ b/tests/event-log/event-stream.spec.ts @@ -23,12 +23,20 @@ describe('EventStream', () => { }); it('emits all messages to each subscriptions', async () => { - const messageCids: string[] = []; - const handler = async (_tenant: string, message: GenericMessage, _indexes: KeyValues): Promise => { + const messageCids1: string[] = []; + const handler1 = async (_tenant: string, message: GenericMessage, _indexes: KeyValues): Promise => { const messageCid = await Message.getCid(message); - messageCids.push(messageCid); + messageCids1.push(messageCid); }; - const subcription = await eventStream.subscribe('sub-1', handler); + + const messageCids2: string[] = []; + const handler2 = async (_tenant: string, message: GenericMessage, _indexes: KeyValues): Promise => { + const messageCid = await Message.getCid(message); + messageCids2.push(messageCid); + }; + + const subscription1 = await eventStream.subscribe('sub-1', handler1); + const subscription2 = await eventStream.subscribe('sub-2', handler2); const message1 = await TestDataGenerator.generateRecordsWrite({}); const message1Cid = await Message.getCid(message1.message); @@ -40,11 +48,13 @@ describe('EventStream', () => { const message3Cid = await Message.getCid(message3.message); eventStream.emit('did:alice', message3.message, {}); - await subcription.close(); + await subscription1.close(); + await subscription2.close(); await Time.minimalSleep(); - expect(messageCids).to.have.members([ message1Cid, message2Cid, message3Cid ]); + expect(messageCids1).to.have.members([ message1Cid, message2Cid, message3Cid ]); + expect(messageCids2).to.have.members([ message1Cid, message2Cid, message3Cid ]); }); it('does not emit messages if subscription is closed', async () => { diff --git a/tests/interfaces/events-subscribe.spec.ts b/tests/interfaces/events-subscribe.spec.ts index e971aa6df..09a531012 100644 --- a/tests/interfaces/events-subscribe.spec.ts +++ b/tests/interfaces/events-subscribe.spec.ts @@ -1,3 +1,4 @@ +import { authorizeOwner } from '../../src/core/auth.js'; import { EventsSubscribe } from '../../src/interfaces/events-subscribe.js'; import { DidKeyResolver, DwnInterfaceName, DwnMethodName, Jws, Time } from '../../src/index.js'; @@ -8,15 +9,19 @@ describe('EventsSubscribe', () => { it('should be able to create and authorize EventsSubscribe', async () => { const alice = await DidKeyResolver.generate(); const timestamp = Time.getCurrentTimestamp(); - const { message } = await EventsSubscribe.create({ + const eventsSubscribe = await EventsSubscribe.create({ signer : Jws.createSigner(alice), messageTimestamp : timestamp, }); + const message = eventsSubscribe.message; expect(message.descriptor.interface).to.eql(DwnInterfaceName.Events); expect(message.descriptor.method).to.eql(DwnMethodName.Subscribe); expect(message.authorization).to.exist; expect(message.descriptor.messageTimestamp).to.equal(timestamp); + + // EventsSubscribe authorizes against owner + await authorizeOwner(alice.did, eventsSubscribe); }); }); }); diff --git a/tests/scenarios/subscriptions.spec.ts b/tests/scenarios/subscriptions.spec.ts index 43ab294ca..3e613f420 100644 --- a/tests/scenarios/subscriptions.spec.ts +++ b/tests/scenarios/subscriptions.spec.ts @@ -266,11 +266,11 @@ export function testSubscriptionScenarios(): void { expect(write2Proto2Response.status.code).equals(202); // proto1 messages from handler do not change. - expect(proto1Messages.length).to.equal(1, 'proto1 after subscription.off()'); + expect(proto1Messages.length).to.equal(1, 'proto1 after subscription.close()'); expect(proto1Messages).to.include(await Message.getCid(write1proto1.message)); //proto2 messages from handler have the new message. - expect(proto2Messages.length).to.equal(2, 'proto2 after subscription.off()'); + expect(proto2Messages.length).to.equal(2, 'proto2 after subscription.close()'); expect(proto2Messages).to.have.members([await Message.getCid(write1proto2.message), await Message.getCid(write2proto2.message)]); }); diff --git a/tests/test-event-stream.ts b/tests/test-event-stream.ts index 6cd4eb6e0..d27a9bfa1 100644 --- a/tests/test-event-stream.ts +++ b/tests/test-event-stream.ts @@ -3,16 +3,16 @@ import type { EventStream } from '../src/index.js'; import { EventEmitterStream } from '../src/index.js'; /** - * Class that manages store implementations for testing. + * Class that manages the EventStream implementation for testing. * This is intended to be extended as the single point of configuration - * that allows different store implementations to be swapped in - * to test compatibility with default/built-in store implementations. + * that allows different EventStream implementations to be swapped in + * to test compatibility with default/built-in implementation. */ export class TestEventStream { private static eventStream?: EventStream; /** - * Overrides test stores with given implementation. + * Overrides the event stream with a given implementation. * If not given, default implementation will be used. */ public static override(overrides?: { eventStream?: EventStream }): void { @@ -20,7 +20,7 @@ export class TestEventStream { } /** - * Initializes and return the stores used for running the test suite. + * Initializes and returns the event stream used for running the test suite. */ public static get(): EventStream { TestEventStream.eventStream ??= new EventEmitterStream(); From d21ab28ca8831c229fc6fa12c3fb1054a39a42d0 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 17 Jan 2024 22:28:54 -0500 Subject: [PATCH 41/44] improve coverage --- tests/handlers/permissions-request.spec.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/handlers/permissions-request.spec.ts b/tests/handlers/permissions-request.spec.ts index 4dbf59ba2..7c38d3f4f 100644 --- a/tests/handlers/permissions-request.spec.ts +++ b/tests/handlers/permissions-request.spec.ts @@ -57,6 +57,25 @@ export function testPermissionsRequestHandler(): void { await dwn.close(); }); + it('should process a PermissionsRequest with EventStream not set', async () => { + // eventStream not defined + const permissionsRevokeHandler = new PermissionsRequestHandler(didResolver, messageStore, eventLog); + + // scenario: Alice issues a grant to Bob, then she revokes the grant. + const alice = await DidKeyResolver.generate(); + const bob = await DidKeyResolver.generate(); + + // Alice issues a grant + const { message } = await TestDataGenerator.generatePermissionsRequest({ + author : alice, + grantedBy : alice.did, + grantedTo : bob.did, + grantedFor : alice.did, + }); + const permissionsGrantReply = await permissionsRevokeHandler.handle({ tenant: alice.did, message: message }); + expect(permissionsGrantReply.status.code).to.eq(202); + }); + it('should accept a PermissionsRequest with conditions omitted', async () => { // scenario: Bob sends a PermissionsRequest to Alice's DWN const alice = await DidKeyResolver.generate(); From c29d4fad6ff2be7893300861f506e1a410024f8b Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Thu, 18 Jan 2024 16:06:06 -0500 Subject: [PATCH 42/44] review updates, added more test scenarios --- src/handlers/permissions-grant.ts | 4 +- src/handlers/permissions-request.ts | 4 +- src/handlers/permissions-revoke.ts | 4 +- src/handlers/protocols-configure.ts | 4 +- src/handlers/records-delete.ts | 4 +- src/handlers/records-write.ts | 4 +- src/types/events-types.ts | 15 +- tests/event-log/event-emitter-stream.spec.ts | 5 +- tests/event-log/event-stream.spec.ts | 12 +- tests/handlers/permissions-grant.spec.ts | 16 -- tests/handlers/permissions-request.spec.ts | 19 --- tests/handlers/permissions-revoke.spec.ts | 29 ---- tests/handlers/protocols-configure.spec.ts | 17 -- tests/handlers/records-delete.spec.ts | 19 --- tests/handlers/records-write.spec.ts | 11 -- tests/scenarios/subscriptions.spec.ts | 165 ++++++++++++++++++- 16 files changed, 201 insertions(+), 131 deletions(-) diff --git a/src/handlers/permissions-grant.ts b/src/handlers/permissions-grant.ts index 69db855fb..97e40e9c9 100644 --- a/src/handlers/permissions-grant.ts +++ b/src/handlers/permissions-grant.ts @@ -49,7 +49,9 @@ export class PermissionsGrantHandler implements MethodHandler { await this.eventLog.append(tenant, messageCid, indexes); // only emit if the event stream is set - this.eventStream?.emit(tenant, message, indexes); + if (this.eventStream !== undefined) { + this.eventStream.emit(tenant, message, indexes); + } } return { diff --git a/src/handlers/permissions-request.ts b/src/handlers/permissions-request.ts index 98b9690cb..8e2efba68 100644 --- a/src/handlers/permissions-request.ts +++ b/src/handlers/permissions-request.ts @@ -53,7 +53,9 @@ export class PermissionsRequestHandler implements MethodHandler { await this.eventLog.append(tenant, messageCid, indexes); // only emit if the event stream is set - this.eventStream?.emit(tenant, message, indexes); + if (this.eventStream !== undefined) { + this.eventStream.emit(tenant, message, indexes); + } } return { diff --git a/src/handlers/permissions-revoke.ts b/src/handlers/permissions-revoke.ts index 4d7be7cda..9edefbedc 100644 --- a/src/handlers/permissions-revoke.ts +++ b/src/handlers/permissions-revoke.ts @@ -96,7 +96,9 @@ export class PermissionsRevokeHandler implements MethodHandler { await this.eventLog.append(tenant, await Message.getCid(message), indexes); // only emit if the event stream is set - this.eventStream?.emit(tenant, message, indexes); + if (this.eventStream !== undefined) { + this.eventStream.emit(tenant, message, indexes); + } // Delete existing revokes which are all newer than the incoming message const removedRevokeCids: string[] = []; diff --git a/src/handlers/protocols-configure.ts b/src/handlers/protocols-configure.ts index 606ad88bc..38e95dff5 100644 --- a/src/handlers/protocols-configure.ts +++ b/src/handlers/protocols-configure.ts @@ -66,7 +66,9 @@ export class ProtocolsConfigureHandler implements MethodHandler { await this.eventLog.append(tenant, messageCid, indexes); // only emit if the event stream is set - this.eventStream?.emit(tenant, message, indexes); + if (this.eventStream !== undefined) { + this.eventStream.emit(tenant, message, indexes); + } messageReply = { status: { code: 202, detail: 'Accepted' } diff --git a/src/handlers/records-delete.ts b/src/handlers/records-delete.ts index a4d06b7a0..442c716e9 100644 --- a/src/handlers/records-delete.ts +++ b/src/handlers/records-delete.ts @@ -96,7 +96,9 @@ export class RecordsDeleteHandler implements MethodHandler { await this.eventLog.append(tenant, messageCid, indexes); // only emit if the event stream is set - this.eventStream?.emit(tenant, message, indexes); + if (this.eventStream !== undefined) { + this.eventStream.emit(tenant, message, indexes); + } // delete all existing messages that are not newest, except for the initial write await StorageController.deleteAllOlderMessagesButKeepInitialWrite( diff --git a/src/handlers/records-write.ts b/src/handlers/records-write.ts index e84e842ec..65313c875 100644 --- a/src/handlers/records-write.ts +++ b/src/handlers/records-write.ts @@ -132,7 +132,9 @@ export class RecordsWriteHandler implements MethodHandler { await this.eventLog.append(tenant, await Message.getCid(message), indexes); // only emit if the event stream is set - this.eventStream?.emit(tenant, message, indexes); + if (this.eventStream !== undefined) { + this.eventStream.emit(tenant, message, indexes); + } } catch (error) { const e = error as any; if (e.code === DwnErrorCode.RecordsWriteMissingEncodedDataInPrevious || diff --git a/src/types/events-types.ts b/src/types/events-types.ts index ecba9f913..d8d4c5985 100644 --- a/src/types/events-types.ts +++ b/src/types/events-types.ts @@ -3,14 +3,18 @@ import type { AuthorizationModel, GenericMessage, GenericMessageReply, MessageSu import type { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; import type { PaginationCursor, RangeCriterion, RangeFilter } from './query-types.js'; -// filters used when filtering for any type of Message across interfaces +/** + * filters used when filtering for any type of Message across interfaces + */ export type EventsMessageFilter = { interface?: string; method?: string; dateUpdated?: RangeCriterion; }; -// We only allow filtering for events by immutable properties, the omitted properties could be different per subsequent writes. +/** + * We only allow filtering for events by immutable properties, the omitted properties could be different per subsequent writes. + */ export type EventsRecordsFilter = { recipient?: string; protocol?: string; @@ -24,8 +28,11 @@ export type EventsRecordsFilter = { dateCreated?: RangeCriterion; }; -// a union type of the different types of filters a user can use when issuing an EventsQuery or EventsSubscribe -//TODO: simplify the EventsFilters to only the necessary in order to reduce complexity https://github.com/TBD54566975/dwn-sdk-js/issues/663 + +/** + * A union type of the different types of filters a user can use when issuing an EventsQuery or EventsSubscribe + * TODO: simplify the EventsFilters to only the necessary in order to reduce complexity https://github.com/TBD54566975/dwn-sdk-js/issues/663 + */ export type EventsFilter = EventsMessageFilter | EventsRecordsFilter | ProtocolsQueryFilter; export type EventsGetDescriptor = { diff --git a/tests/event-log/event-emitter-stream.spec.ts b/tests/event-log/event-emitter-stream.spec.ts index 9141a284b..947729cc8 100644 --- a/tests/event-log/event-emitter-stream.spec.ts +++ b/tests/event-log/event-emitter-stream.spec.ts @@ -1,4 +1,3 @@ -import type { EventEmitter } from 'events'; import type { MessageStore } from '../../src/index.js'; import { EventEmitterStream } from '../../src/event-log/event-emitter-stream.js'; @@ -37,7 +36,7 @@ describe('EventEmitterStream', () => { it('should remove listeners when `close` method is used', async () => { eventStream = new EventEmitterStream(); - const emitter = (eventStream as any).eventEmitter as EventEmitter; + const emitter = eventStream['eventEmitter']; // count the `events` listeners, which represents all listeners expect(emitter.listenerCount('events')).to.equal(0); @@ -53,7 +52,7 @@ describe('EventEmitterStream', () => { it('logs message when the emitter experiences an error', async () => { const eventErrorSpy = sinon.spy(EventEmitterStream.prototype as any, 'eventError'); eventStream = new EventEmitterStream(); - const emitter = (eventStream as any).eventEmitter as EventEmitter; + const emitter = eventStream['eventEmitter']; emitter.emit('error', new Error('random error')); expect(eventErrorSpy.callCount).to.equal(1); }); diff --git a/tests/event-log/event-stream.spec.ts b/tests/event-log/event-stream.spec.ts index 48b2eaa3c..5299369f2 100644 --- a/tests/event-log/event-stream.spec.ts +++ b/tests/event-log/event-stream.spec.ts @@ -63,12 +63,12 @@ describe('EventStream', () => { const messageCid = await Message.getCid(message); messageCids.push(messageCid); }; - const subcription = await eventStream.subscribe('sub-1', handler); + const subscription = await eventStream.subscribe('sub-1', handler); const message1 = await TestDataGenerator.generateRecordsWrite({}); const message1Cid = await Message.getCid(message1.message); eventStream.emit('did:alice', message1.message, {}); - await subcription.close(); + await subscription.close(); const message2 = await TestDataGenerator.generateRecordsWrite({}); eventStream.emit('did:alice', message2.message, {}); @@ -78,15 +78,15 @@ describe('EventStream', () => { expect(messageCids).to.have.members([ message1Cid ]); }); - it('does not emit messages if emitter is closed', async () => { + it('does not emit messages if event stream is closed', async () => { const messageCids: string[] = []; const handler = async (_tenant: string, message: GenericMessage, _indexes: KeyValues): Promise => { const messageCid = await Message.getCid(message); messageCids.push(messageCid); }; - const subcription = await eventStream.subscribe('sub-1', handler); + const subscription = await eventStream.subscribe('sub-1', handler); - // close eventEmitter + // close eventStream await eventStream.close(); const message1 = await TestDataGenerator.generateRecordsWrite({}); @@ -94,7 +94,7 @@ describe('EventStream', () => { const message2 = await TestDataGenerator.generateRecordsWrite({}); eventStream.emit('did:alice', message2.message, {}); - await subcription.close(); + await subscription.close(); await Time.minimalSleep(); expect(messageCids).to.have.length(0); diff --git a/tests/handlers/permissions-grant.spec.ts b/tests/handlers/permissions-grant.spec.ts index 1feb4d8bf..b2c9c7328 100644 --- a/tests/handlers/permissions-grant.spec.ts +++ b/tests/handlers/permissions-grant.spec.ts @@ -60,22 +60,6 @@ export function testPermissionsGrantHandler(): void { await dwn.close(); }); - it('should process a PermissionsGrant with EventStream not set', async () => { - // eventStream not defined - const permissionsGrantHandler = new PermissionsGrantHandler(didResolver, messageStore, eventLog); - - const alice = await DidKeyResolver.generate(); - - const { message } = await TestDataGenerator.generatePermissionsGrant({ - author : alice, - grantedBy : alice.did, - grantedFor : alice.did, - }); - - const reply = await permissionsGrantHandler.handle({ tenant: alice.did, message }); - expect(reply.status.code).to.equal(202); - }); - it('should accept a PermissionsGrant with permissionsRequestId omitted', async () => { const alice = await DidKeyResolver.generate(); diff --git a/tests/handlers/permissions-request.spec.ts b/tests/handlers/permissions-request.spec.ts index 7c38d3f4f..4dbf59ba2 100644 --- a/tests/handlers/permissions-request.spec.ts +++ b/tests/handlers/permissions-request.spec.ts @@ -57,25 +57,6 @@ export function testPermissionsRequestHandler(): void { await dwn.close(); }); - it('should process a PermissionsRequest with EventStream not set', async () => { - // eventStream not defined - const permissionsRevokeHandler = new PermissionsRequestHandler(didResolver, messageStore, eventLog); - - // scenario: Alice issues a grant to Bob, then she revokes the grant. - const alice = await DidKeyResolver.generate(); - const bob = await DidKeyResolver.generate(); - - // Alice issues a grant - const { message } = await TestDataGenerator.generatePermissionsRequest({ - author : alice, - grantedBy : alice.did, - grantedTo : bob.did, - grantedFor : alice.did, - }); - const permissionsGrantReply = await permissionsRevokeHandler.handle({ tenant: alice.did, message: message }); - expect(permissionsGrantReply.status.code).to.eq(202); - }); - it('should accept a PermissionsRequest with conditions omitted', async () => { // scenario: Bob sends a PermissionsRequest to Alice's DWN const alice = await DidKeyResolver.generate(); diff --git a/tests/handlers/permissions-revoke.spec.ts b/tests/handlers/permissions-revoke.spec.ts index a134ff3d7..73be4efe1 100644 --- a/tests/handlers/permissions-revoke.spec.ts +++ b/tests/handlers/permissions-revoke.spec.ts @@ -9,7 +9,6 @@ import { Dwn } from '../../src/dwn.js'; import { DwnErrorCode } from '../../src/core/dwn-error.js'; import { Message } from '../../src/core/message.js'; import { PermissionsRevoke } from '../../src/interfaces/permissions-revoke.js'; -import { PermissionsRevokeHandler } from '../../src/handlers/permissions-revoke.js'; import { TestDataGenerator } from '../utils/test-data-generator.js'; import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; @@ -52,34 +51,6 @@ describe('PermissionsRevokeHandler.handle()', () => { await dwn.close(); }); - it('should process a PermissionsRevoke with EventStream not set', async () => { - // eventStream not defined - const permissionsRevokeHandler = new PermissionsRevokeHandler(didResolver, messageStore, eventLog); - - // scenario: Alice issues a grant to Bob, then she revokes the grant. - const alice = await DidKeyResolver.generate(); - const bob = await DidKeyResolver.generate(); - - - // Alice issues a grant - const { permissionsGrant } = await TestDataGenerator.generatePermissionsGrant({ - author : alice, - grantedBy : alice.did, - grantedTo : bob.did, - grantedFor : alice.did, - }); - const permissionsGrantReply = await dwn.processMessage(alice.did, permissionsGrant.message); - expect(permissionsGrantReply.status.code).to.eq(202); - - // Alice revokes the grant - const { permissionsRevoke } = await TestDataGenerator.generatePermissionsRevoke({ - author : alice, - permissionsGrantId : await Message.getCid(permissionsGrant.message) - }); - const permissionsRevokeReply = await permissionsRevokeHandler.handle({ tenant: alice.did, message: permissionsRevoke.message }); - expect(permissionsRevokeReply.status.code).to.eq(202); - }); - it('should accept a PermissionsRevoke that revokes an existing grant', async () => { // scenario: Alice issues a grant to Bob, then she revokes the grant. const alice = await DidKeyResolver.generate(); diff --git a/tests/handlers/protocols-configure.spec.ts b/tests/handlers/protocols-configure.spec.ts index 2d46afd0c..7d56d2dc8 100644 --- a/tests/handlers/protocols-configure.spec.ts +++ b/tests/handlers/protocols-configure.spec.ts @@ -18,7 +18,6 @@ import { DidKeyResolver } from '../../src/did/did-key-resolver.js'; import { GeneralJwsBuilder } from '../../src/jose/jws/general/builder.js'; import { lexicographicalCompare } from '../../src/utils/string.js'; import { Message } from '../../src/core/message.js'; -import { ProtocolsConfigureHandler } from '../../src/handlers/protocols-configure.js'; import { TestDataGenerator } from '../utils/test-data-generator.js'; import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; @@ -68,22 +67,6 @@ export function testProtocolsConfigureHandler(): void { await dwn.close(); }); - it('should process a ProtocolsConfigure with EventStream not set', async () => { - // eventStream not defined - const protocolsConfigureHandler = new ProtocolsConfigureHandler(didResolver, messageStore, eventLog); - - const alice = await DidKeyResolver.generate(); - - const protocolDefinition = minimalProtocolDefinition; - const { message } = await TestDataGenerator.generateProtocolsConfigure({ - author: alice, - protocolDefinition, - }); - - const reply = await protocolsConfigureHandler.handle({ tenant: alice.did, message }); - expect(reply.status.code).to.equal(202); - }); - it('should allow a protocol definition with schema or dataFormat omitted', async () => { const alice = await DidKeyResolver.generate(); diff --git a/tests/handlers/records-delete.spec.ts b/tests/handlers/records-delete.spec.ts index a3d1ce018..d5d192310 100644 --- a/tests/handlers/records-delete.spec.ts +++ b/tests/handlers/records-delete.spec.ts @@ -71,25 +71,6 @@ export function testRecordsDeleteHandler(): void { await dwn.close(); }); - it('should process a RecordsDelete with EventStream not set', async () => { - // eventStream not defined - const recordsDeleteHandler = new RecordsDeleteHandler(didResolver, messageStore, dataStore, eventLog); - - const alice = await DidKeyResolver.generate(); - - const { message: record, dataStream } = await TestDataGenerator.generateRecordsWrite({ author: alice }); - const writeReply = await dwn.processMessage(alice.did, record, { dataStream }); - expect(writeReply.status.code).to.equal(202); - - const { message } = await RecordsDelete.create({ - recordId : record.recordId, - signer : Jws.createSigner(alice) - }); - - const deleteReply = await recordsDeleteHandler.handle({ tenant: alice.did, message }); - expect(deleteReply.status.code).to.equal(202); - }); - it('should handle RecordsDelete successfully and return 404 if deleting a deleted record', async () => { const alice = await DidKeyResolver.generate(); diff --git a/tests/handlers/records-write.spec.ts b/tests/handlers/records-write.spec.ts index 9f6a622ae..b5d7509a6 100644 --- a/tests/handlers/records-write.spec.ts +++ b/tests/handlers/records-write.spec.ts @@ -88,17 +88,6 @@ export function testRecordsWriteHandler(): void { await dwn.close(); }); - it('should process a RecordsWrite with EventStream not set', async () => { - // eventStream not defined - const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog); - - const alice = await DidKeyResolver.generate(); - - const { message, dataStream } = await TestDataGenerator.generateRecordsWrite({ author: alice }); - const writeReply = await recordsWriteHandler.handle({ tenant: alice.did, message, dataStream }); - expect(writeReply.status.code).to.equal(202); - }); - it('should only be able to overwrite existing record if new record has a later `messageTimestamp` value', async () => { // write a message into DB const author = await DidKeyResolver.generate(); diff --git a/tests/scenarios/subscriptions.spec.ts b/tests/scenarios/subscriptions.spec.ts index 3e613f420..ef417f89c 100644 --- a/tests/scenarios/subscriptions.spec.ts +++ b/tests/scenarios/subscriptions.spec.ts @@ -12,7 +12,7 @@ import { TestDataGenerator } from '../utils/test-data-generator.js'; import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { Time } from '../../src/utils/time.js'; -import { DidKeyResolver, DidResolver, Dwn, Message } from '../../src/index.js'; +import { DidKeyResolver, DidResolver, Dwn, DwnInterfaceName, DwnMethodName, Message } from '../../src/index.js'; import { expect } from 'chai'; @@ -102,6 +102,169 @@ export function testSubscriptionScenarios(): void { expect(messageCids).to.eql([ write1MessageCid, grant1MessageCid, protocol1MessageCid, delete1MessageCid ]); }); + it('filters by interface type', async () => { + // scenario: + // alice subscribes to 3 different message interfaces (Permissions, Records, Grants) + // alice creates (3) messages, (RecordsWrite, PermissionsGrant and ProtocolsConfigure + // alice checks that each handler emitted the appropriate message + // alice deletes the record, and revokes the grant + // alice checks that the Records and Permissions handlers emitted the appropriate message + const alice = await DidKeyResolver.generate(); + + // subscribe to records + const recordsInterfaceSubscription = await TestDataGenerator.generateEventsSubscribe({ + author : alice, + filters : [{ interface: DwnInterfaceName.Records }] + }); + const recordsMessageCids:string[] = []; + const recordsSubscribeHandler = async (message: GenericMessage):Promise => { + const messageCid = await Message.getCid(message); + recordsMessageCids.push(messageCid); + }; + + const recordsInterfaceSubscriptionReply = await dwn.processMessage( + alice.did, + recordsInterfaceSubscription.message, + { subscriptionHandler: recordsSubscribeHandler } + ); + expect(recordsInterfaceSubscriptionReply.status.code).to.equal(200); + expect(recordsInterfaceSubscriptionReply.subscription).to.exist; + + // subscribe to permissions + const permissionsInterfaceSubscription = await TestDataGenerator.generateEventsSubscribe({ + author : alice, + filters : [{ interface: DwnInterfaceName.Permissions }] + }); + const permissionsMessageCids:string[] = []; + const permissionsSubscribeHandler = async (message: GenericMessage):Promise => { + const messageCid = await Message.getCid(message); + permissionsMessageCids.push(messageCid); + }; + + const permissionsInterfaceSubscriptionReply = await dwn.processMessage( + alice.did, + permissionsInterfaceSubscription.message, + { subscriptionHandler: permissionsSubscribeHandler } + ); + expect(permissionsInterfaceSubscriptionReply.status.code).to.equal(200); + expect(permissionsInterfaceSubscriptionReply.subscription).to.exist; + + // subscribe to protocols + const protocolsInterfaceSubscription = await TestDataGenerator.generateEventsSubscribe({ + author : alice, + filters : [{ interface: DwnInterfaceName.Protocols }] + }); + const protocolsMessageCids:string[] = []; + const protocolsSubscribeHandler = async (message: GenericMessage):Promise => { + const messageCid = await Message.getCid(message); + protocolsMessageCids.push(messageCid); + }; + + const protocolsInterfaceSubscriptionReply = await dwn.processMessage( + alice.did, + protocolsInterfaceSubscription.message, + { subscriptionHandler: protocolsSubscribeHandler } + ); + expect(protocolsInterfaceSubscriptionReply.status.code).to.equal(200); + expect(protocolsInterfaceSubscriptionReply.subscription).to.exist; + + // create one of each message types + const record = await TestDataGenerator.generateRecordsWrite({ author: alice }); + const grant = await TestDataGenerator.generatePermissionsGrant({ author: alice }); + const protocol = await TestDataGenerator.generateProtocolsConfigure({ author: alice }); + + // insert data + const recordReply = await dwn.processMessage(alice.did, record.message, { dataStream: record.dataStream }); + const grantReply = await dwn.processMessage(alice.did, grant.message); + const protocolReply = await dwn.processMessage(alice.did, protocol.message); + expect(recordReply.status.code).to.equal(202, 'RecordsWrite'); + expect(grantReply.status.code).to.equal(202, 'PermissionsGrant'); + expect(protocolReply.status.code).to.equal(202, 'ProtocolConfigure'); + + // check record message + expect(recordsMessageCids.length).to.equal(1); + expect(recordsMessageCids).to.have.members([ await Message.getCid(record.message) ]); + + // check permissions message + expect(permissionsMessageCids.length).to.equal(1); + expect(permissionsMessageCids).to.have.members([ await Message.getCid(grant.message) ]); + + // check protocols message + expect(protocolsMessageCids.length).to.equal(1); + expect(protocolsMessageCids).to.have.members([ await Message.getCid(protocol.message) ]); + + // insert additional data to query beyond a cursor + const recordDelete = await TestDataGenerator.generateRecordsDelete({ author: alice, recordId: record.message.recordId }); + const revokeGrant = await TestDataGenerator.generatePermissionsRevoke({ + author: alice, permissionsGrantId: await Message.getCid(grant.message) + }); + const recordDeleteReply = await dwn.processMessage(alice.did, recordDelete.message); + const revokeGrantReply = await dwn.processMessage(alice.did, revokeGrant.message); + expect(recordDeleteReply.status.code).to.equal(202, 'RecordsDelete'); + expect(revokeGrantReply.status.code).to.equal(202, 'PermissionsRevoke'); + + // check record messages to include the delete message + expect(recordsMessageCids.length).to.equal(2); + expect(recordsMessageCids).to.include.members([ await Message.getCid(recordDelete.message) ]); + + // check permissions messages to include the revoke message + expect(permissionsMessageCids.length).to.equal(2); + expect(permissionsMessageCids).to.include.members([ await Message.getCid(revokeGrant.message) ]); + + // protocols remains unchanged + expect(protocolsMessageCids.length).to.equal(1); + }); + + it('filters by method type', async () => { + // scenario: + // alice creates a variety of Messages (RecordsWrite, RecordsDelete, ProtocolConfigure, PermissionsGrant) + // alice queries for only RecordsWrite messages + // alice creates more messages to query beyond a cursor + + const alice = await DidKeyResolver.generate(); + + // subscribe to records write + const recordsWriteSubscription = await TestDataGenerator.generateEventsSubscribe({ + author : alice, + filters : [{ interface: DwnInterfaceName.Records, method: DwnMethodName.Write }] + }); + const recordsMessageCids:string[] = []; + const recordsSubscribeHandler = async (message: GenericMessage):Promise => { + const messageCid = await Message.getCid(message); + recordsMessageCids.push(messageCid); + }; + + const recordsWriteSubscriptionReply = await dwn.processMessage( + alice.did, + recordsWriteSubscription.message, + { subscriptionHandler: recordsSubscribeHandler } + ); + expect(recordsWriteSubscriptionReply.status.code).to.equal(200); + expect(recordsWriteSubscriptionReply.subscription).to.exist; + + // create one of each message types + const record = await TestDataGenerator.generateRecordsWrite({ author: alice }); + + // insert data + const recordReply = await dwn.processMessage(alice.did, record.message, { dataStream: record.dataStream }); + expect(recordReply.status.code).to.equal(202, 'RecordsWrite'); + + // sleep to make sure event was processed and added to array asynchronously + await Time.minimalSleep(); + + // check record message + expect(recordsMessageCids.length).to.equal(1); + expect(recordsMessageCids).to.have.members([ await Message.getCid(record.message) ]); + + // delete the message + const recordDelete = await TestDataGenerator.generateRecordsDelete({ author: alice, recordId: record.message.recordId }); + const recordDeleteReply = await dwn.processMessage(alice.did, recordDelete.message); + expect(recordDeleteReply.status.code).to.equal(202, 'RecordsDelete'); + + // check record messages remain unchanged and do not include the delete since we only subscribe to writes + expect(recordsMessageCids.length).to.equal(1); + }); + it('filters by schema', async () => { const alice = await DidKeyResolver.generate(); From b583140b4285d3f823dd1a5e76fe6212c2b2d1e5 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Thu, 18 Jan 2024 20:51:59 -0500 Subject: [PATCH 43/44] scenario tests, review comment updates --- package.json | 2 +- src/event-log/event-emitter-stream.ts | 2 +- src/types/events-types.ts | 3 +- src/utils/events.ts | 23 +- tests/event-log/event-stream.spec.ts | 6 + tests/scenarios/subscriptions.spec.ts | 524 ++++++++++++++++++++++---- 6 files changed, 454 insertions(+), 106 deletions(-) diff --git a/package.json b/package.json index 8d2b3898a..cf91c82e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tbd54566975/dwn-sdk-js", - "version": "0.2.12", + "version": "0.2.13", "description": "A reference implementation of https://identity.foundation/decentralized-web-node/spec/", "repository": { "type": "git", diff --git a/src/event-log/event-emitter-stream.ts b/src/event-log/event-emitter-stream.ts index 924032322..787e38cca 100644 --- a/src/event-log/event-emitter-stream.ts +++ b/src/event-log/event-emitter-stream.ts @@ -41,7 +41,7 @@ export class EventEmitterStream implements EventStream { emit(tenant: string, message: GenericMessage, indexes: KeyValues): void { if (!this.isOpen) { - // silently ignore + console.error('message emitted when EventEmitterStream is closed', tenant, message, indexes); return; } this.eventEmitter.emit(EVENTS_LISTENER_CHANNEL, tenant, message, indexes); diff --git a/src/types/events-types.ts b/src/types/events-types.ts index d8d4c5985..6856c6972 100644 --- a/src/types/events-types.ts +++ b/src/types/events-types.ts @@ -1,4 +1,3 @@ -import type { ProtocolsQueryFilter } from './protocols-types.js'; import type { AuthorizationModel, GenericMessage, GenericMessageReply, MessageSubscription, MessageSubscriptionHandler } from './message-types.js'; import type { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; import type { PaginationCursor, RangeCriterion, RangeFilter } from './query-types.js'; @@ -33,7 +32,7 @@ export type EventsRecordsFilter = { * A union type of the different types of filters a user can use when issuing an EventsQuery or EventsSubscribe * TODO: simplify the EventsFilters to only the necessary in order to reduce complexity https://github.com/TBD54566975/dwn-sdk-js/issues/663 */ -export type EventsFilter = EventsMessageFilter | EventsRecordsFilter | ProtocolsQueryFilter; +export type EventsFilter = EventsMessageFilter | EventsRecordsFilter; export type EventsGetDescriptor = { interface: DwnInterfaceName.Events; diff --git a/src/utils/events.ts b/src/utils/events.ts index 199263849..f66c56dc0 100644 --- a/src/utils/events.ts +++ b/src/utils/events.ts @@ -1,9 +1,7 @@ import type { Filter } from '../types/query-types.js'; -import type { ProtocolsQueryFilter } from '../types/protocols-types.js'; import type { EventsFilter, EventsMessageFilter, EventsRecordsFilter } from '../types/events-types.js'; import { FilterUtility } from '../utils/filter.js'; -import { ProtocolsQuery } from '../interfaces/protocols-query.js'; import { Records } from '../utils/records.js'; import { isEmptyObject, removeUndefinedProperties } from './object.js'; @@ -21,8 +19,6 @@ export class Events { let eventsFilter: EventsFilter; if (this.isRecordsFilter(filter)) { eventsFilter = Records.normalizeFilter(filter); - } else if (this.isProtocolFilter(filter)) { - eventsFilter = ProtocolsQuery.normalizeFilter(filter); } else { // no normalization needed eventsFilter = filter; @@ -52,16 +48,12 @@ export class Events { // convert each filter individually by the specific type of filter it is // we must check for the type of filter in a specific order to make a reductive decision as to which filters need converting // first we check for `EventsRecordsFilter` fields for conversion - // then we check for the `EventsMessageFilter` fields for conversion - // finally we pass through the filters as `ProtocolQueryFilter` does not require conversion + // otherwise it is `EventsMessageFilter` fields for conversion for (const filter of filters) { if (this.isRecordsFilter(filter)) { eventsQueryFilters.push(Records.convertFilter(filter)); - } else if (this.isMessagesFilter(filter)) { - eventsQueryFilters.push(this.convertFilter(filter)); } else { - // protocol filters do not need any conversion - eventsQueryFilters.push(filter); + eventsQueryFilters.push(this.convertFilter(filter)); } } @@ -80,10 +72,6 @@ export class Events { return filterCopy as Filter; } - private static isMessagesFilter(filter: EventsFilter): filter is EventsMessageFilter { - return 'method' in filter || 'interface' in filter || 'dateUpdated' in filter; - } - // we deliberately do not check for `dateUpdated` in this filter. // if it were the only property that matched, it could be handled by `EventMessageFilter` private static isRecordsFilter(filter: EventsFilter): filter is EventsRecordsFilter { @@ -94,11 +82,8 @@ export class Events { 'parentId' in filter || 'recordId' in filter || 'schema' in filter || - ('protocolPath' in filter && 'protocol' in filter) || + 'protocol' in filter || + 'protocolPath' in filter || 'recipient' in filter; } - - private static isProtocolFilter(filter: EventsFilter): filter is ProtocolsQueryFilter { - return 'protocol' in filter; - } } \ No newline at end of file diff --git a/tests/event-log/event-stream.spec.ts b/tests/event-log/event-stream.spec.ts index 5299369f2..1ebc81857 100644 --- a/tests/event-log/event-stream.spec.ts +++ b/tests/event-log/event-stream.spec.ts @@ -10,14 +10,20 @@ import chai, { expect } from 'chai'; chai.use(chaiAsPromised); describe('EventStream', () => { + // saving the original `console.error` function to re-assign after tests complete + const originalConsoleErrorFunction = console.error; let eventStream: EventStream; before(async () => { eventStream = TestEventStream.get(); await eventStream.open(); + + // do not print the console error statements from the emitter error + console.error = (_):void => { }; }); after(async () => { + console.error = originalConsoleErrorFunction; // Clean up after each test by closing and clearing the event stream await eventStream.close(); }); diff --git a/tests/scenarios/subscriptions.spec.ts b/tests/scenarios/subscriptions.spec.ts index ef417f89c..dae65cfb3 100644 --- a/tests/scenarios/subscriptions.spec.ts +++ b/tests/scenarios/subscriptions.spec.ts @@ -7,12 +7,13 @@ import type { } from '../../src/index.js'; import freeForAll from '../vectors/protocol-definitions/free-for-all.json' assert { type: 'json' }; +import threadProtocol from '../vectors/protocol-definitions/thread-role.json' assert { type: 'json' }; import { TestDataGenerator } from '../utils/test-data-generator.js'; import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { Time } from '../../src/utils/time.js'; -import { DidKeyResolver, DidResolver, Dwn, DwnInterfaceName, DwnMethodName, Message } from '../../src/index.js'; +import { DidKeyResolver, DidResolver, Dwn, DwnConstant, DwnInterfaceName, DwnMethodName, Message } from '../../src/index.js'; import { expect } from 'chai'; @@ -265,6 +266,261 @@ export function testSubscriptionScenarios(): void { expect(recordsMessageCids.length).to.equal(1); }); + it('filters by a protocol across different message types', async () => { + // scenario: + // alice creates (3) different message types all related to "proto1" (Configure, RecordsWrite, RecordsDelete) + // alice creates (3) different message types all related to "proto2" (Configure, RecordsWrite, RecordsDelete) + // when subscribing for a specific protocol, only Messages related to it should be emitted. + const alice = await DidKeyResolver.generate(); + + const proto1Messages:string[] = []; + const proto1Handler = async (message:GenericMessage):Promise => { + proto1Messages.push(await Message.getCid(message)); + }; + + const proto1Subscription = await TestDataGenerator.generateEventsSubscribe({ + author : alice, + filters : [{ protocol: 'http://proto1' }] + }); + const proto1SubscriptionReply = await dwn.processMessage(alice.did, proto1Subscription.message, { + subscriptionHandler: proto1Handler + }); + expect(proto1SubscriptionReply.status.code).to.equal(200); + expect(proto1SubscriptionReply.subscription).to.exist; + + const proto2Messages:string[] = []; + const proto2Handler = async (message:GenericMessage):Promise => { + proto2Messages.push(await Message.getCid(message)); + }; + + const proto2Subscription = await TestDataGenerator.generateEventsSubscribe({ + author : alice, + filters : [{ protocol: 'http://proto2' }] + }); + const proto2SubscriptionReply = await dwn.processMessage(alice.did, proto2Subscription.message, { + subscriptionHandler: proto2Handler + }); + expect(proto2SubscriptionReply.status.code).to.equal(200); + expect(proto2SubscriptionReply.subscription).to.exist; + + // create a proto1 + const protoConf1 = await TestDataGenerator.generateProtocolsConfigure({ + author : alice, + protocolDefinition : { ...freeForAll, protocol: 'http://proto1' } + }); + + const postProperties = { + protocolPath : 'post', + schema : freeForAll.types.post.schema, + dataFormat : freeForAll.types.post.dataFormats[0], + }; + + const proto1 = protoConf1.message.descriptor.definition.protocol; + const protoConf1Response = await dwn.processMessage(alice.did, protoConf1.message); + expect(protoConf1Response.status.code).equals(202); + + // create a proto2 + const protoConf2 = await TestDataGenerator.generateProtocolsConfigure({ + author : alice, + protocolDefinition : { ...freeForAll, protocol: 'http://proto2' } + }); + const proto2 = protoConf2.message.descriptor.definition.protocol; + const protoConf2Response = await dwn.processMessage(alice.did, protoConf2.message); + expect(protoConf2Response.status.code).equals(202); + + // create a record for proto1 + const write1proto1 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto1, ...postProperties }); + const write1Response = await dwn.processMessage(alice.did, write1proto1.message, { dataStream: write1proto1.dataStream }); + expect(write1Response.status.code).equals(202); + + // create a record for proto2 + const write1proto2 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto2, ...postProperties }); + const write1Proto2Response = await dwn.processMessage(alice.did, write1proto2.message, { dataStream: write1proto2.dataStream }); + expect(write1Proto2Response.status.code).equals(202); + + // check for proto1 messages + expect(proto1Messages.length).to.equal(2); + expect(proto1Messages).to.have.members([ await Message.getCid(protoConf1.message), await Message.getCid(write1proto1.message) ]); + + // check for proto2 messages + expect(proto2Messages.length).to.equal(2); + expect(proto2Messages).to.have.members([ await Message.getCid(protoConf2.message), await Message.getCid(write1proto2.message) ]); + + // delete proto1 message + const deleteProto1Message = await TestDataGenerator.generateRecordsDelete({ author: alice, recordId: write1proto1.message.recordId }); + const deleteProto1MessageReply = await dwn.processMessage(alice.did, deleteProto1Message.message); + expect(deleteProto1MessageReply.status.code).to.equal(202); + + // delete proto2 message + const deleteProto2Message = await TestDataGenerator.generateRecordsDelete({ author: alice, recordId: write1proto2.message.recordId }); + const deleteProto2MessageReply = await dwn.processMessage(alice.did, deleteProto2Message.message); + expect(deleteProto2MessageReply.status.code).to.equal(202); + + // check for the delete in proto1 messages + expect(proto1Messages.length).to.equal(3); + expect(proto1Messages).to.include.members([ await Message.getCid(deleteProto1Message.message) ]); + + // check for the delete in proto2 messages + expect(proto2Messages.length).to.equal(3); + expect(proto2Messages).to.include.members([ await Message.getCid(deleteProto2Message.message) ]); + }); + + it('filters by protocol & parentId across multiple protocolPaths', async () => { + // scenario: subscribe to multiple protocolPaths for a given protocol and parentId + // alice installs a protocol and creates a thread + // alice subscribes to update to that thread, it's participant as well as thread chats + // alice adds bob and carol as participants to the thread + // alice, bob, and carol all create messages + // alice deletes carol participant message + // alice checks that the correct messages were omitted + + const alice = await DidKeyResolver.generate(); + const bob = await DidKeyResolver.generate(); + const carol = await DidKeyResolver.generate(); + + // create protocol + const protocolConfigure = await TestDataGenerator.generateProtocolsConfigure({ + author : alice, + protocolDefinition : { ...threadProtocol } + }); + const protocolConfigureReply = await dwn.processMessage(alice.did, protocolConfigure.message); + expect(protocolConfigureReply.status.code).to.equal(202); + const protocol = protocolConfigure.message.descriptor.definition.protocol; + + // alice creates thread + const thread = await TestDataGenerator.generateRecordsWrite({ + author : alice, + protocol : protocol, + protocolPath : 'thread' + }); + const threadReply = await dwn.processMessage(alice.did, thread.message, { dataStream: thread.dataStream }); + expect(threadReply.status.code).to.equal(202); + + + // subscribe to this thread's events + const messages:string[] = []; + const subscriptionHandler = async (message:GenericMessage):Promise => { + messages.push(await Message.getCid(message)); + }; + + const threadSubscription = await TestDataGenerator.generateEventsSubscribe({ + author : alice, + filters : [ + { protocol: protocol, protocolPath: 'thread', parentId: thread.message.recordId }, // thread updates + { protocol: protocol, protocolPath: 'thread/participant', parentId: thread.message.recordId }, // participant updates + { protocol: protocol, protocolPath: 'thread/chat', parentId: thread.message.recordId } // chat updates + ], + }); + const threadSubscriptionReply = await dwn.processMessage(alice.did, threadSubscription.message, { + subscriptionHandler + }); + expect(threadSubscriptionReply.status.code).to.equal(200); + expect(threadSubscriptionReply.subscription).to.exist; + + // add bob as participant + const bobParticipant = await TestDataGenerator.generateRecordsWrite({ + author : alice, + recipient : bob.did, + parentId : thread.message.recordId, + contextId : thread.message.contextId, + protocol : protocol, + protocolPath : 'thread/participant' + }); + const bobParticipantReply = await dwn.processMessage(alice.did, bobParticipant.message, { dataStream: bobParticipant.dataStream }); + expect(bobParticipantReply.status.code).to.equal(202); + + // add carol as participant + const carolParticipant = await TestDataGenerator.generateRecordsWrite({ + author : alice, + recipient : carol.did, + parentId : thread.message.recordId, + contextId : thread.message.contextId, + protocol : protocol, + protocolPath : 'thread/participant' + }); + const carolParticipantReply = await dwn.processMessage(alice.did, carolParticipant.message, { dataStream: carolParticipant.dataStream }); + expect(carolParticipantReply.status.code).to.equal(202); + + // add another thread as a control, will not show up in handled events + const additionalThread = await TestDataGenerator.generateRecordsWrite({ + author : alice, + protocol : protocol, + protocolPath : 'thread' + }); + const additionalThreadReply = await dwn.processMessage(alice.did, additionalThread.message, { dataStream: additionalThread.dataStream }); + expect(additionalThreadReply.status.code).to.equal(202); + + // sleep to allow all messages to be processed by the handler message + await Time.minimalSleep(); + + expect(messages.length).to.equal(2); + expect(messages).to.have.members([ + await Message.getCid(bobParticipant.message), + await Message.getCid(carolParticipant.message), + ]); + + // add a message to protocol1 + const message1 = await TestDataGenerator.generateRecordsWrite({ + author : bob, + recipient : alice.did, + parentId : thread.message.recordId, + contextId : thread.message.contextId, + protocol : protocol, + protocolPath : 'thread/chat', + protocolRole : 'thread/participant', + }); + const message1Reply = await dwn.processMessage(alice.did, message1.message, { dataStream: message1.dataStream }); + expect(message1Reply.status.code).to.equal(202); + + const message2 = await TestDataGenerator.generateRecordsWrite({ + author : bob, + recipient : alice.did, + parentId : thread.message.recordId, + contextId : thread.message.contextId, + protocol : protocol, + protocolPath : 'thread/chat', + protocolRole : 'thread/participant', + }); + const message2Reply = await dwn.processMessage(alice.did, message2.message, { dataStream: message2.dataStream }); + expect(message2Reply.status.code).to.equal(202); + + const message3 = await TestDataGenerator.generateRecordsWrite({ + author : carol, + recipient : alice.did, + parentId : thread.message.recordId, + contextId : thread.message.contextId, + protocol : protocol, + protocolPath : 'thread/chat', + protocolRole : 'thread/participant', + }); + const message3Reply = await dwn.processMessage(alice.did, message3.message, { dataStream: message3.dataStream }); + expect(message3Reply.status.code).to.equal(202); + + // sleep in order to allow messages to process and check for the added messages + await Time.minimalSleep(); + expect(messages.length).to.equal(5); + expect(messages).to.include.members([ + await Message.getCid(message1.message), + await Message.getCid(message2.message), + await Message.getCid(message3.message), + ]); + + // delete carol participant + const deleteCarol = await TestDataGenerator.generateRecordsDelete({ + author : alice, + recordId : carolParticipant.message.recordId + }); + const deleteCarolReply = await dwn.processMessage(alice.did, deleteCarol.message); + expect(deleteCarolReply.status.code).to.equal(202); + + // sleep in order to allow messages to process and check for the delete message + await Time.minimalSleep(); + expect(messages.length).to.equal(6); + expect(messages).to.include.members([ + await Message.getCid(deleteCarol.message) + ]); + }); + it('filters by schema', async () => { const alice = await DidKeyResolver.generate(); @@ -338,103 +594,66 @@ export function testSubscriptionScenarios(): void { expect(schema2Messages).to.have.members([await Message.getCid(write1schema2.message), await Message.getCid(write2schema2.message)]); }); - it('filters by protocol', async () => { + it('filters by recordId', async () => { const alice = await DidKeyResolver.generate(); - // create a proto1 - const protoConf1 = await TestDataGenerator.generateProtocolsConfigure({ - author : alice, - protocolDefinition : { ...freeForAll, protocol: 'proto1' } + const write = await TestDataGenerator.generateRecordsWrite({ + author : alice, + schema : 'schema1' }); + const write1Reply = await dwn.processMessage(alice.did, write.message, { dataStream: write.dataStream }); + expect(write1Reply.status.code).to.equal(202); - const postProperties = { - protocolPath : 'post', - schema : freeForAll.types.post.schema, - dataFormat : freeForAll.types.post.dataFormats[0], + // create a subscription and capture the messages associated with the record + const messages: string[] = []; + const subscriptionHandler = async (message: GenericMessage):Promise => { + messages.push(await Message.getCid(message)); }; - // create a proto1 - const proto1 = protoConf1.message.descriptor.definition.protocol; - const protoConf1Response = await dwn.processMessage(alice.did, protoConf1.message); - expect(protoConf1Response.status.code).equals(202); - - // create a proto2 - const protoConf2 = await TestDataGenerator.generateProtocolsConfigure({ - author : alice, - protocolDefinition : { ...freeForAll, protocol: 'proto2' } + const recordIdSubscribe = await TestDataGenerator.generateEventsSubscribe({ + author : alice, + filters : [{ recordId: write.message.recordId }] }); - const proto2 = protoConf2.message.descriptor.definition.protocol; - const protoConf2Response = await dwn.processMessage(alice.did, protoConf2.message); - expect(protoConf2Response.status.code).equals(202); - - // we will add messageCids to these arrays as they are received by their handler to check against later - const proto1Messages:string[] = []; - const proto2Messages:string[] = []; - - // we add a handler to the subscription and add the messageCid to the appropriate array - const proto1Handler = async (message:GenericMessage):Promise => { - const messageCid = await Message.getCid(message); - proto1Messages.push(messageCid); - }; - - // subscribe to proto1 messages - const proto1Subscription = await TestDataGenerator.generateEventsSubscribe({ author: alice, filters: [{ protocol: proto1 }] }); - const proto1SubscriptionReply = await dwn.processMessage(alice.did, proto1Subscription.message, { subscriptionHandler: proto1Handler }); - expect(proto1SubscriptionReply.status.code).to.equal(200); - expect(proto1SubscriptionReply.subscription?.id).to.equal(await Message.getCid(proto1Subscription.message)); - - // we add a handler to the subscription and add the messageCid to the appropriate array - const proto2Handler = async (message:GenericMessage):Promise => { - const messageCid = await Message.getCid(message); - proto2Messages.push(messageCid); - }; - - // subscribe to proto2 messages - const proto2Subscription = await TestDataGenerator.generateEventsSubscribe({ author: alice, filters: [{ protocol: proto2 }] }); - const proto2SubscriptionReply = await dwn.processMessage(alice.did, proto2Subscription.message, { subscriptionHandler: proto2Handler }); - expect(proto2SubscriptionReply.status.code).to.equal(200); - expect(proto2SubscriptionReply.subscription?.id).to.equal(await Message.getCid(proto2Subscription.message)); - - // create some random record, will not show up in records subscription - const write1Random = await TestDataGenerator.generateRecordsWrite({ author: alice }); - const write1RandomResponse = await dwn.processMessage(alice.did, write1Random.message, { dataStream: write1Random.dataStream }); - expect(write1RandomResponse.status.code).to.equal(202); + const recordIdSubscribeReply = await dwn.processMessage(alice.did, recordIdSubscribe.message, { + subscriptionHandler + }); + expect(recordIdSubscribeReply.status.code).to.equal(200); - // create a record for proto1 - const write1proto1 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto1, ...postProperties }); - const write1Response = await dwn.processMessage(alice.did, write1proto1.message, { dataStream: write1proto1.dataStream }); - expect(write1Response.status.code).equals(202); + // a write as a control, will not show up in subscription + const controlWrite = await TestDataGenerator.generateRecordsWrite({ + author : alice, + schema : 'schema1' + }); + const write2Reply = await dwn.processMessage(alice.did, controlWrite.message, { dataStream: controlWrite.dataStream }); + expect(write2Reply.status.code).to.equal(202); - // create a record for proto2 - const write1proto2 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto2, ...postProperties }); - const write1Proto2Response = await dwn.processMessage(alice.did, write1proto2.message, { dataStream: write1proto2.dataStream }); - expect(write1Proto2Response.status.code).equals(202); + // update record + const update = await TestDataGenerator.generateFromRecordsWrite({ + author : alice, + existingWrite : write.recordsWrite, + }); + const updateReply = await dwn.processMessage(alice.did, update.message, { dataStream: update.dataStream }); + expect(updateReply.status.code).to.equal(202); - expect(proto1Messages.length).to.equal(1, 'proto1'); - expect(proto1Messages).to.include(await Message.getCid(write1proto1.message)); - expect(proto2Messages.length).to.equal(1, 'proto2'); - expect(proto2Messages).to.include(await Message.getCid(write1proto2.message)); - // remove listener for proto1 - proto1SubscriptionReply.subscription?.close(); + // sleep to allow all messages to be processed by the handler message + await Time.minimalSleep(); - // create another record for proto1 - const write2proto1 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto1, ...postProperties }); - const write2Response = await dwn.processMessage(alice.did, write2proto1.message, { dataStream: write2proto1.dataStream }); - expect(write2Response.status.code).equals(202); + expect(messages.length).to.equal(1); + expect(messages).to.have.members([ await Message.getCid(update.message) ]); - // create another record for proto2 - const write2proto2 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto2, ...postProperties }); - const write2Proto2Response = await dwn.processMessage(alice.did, write2proto2.message, { dataStream: write2proto2.dataStream }); - expect(write2Proto2Response.status.code).equals(202); + const deleteRecord = await TestDataGenerator.generateRecordsDelete({ + author : alice, + recordId : write.message.recordId, + }); + const deleteRecordReply = await dwn.processMessage(alice.did, deleteRecord.message); + expect(deleteRecordReply.status.code).to.equal(202); - // proto1 messages from handler do not change. - expect(proto1Messages.length).to.equal(1, 'proto1 after subscription.close()'); - expect(proto1Messages).to.include(await Message.getCid(write1proto1.message)); + // sleep to allow all messages to be processed by the handler message + await Time.minimalSleep(); - //proto2 messages from handler have the new message. - expect(proto2Messages.length).to.equal(2, 'proto2 after subscription.close()'); - expect(proto2Messages).to.have.members([await Message.getCid(write1proto2.message), await Message.getCid(write2proto2.message)]); + expect(messages.length).to.equal(2); + expect(messages).to.include.members([ await Message.getCid(deleteRecord.message) ]); }); it('filters by recipient', async () => { @@ -525,6 +744,145 @@ export function testSubscriptionScenarios(): void { expect(receivedMessages).to.not.include.members([ await Message.getCid(messageFromAliceToBob2.message)]); }); + it('filters by dataFormat', async () => { + // scenario: alice stores different file types and needs events relating to `image/jpeg` + // alice creates 3 files, one of them `image/jpeg` + // alice queries for `image/jpeg` retrieving the one message + // alice adds another image to query for using the prior image as a cursor + + const alice = await DidKeyResolver.generate(); + + const imageMessages: string[] = []; + const imageHandler = async (message:GenericMessage):Promise => { + imageMessages.push(await Message.getCid(message)); + }; + + const imageSubscription = await TestDataGenerator.generateEventsSubscribe({ + author : alice, + filters : [{ dataFormat: 'image/jpeg' }] + }); + const imageSubscriptionReply = await dwn.processMessage(alice.did, imageSubscription.message, { + subscriptionHandler: imageHandler + }); + expect(imageSubscriptionReply.status.code).to.equal(200); + + // write a text file + const textFile = await TestDataGenerator.generateRecordsWrite({ + author : alice, + dataFormat : 'application/text' + }); + const textFileReply = await dwn.processMessage(alice.did, textFile.message, { dataStream: textFile.dataStream }); + expect(textFileReply.status.code).to.equal(202); + + // write an application/json file + const jsonData = await TestDataGenerator.generateRecordsWrite({ + author : alice, + dataFormat : 'application/json' + }); + const jsonDataReply = await dwn.processMessage(alice.did, jsonData.message, { dataStream: jsonData.dataStream }); + expect(jsonDataReply.status.code).to.equal(202); + + // write an image + const imageData = await TestDataGenerator.generateRecordsWrite({ + author : alice, + dataFormat : 'image/jpeg' + }); + const imageDataReply = await dwn.processMessage(alice.did, imageData.message, { dataStream: imageData.dataStream }); + expect(imageDataReply.status.code).to.equal(202); + + + // wait for messages to emit and handler to process + await Time.minimalSleep(); + expect(imageMessages.length).to.equal(1); + expect(imageMessages).to.have.members([ await Message.getCid(imageData.message) ]); + + // add another image + const imageData2 = await TestDataGenerator.generateRecordsWrite({ + author : alice, + dataFormat : 'image/jpeg' + }); + const imageData2Reply = await dwn.processMessage(alice.did, imageData2.message, { dataStream: imageData2.dataStream }); + expect(imageData2Reply.status.code).to.equal(202); + + // delete the first image + const deleteImageData = await TestDataGenerator.generateRecordsDelete({ + author : alice, + recordId : imageData.message.recordId, + }); + const deleteImageDataReply = await dwn.processMessage(alice.did, deleteImageData.message); + expect(deleteImageDataReply.status.code).to.equal(202); + + // wait for messages to emit and handler to process + await Time.minimalSleep(); + expect(imageMessages.length).to.equal(3); + // check that the new image and the delete messages were emitted + expect(imageMessages).to.include.members([ + await Message.getCid(imageData2.message), + await Message.getCid(deleteImageData.message) + ]); + });; + + it('filters by dataSize', async () => { + // scenario: + // alice subscribes to messages with data size under a threshold + // alice inserts both small and large data + + const alice = await DidKeyResolver.generate(); + + const smallMessages: string[] = []; + const subscriptionHandler = async (message:GenericMessage):Promise => { + smallMessages.push(await Message.getCid(message)); + }; + const smallMessageSubscription = await TestDataGenerator.generateEventsSubscribe({ + author : alice, + filters : [{ dataSize: { lte: DwnConstant.maxDataSizeAllowedToBeEncoded } }] + }); + const smallMessageSubscriptionReply = await dwn.processMessage(alice.did, smallMessageSubscription.message, { + subscriptionHandler, + }); + expect(smallMessageSubscriptionReply.status.code).to.equal(200); + + // add a small data size record + const smallSize1 = await TestDataGenerator.generateRecordsWrite({ + author: alice, + }); + const smallSize1Reply = await dwn.processMessage(alice.did, smallSize1.message, { dataStream: smallSize1.dataStream }); + expect(smallSize1Reply.status.code).to.equal(202); + + // add a large data size record + const largeSize = await TestDataGenerator.generateRecordsWrite({ + author : alice, + data : TestDataGenerator.randomBytes(DwnConstant.maxDataSizeAllowedToBeEncoded + 1) + }); + const largeSizeReply = await dwn.processMessage(alice.did, largeSize.message, { dataStream: largeSize.dataStream }); + expect(largeSizeReply.status.code).to.equal(202); + + // wait for message handler to process and check results + await Time.minimalSleep(); + expect(smallMessages.length).to.equal(1); + expect(smallMessages).to.have.members([ await Message.getCid(smallSize1.message) ]); + + // add another small record + const smallSize2 = await TestDataGenerator.generateRecordsWrite({ + author: alice, + }); + const smallSize2Reply = await dwn.processMessage(alice.did, smallSize2.message, { dataStream: smallSize2.dataStream }); + expect(smallSize2Reply.status.code).to.equal(202); + + // add another large record + const largeSize2 = await TestDataGenerator.generateRecordsWrite({ + author : alice, + data : TestDataGenerator.randomBytes(DwnConstant.maxDataSizeAllowedToBeEncoded + 1) + }); + const largeSize2Reply = await dwn.processMessage(alice.did, largeSize2.message, { dataStream: largeSize2.dataStream }); + expect(largeSize2Reply.status.code).to.equal(202); + + // wait for message handler to process and check results + await Time.minimalSleep(); + expect(smallMessages.length).to.equal(2); + expect(smallMessages).to.include.members([ await Message.getCid(smallSize2.message) ]); + }); + it('does not emit events after subscription is closed', async () => { const alice = await DidKeyResolver.generate(); From 19101e783b2ae65f7edaa037feeaaaa7fae7f8f1 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Thu, 18 Jan 2024 20:56:58 -0500 Subject: [PATCH 44/44] version bump --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index fcccff2e6..a49da66f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tbd54566975/dwn-sdk-js", - "version": "0.2.12", + "version": "0.2.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tbd54566975/dwn-sdk-js", - "version": "0.2.12", + "version": "0.2.13", "license": "Apache-2.0", "dependencies": { "@ipld/dag-cbor": "9.0.3",