From ace20b738c8ea3badb0dbec227781568c85304fe Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Thu, 21 Dec 2023 12:34:36 -0500 Subject: [PATCH 01/16] 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 | 40 +- 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 | 26 +- 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, 3100 insertions(+), 176 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 934266617..a6dfb20a1 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' }; @@ -44,6 +45,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' }; @@ -54,10 +56,12 @@ const schemas = { AuthorizationOwner, RecordsDelete, RecordsQuery, + RecordsSubscribe, RecordsWrite, RecordsWriteUnidentified, EventsFilter, EventsGet, + EventsSubscribe, EventsQuery, Definitions, GeneralJwk, @@ -92,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); +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 90a048e3e..b08e9bc50 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', @@ -88,6 +89,7 @@ export enum DwnErrorCode { RecordsGrantAuthorizationDeleteProtocolScopeMismatch = 'RecordsGrantAuthorizationDeleteProtocolScopeMismatch', RecordsGrantAuthorizationQueryProtocolScopeMismatch = 'RecordsGrantAuthorizationQueryProtocolScopeMismatch', RecordsGrantAuthorizationScopeContextIdMismatch = 'RecordsGrantAuthorizationScopeContextIdMismatch', + RecordsGrantAuthorizationSubscribeProtocolScopeMismatch = 'RecordsGrantAuthorizationSubscribeProtocolScopeMismatch', RecordsGrantAuthorizationScopeNotProtocol = 'RecordsGrantAuthorizationScopeNotProtocol', RecordsGrantAuthorizationScopeProtocolMismatch = 'RecordsGrantAuthorizationScopeProtocolMismatch', RecordsGrantAuthorizationScopeProtocolPathMismatch = 'RecordsGrantAuthorizationScopeProtocolPathMismatch', @@ -99,6 +101,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 5cceee984..17f6115e0 100644 --- a/src/core/message-reply.ts +++ b/src/core/message-reply.ts @@ -2,7 +2,7 @@ import type { MessagesGetReplyEntry } from '../types/messages-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 { @@ -39,4 +39,9 @@ export type UnionMessageReply = GenericMessageReply & { * Mutually exclusive with `record`. */ cursor?: string; + + /** + * 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 ef9a72866..b2aebd273 100644 --- a/src/dwn.ts +++ b/src/dwn.ts @@ -1,21 +1,24 @@ 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 { Readable } from 'readable-stream'; 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 } 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 } from './types/records-types.js'; +import type { RecordsDeleteMessage, RecordsQueryMessage, RecordsQueryReply, RecordsReadMessage, RecordsReadReply, RecordsSubscribeMessage, RecordsSubscribeReply, RecordsWriteMessage } 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'; @@ -27,6 +30,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'; @@ -36,33 +40,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 + ) }; } @@ -72,6 +137,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(); @@ -83,9 +149,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(); @@ -97,6 +165,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; @@ -105,6 +174,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, dataStream?: Readable): Promise; public async processMessage(tenant: string, rawMessage: unknown, dataStream?: Readable): Promise; @@ -152,6 +222,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}` } @@ -172,10 +243,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 1617458a4..9453f9308 100644 --- a/src/event-log/event-log-level.ts +++ b/src/event-log/event-log-level.ts @@ -1,3 +1,4 @@ +import type { EventStream } from '../types/event-stream.js'; import type { ULIDFactory } from 'ulidx'; import type { EventLog, GetEventsOptions } from '../types/event-log.js'; import type { Filter, KeyValues } from '../types/query-types.js'; @@ -15,6 +16,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 8affebf2e..2c6be580b 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, GetEventsOptions } 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, EventsSubscribeDescriptor, EventsSubscribeMessage, EventsSubscribeReply, EventSubscription } from './types/event-types.js'; export type { Filter } from './types/query-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 { 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 { 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 1664cf3cb..4f6e97db3 100644 --- a/src/interfaces/events-query.ts +++ b/src/interfaces/events-query.ts @@ -1,7 +1,7 @@ import type { Filter } from '../types/query-types.js'; 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 { AbstractMessage } from '../core/abstract-message.js'; import { FilterUtility } from '../utils/filter.js'; @@ -14,7 +14,7 @@ import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.j export type EventsQueryOptions = { signer: Signer; - filters: EventsQueryFilter[]; + filters: EventsFilter[]; cursor?: string; 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 a89205a92..112c6a9aa 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?: string; messageTimestamp: string; @@ -41,11 +41,35 @@ export type EventsGetReply = GenericMessageReply & { entries?: string[]; }; +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?: string; }; diff --git a/src/types/message-types.ts b/src/types/message-types.ts index b3d07343f..f9bc2209b 100644 --- a/src/types/message-types.ts +++ b/src/types/message-types.ts @@ -67,6 +67,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 55fd9db92..bf473c111 100644 --- a/src/types/records-types.ts +++ b/src/types/records-types.ts @@ -102,6 +102,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; @@ -142,6 +149,14 @@ export type RecordsQueryReply = GenericMessageReply & { cursor?: string; }; +export type RecordsSubscribeMessage = GenericMessage & { + descriptor: RecordsSubscribeDescriptor; +}; + +export type RecordsSubscribeReply = GenericMessageReply & { + subscription?: RecordsSubscription; +}; + export type RecordsReadMessage = { authorization?: AuthorizationModel; descriptor: RecordsReadDescriptor; @@ -174,4 +189,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 ed6277cbd..3843ca5c1 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 { DataStore, EventLog, MessageStore } from '../src/index.js'; import type { EventsGetReply, TenantGate } 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 b486f2f5f..90cfebc6f 100644 --- a/tests/event-log/event-log.spec.ts +++ b/tests/event-log/event-log.spec.ts @@ -29,12 +29,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); @@ -52,7 +52,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); @@ -60,7 +60,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); @@ -80,14 +80,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); @@ -104,7 +104,7 @@ export function testEventLog(): void { it('gets all events that occurred after the cursor 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); @@ -114,7 +114,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 === 4) { @@ -138,7 +138,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 +146,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 +164,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 +172,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 +192,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 +200,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 +210,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,13 +236,13 @@ 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); 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); if (i === 3) { @@ -258,13 +258,13 @@ export function testEventLog(): void { // not inserted into expected events because it's not a part of the schema. 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); expectedEvents.push(messageCid); 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 44ff15430..18b615ef9 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 113357676..34e10b8c5 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 d312157e8..c41b456c3 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 1ff897841..a0b79a8d5 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 8b45a3965..314a124d0 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 6812efa4d..5e145b75d 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 863a63d07..9dce40418 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, @@ -19,7 +20,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'; @@ -30,6 +30,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); @@ -39,6 +40,7 @@ export function testRecordsDeleteHandler(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; describe('functional tests', () => { @@ -52,8 +54,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 () => { @@ -759,7 +762,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); }); @@ -772,7 +775,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 a441d8227..ebbdec811 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 () => { @@ -1475,29 +1478,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); @@ -1600,11 +1603,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 2559ca10b..39681243f 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 3fc341437..8a498955f 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 () => { @@ -3053,7 +3056,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); @@ -4176,7 +4179,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); @@ -4200,7 +4203,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); @@ -4218,7 +4221,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); @@ -4233,7 +4236,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! }); @@ -4266,7 +4269,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); @@ -4278,7 +4281,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); @@ -4293,7 +4296,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); @@ -4310,7 +4313,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); @@ -4331,14 +4334,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 eb975c995..780291680 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 4c7b6b66c..3640c39f8 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 1d3ee49d0..baa2d0005 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 20b3b022c..36e0b41c6 100644 --- a/tests/store/message-store.spec.ts +++ b/tests/store/message-store.spec.ts @@ -259,7 +259,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, [{}]); @@ -279,7 +279,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); @@ -297,7 +297,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 }); @@ -317,7 +317,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 }); @@ -338,7 +338,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 }); @@ -359,7 +359,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 }); @@ -381,7 +381,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, [{}]); @@ -394,7 +394,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) => @@ -415,7 +415,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 @@ -433,7 +433,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) => @@ -456,7 +456,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) => @@ -480,7 +480,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; @@ -509,7 +509,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 = 4; 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 1654df151..3ee583632 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'; @@ -9,16 +10,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'; @@ -28,6 +30,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'; @@ -39,6 +42,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'; @@ -163,6 +167,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; @@ -236,7 +256,7 @@ export type GenerateEventsGetOutput = { export type GenerateEventsQueryInput = { author?: Persona; - filters: EventsQueryFilter[]; + filters: EventsFilter[]; cursor?: string; }; @@ -246,6 +266,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[] @@ -637,6 +668,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. */ @@ -765,6 +834,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 3df786fc33694b16fea0c6fd9dc3eb674d54df59 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Thu, 21 Dec 2023 15:46:29 -0500 Subject: [PATCH 02/16] 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} (91%) rename src/types/{event-stream.ts => subscriptions.ts} (55%) diff --git a/src/dwn.ts b/src/dwn.ts index b2aebd273..26cf20552 100644 --- a/src/dwn.ts +++ b/src/dwn.ts @@ -1,12 +1,12 @@ 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 { Readable } from 'readable-stream'; 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 } 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 9453f9308..6b78cb487 100644 --- a/src/event-log/event-log-level.ts +++ b/src/event-log/event-log-level.ts @@ -1,4 +1,4 @@ -import type { EventStream } from '../types/event-stream.js'; +import type { EventStream } from '../types/subscriptions.js'; import type { ULIDFactory } from 'ulidx'; import type { EventLog, GetEventsOptions } from '../types/event-log.js'; import type { Filter, KeyValues } 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 24ae57b21..14557bbc2 100644 --- a/src/handlers/events-get.ts +++ b/src/handlers/events-get.ts @@ -2,7 +2,7 @@ import type { DidResolver } from '../did/did-resolver.js'; import type { EventLog } from '../types/event-log.js'; import type { GetEventsOptions } 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 762bce1b9..367f3808d 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 2c6be580b..51e0755f1 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, GetEventsOptions } from './types/event-log.js'; -export type { EventStream } from './types/event-stream.js'; -export type { EventsGetMessage, EventsGetReply, EventsSubscribeDescriptor, EventsSubscribeMessage, EventsSubscribeReply, EventSubscription } from './types/event-types.js'; +export type { EventStream, SubscriptionReply } from './types/subscriptions.js'; +export type { EventsGetMessage, EventsGetReply, EventsSubscribeDescriptor, EventsSubscribeMessage, EventsSubscribeReply, EventsSubscription } from './types/events-types.js'; export type { Filter } from './types/query-types.js'; export type { GenericMessage, GenericMessageReply, MessageSort, Pagination, QueryResultEntry } from './types/message-types.js'; export type { MessagesGetMessage, MessagesGetReply, MessagesGetReplyEntry } from './types/messages-types.js'; diff --git a/src/interfaces/events-get.ts b/src/interfaces/events-get.ts index 6367c6558..9c7f234de 100644 --- a/src/interfaces/events-get.ts +++ b/src/interfaces/events-get.ts @@ -1,5 +1,5 @@ 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 4f6e97db3..c831c2b08 100644 --- a/src/interfaces/events-query.ts +++ b/src/interfaces/events-query.ts @@ -1,7 +1,7 @@ import type { Filter } from '../types/query-types.js'; 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 { AbstractMessage } from '../core/abstract-message.js'; import { FilterUtility } from '../utils/filter.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 91% rename from src/types/event-types.ts rename to src/types/events-types.ts index 112c6a9aa..9ef17a3ed 100644 --- a/src/types/event-types.ts +++ b/src/types/events-types.ts @@ -46,16 +46,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 3843ca5c1..cef12989d 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 { DataStore, EventLog, MessageStore } from '../src/index.js'; import type { EventsGetReply, TenantGate } from '../src/index.js'; diff --git a/tests/handlers/events-get.spec.ts b/tests/handlers/events-get.spec.ts index 18b615ef9..c8448f9a2 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 34e10b8c5..6e9bf529d 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 c41b456c3..4c8ae59d2 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 a0b79a8d5..c71ea1a58 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 5e145b75d..af24d746e 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 9dce40418..8d0189e0e 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 ebbdec811..51005b89a 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 39681243f..ff677de1b 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 8a498955f..71fb0ca3b 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 780291680..ae4ab0c98 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 3640c39f8..a7966afb4 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 3ee583632..512b0e97f 100644 --- a/tests/utils/test-data-generator.ts +++ b/tests/utils/test-data-generator.ts @@ -15,7 +15,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 f65539520106dc6e061a1f8d8d8c6568fbe9875d Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Fri, 22 Dec 2023 17:45:42 -0500 Subject: [PATCH 03/16] 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 4fb69d527249b9fd44683fb406af2bf8d787747f Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Fri, 22 Dec 2023 18:43:30 -0500 Subject: [PATCH 04/16] 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 ae4ab0c98..aec1401f9 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); @@ -181,7 +181,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 @@ -224,6 +224,63 @@ export function testDelegatedGrantScenarios(): void { const participantRoleReply = await dwn.processMessage(bob.did, participantRoleRecord.message, 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, @@ -343,6 +400,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 d20624a24d9c5e357937a2a7a85c69bc70069a20 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Sat, 23 Dec 2023 01:18:46 -0500 Subject: [PATCH 05/16] 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 9ef17a3ed..08f30f0a7 100644 --- a/src/types/events-types.ts +++ b/src/types/events-types.ts @@ -46,7 +46,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 f9bc2209b..4a0a3c822 100644 --- a/src/types/message-types.ts +++ b/src/types/message-types.ts @@ -67,7 +67,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 bf473c111..e90adcdca 100644 --- a/src/types/records-types.ts +++ b/src/types/records-types.ts @@ -193,6 +193,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 aec1401f9..dbf116c29 100644 --- a/tests/scenarios/delegated-grant.spec.ts +++ b/tests/scenarios/delegated-grant.spec.ts @@ -251,7 +251,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 c356440a91dbe7247db56815b74cc4353429d2e5 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Fri, 29 Dec 2023 18:02:34 -0500 Subject: [PATCH 06/16] 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 f4a94d54a4ac19eb4d2fbd28d09e80e3031367d9 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 3 Jan 2024 16:07:36 -0500 Subject: [PATCH 07/16] 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 08f30f0a7..a2d07dcf5 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 { RangeCriterion, RangeFilter } from './query-types.js'; +import type { KeyValues, RangeCriterion, RangeFilter } from './query-types.js'; export type EventsMessageFilter = { interface?: string; @@ -46,7 +46,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 4a0a3c822..f9bc2209b 100644 --- a/src/types/message-types.ts +++ b/src/types/message-types.ts @@ -67,7 +67,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 7d620349afc0885e936d54598a74cd14976f5971 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Thu, 4 Jan 2024 10:31:32 -0500 Subject: [PATCH 08/16] 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 26cf20552..7ee976e13 100644 --- a/src/dwn.ts +++ b/src/dwn.ts @@ -62,7 +62,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 512b0e97f..d1d573fe7 100644 --- a/tests/utils/test-data-generator.ts +++ b/tests/utils/test-data-generator.ts @@ -268,7 +268,7 @@ export type GenerateEventsQueryOutput = { export type GenerateEventsSubscribeInput = { author: Persona; - filters: EventsFilter[]; + filters?: EventsFilter[]; }; export type GenerateEventsSubscribeOutput = { From aed0baaad7cb966d0b7a43d08f239e7a79fcaeb0 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Fri, 5 Jan 2024 14:46:16 -0500 Subject: [PATCH 09/16] update events filters --- src/handlers/events-query.ts | 3 +- src/handlers/events-subscribe.ts | 6 ++- src/interfaces/events-query.ts | 85 ++----------------------------- src/utils/events.ts | 87 ++++++++++++++++++++++++++++++++ 4 files changed, 97 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 367f3808d..117168578 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'; @@ -32,7 +33,7 @@ export class EventsQueryHandler implements MethodHandler { } const { filters, cursor } = eventsQuery.message.descriptor; - const logFilters = EventsQuery.convertFilters(filters); + const logFilters = Events.convertFilters(filters); const events = await this.eventLog.queryEvents(tenant, logFilters, 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 c831c2b08..2d0532cc7 100644 --- a/src/interfaces/events-query.ts +++ b/src/interfaces/events-query.ts @@ -1,13 +1,9 @@ -import type { Filter } from '../types/query-types.js'; -import type { ProtocolsQueryFilter } from '../types/protocols-types.js'; import type { Signer } from '../types/signer.js'; -import type { EventsFilter, EventsMessageFilter, EventsQueryDescriptor, EventsQueryMessage, EventsRecordsFilter } from '../types/events-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 +28,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 +42,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 3a9f5fb0574120715c9d76a5fc3c0180637eeb08 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Tue, 9 Jan 2024 11:56:20 -0500 Subject: [PATCH 10/16] 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 b08e9bc50..e8542eba0 100644 --- a/src/core/dwn-error.ts +++ b/src/core/dwn-error.ts @@ -102,6 +102,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 7ee976e13..2abb6feb6 100644 --- a/src/dwn.ts +++ b/src/dwn.ts @@ -62,6 +62,7 @@ export class Dwn { ), [DwnInterfaceName.Events+ DwnMethodName.Subscribe]: new EventsSubscribeHandler( this.didResolver, + this.messageStore, this.eventStream, ), [DwnInterfaceName.Messages + DwnMethodName.Get]: new MessagesGetHandler( @@ -136,7 +137,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 a2d07dcf5..9ef17a3ed 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, RangeCriterion, RangeFilter } from './query-types.js'; +import type { RangeCriterion, RangeFilter } from './query-types.js'; export type EventsMessageFilter = { interface?: string; @@ -46,7 +46,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 e90adcdca..bf473c111 100644 --- a/src/types/records-types.ts +++ b/src/types/records-types.ts @@ -193,6 +193,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 cef12989d..80dea829d 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 c8448f9a2..a8225eee5 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 6e9bf529d..bc2cd1d8c 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 4c8ae59d2..5d5187eb7 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 c71ea1a58..977c6599e 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 314a124d0..0eea2af7d 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 af24d746e..fdeee6fe6 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 8d0189e0e..c525304af 100644 --- a/tests/handlers/records-delete.spec.ts +++ b/tests/handlers/records-delete.spec.ts @@ -20,17 +20,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); @@ -54,7 +55,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 51005b89a..46988b0c3 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 ff677de1b..84d927ecc 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 71fb0ca3b..83fc9e577 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 dbf116c29..947c5881d 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 a7966afb4..cadf23346 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 baa2d0005..694e0d3ba 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 4fef1c23dad4fbca78751097416bee33f6b1d150 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Tue, 9 Jan 2024 12:14:44 -0500 Subject: [PATCH 11/16] 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 bf473c111..9244d05ef 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'; @@ -194,5 +195,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 c120086b88997fad1a45a3a39364ae0f984003c3 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Tue, 9 Jan 2024 12:21:26 -0500 Subject: [PATCH 12/16] 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 942ceeca1e8ef555593fede637682fe8e69a9728 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 10 Jan 2024 10:01:58 -0500 Subject: [PATCH 13/16] 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 9ef17a3ed..a7f8a96dc 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'; @@ -51,6 +52,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 f9bc2209b..674b2973a 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 { SortDirection } from './query-types.js'; @@ -72,6 +73,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 d15427f31d8dc17d203b7b6e594db5ea74c32d01 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 10 Jan 2024 10:10:57 -0500 Subject: [PATCH 14/16] 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 e8542eba0..8a0ae9de0 100644 --- a/src/core/dwn-error.ts +++ b/src/core/dwn-error.ts @@ -103,6 +103,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 14f2cd2c872140a835055e29a4d4c217935f3250 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 10 Jan 2024 10:15:43 -0500 Subject: [PATCH 15/16] 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 a7f8a96dc..bbce47ef1 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 674b2973a..6b1b9728a 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 { SortDirection } from './query-types.js'; diff --git a/src/types/records-types.ts b/src/types/records-types.ts index 9244d05ef..36dbcb19e 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 f49e516b389c8d80a327361c309fc4120a1eca1e Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 10 Jan 2024 10:33:55 -0500 Subject: [PATCH 16/16] 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 };