From d59a1595d56a606deba42ae5605ffe4a3c250b6c Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Fri, 21 Apr 2023 15:47:33 +0200 Subject: [PATCH] feat: Replace WebHookSubscription2021 with WebHookChannel2023 --- config/http/README.md | 10 +- .../http/notifications/webhooks/handler.json | 6 +- .../http/notifications/webhooks/routes.json | 6 +- .../notifications/webhooks/subscription.json | 14 +- .../architecture/features/notifications.md | 6 +- documentation/markdown/usage/notifications.md | 28 ++-- src/index.ts | 8 +- .../WebHookEmitter.ts | 26 ++-- .../WebHookWebId.ts | 8 +- .../WebhookChannel2023Type.ts | 88 ++++++++++++ .../WebHookSubscription2021.ts | 135 ------------------ .../WebSocketChannel2023Type.ts | 2 +- src/util/Vocabularies.ts | 6 +- ...021.test.ts => WebHookChannel2023.test.ts} | 25 ++-- .../WebHookChannel2023Type.test.ts} | 63 +++----- .../WebHookEmitter.test.ts | 19 ++- .../WebHookWebId.test.ts | 2 +- test/util/NotificationUtil.ts | 2 +- test/util/Util.ts | 4 +- 19 files changed, 188 insertions(+), 270 deletions(-) rename src/server/notifications/{WebHookSubscription2021 => WebHookChannel2023}/WebHookEmitter.ts (80%) rename src/server/notifications/{WebHookSubscription2021 => WebHookChannel2023}/WebHookWebId.ts (80%) create mode 100644 src/server/notifications/WebHookChannel2023/WebhookChannel2023Type.ts delete mode 100644 src/server/notifications/WebHookSubscription2021/WebHookSubscription2021.ts rename test/integration/{WebHookSubscription2021.test.ts => WebHookChannel2023.test.ts} (89%) rename test/unit/server/notifications/{WebHookSubscription2021/WebHookSubscription2021.test.ts => WebHookChannel2023/WebHookChannel2023Type.test.ts} (57%) rename test/unit/server/notifications/{WebHookSubscription2021 => WebHookChannel2023}/WebHookEmitter.test.ts (90%) rename test/unit/server/notifications/{WebHookSubscription2021 => WebHookChannel2023}/WebHookWebId.test.ts (97%) diff --git a/config/http/README.md b/config/http/README.md index ca3435536b..01b723edf3 100644 --- a/config/http/README.md +++ b/config/http/README.md @@ -22,14 +22,14 @@ Determines how notifications should be sent out from the server when resources c * *all*: Supports all available notification types of the Solid Notifications protocol [specification](https://solidproject.org/TR/notifications-protocol). - Currently, this includes WebHookSubscription2021 and WebSocketSubscription2021. + Currently, this includes WebHookChannel2023 and WebSocketChannel2023. * *disabled*: No notifications are sent out. * *legacy-websocket*: Follows the legacy Solid WebSocket [specification](https://github.com/solid/solid-spec/blob/master/api-websockets.md). -* *webhooks*: Follows the WebHookSubscription2021 - [specification](https://github.com/solid/notifications/blob/main/webhook-subscription-2021.md) draft. -* *websockets*: Follows the WebSocketSubscription2021 - [specification](https://solidproject.org/TR/websocket-subscription-2021). +* *webhooks*: Follows the WebHookChannel2023 + [specification](https://solid.github.io/notifications/webhook-channel-2023) draft. +* *websockets*: Follows the WebSocketChannel2023 + [specification](https://solid.github.io/notifications/websocket-channel-2023). ## Server-Factory diff --git a/config/http/notifications/webhooks/handler.json b/config/http/notifications/webhooks/handler.json index 51e74e10ae..8b2f9a221e 100644 --- a/config/http/notifications/webhooks/handler.json +++ b/config/http/notifications/webhooks/handler.json @@ -2,10 +2,10 @@ "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", "@graph": [ { - "comment": "Handles the generation and serialization of notifications for WebHookSubscription2021.", + "comment": "Handles the generation and serialization of notifications for WebHookChannel2023.", "@id": "urn:solid-server:default:WebHookNotificationHandler", "@type": "TypedNotificationHandler", - "type": "http://www.w3.org/ns/solid/notifications#WebHookSubscription2021", + "type": "http://www.w3.org/ns/solid/notifications#WebHookChannel2023", "source": { "@type": "ComposedNotificationHandler", "generator": { "@id": "urn:solid-server:default:BaseNotificationGenerator" }, @@ -28,6 +28,6 @@ "handlers": [ { "@id": "urn:solid-server:default:WebHookNotificationHandler" } ] - }, + } ] } diff --git a/config/http/notifications/webhooks/routes.json b/config/http/notifications/webhooks/routes.json index ee595f9c49..0acbf15a88 100644 --- a/config/http/notifications/webhooks/routes.json +++ b/config/http/notifications/webhooks/routes.json @@ -5,7 +5,7 @@ "@id": "urn:solid-server:default:WebHookRoute", "@type": "RelativePathInteractionRoute", "base": { "@id": "urn:solid-server:default:NotificationRoute" }, - "relativePath": "/WebHookSubscription2021/" + "relativePath": "/WebHookChannel2023/" }, { "@id": "urn:solid-server:default:WebHookWebIdRoute", @@ -15,11 +15,11 @@ }, { - "comment": "Handles the WebHookSubscription2021 WebID.", + "comment": "Handles the WebHookChannel2023 WebID.", "@id": "urn:solid-server:default:WebHookWebId", "@type": "OperationRouterHandler", "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "allowedPathNames": [ "/WebHookSubscription2021/webId$" ], + "allowedPathNames": [ "/WebHookChannel2023/webId$" ], "handler": { "@type": "WebHookWebId", "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } diff --git a/config/http/notifications/webhooks/subscription.json b/config/http/notifications/webhooks/subscription.json index fb969b4fee..bdde8e92bf 100644 --- a/config/http/notifications/webhooks/subscription.json +++ b/config/http/notifications/webhooks/subscription.json @@ -2,16 +2,16 @@ "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", "@graph": [ { - "comment": "Handles the subscriptions targeting a WebHookSubscription2021.", + "comment": "Handles the subscriptions targeting a WebHookChannel2023.", "@id": "urn:solid-server:default:WebHookRouter", "@type": "OperationRouterHandler", "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, "allowedMethods": [ "HEAD", "GET", "POST" ], - "allowedPathNames": [ "/WebHookSubscription2021/$" ], + "allowedPathNames": [ "/WebHookChannel2023/$" ], "handler": { "@id": "urn:solid-server:default:WebHookSubscriber", "@type": "NotificationSubscriber", - "channelType": { "@id": "urn:solid-server:default:WebHookSubscription2021" }, + "channelType": { "@id": "urn:solid-server:default:WebHookChannel2023Type" }, "converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, "credentialsExtractor": { "@id": "urn:solid-server:default:CredentialsExtractor" }, "permissionReader": { "@id": "urn:solid-server:default:PermissionReader" }, @@ -20,9 +20,9 @@ } }, { - "comment": "Contains all the metadata relevant for a WebHookSubscription2021.", - "@id": "urn:solid-server:default:WebHookSubscription2021", - "@type": "WebHookSubscription2021", + "comment": "Contains all the metadata relevant for a WebHookChannel2023.", + "@id": "urn:solid-server:default:WebHookChannel2023Type", + "@type": "WebhookChannel2023Type", "route": { "@id": "urn:solid-server:default:WebHookRoute" }, "webIdRoute": { "@id": "urn:solid-server:default:WebHookWebIdRoute" }, "stateHandler": { @@ -37,7 +37,7 @@ "@type": "NotificationDescriber", "subscriptions": [ { - "@id": "urn:solid-server:default:WebHookSubscription2021" + "@id": "urn:solid-server:default:WebHookChannel2023Type" } ] } diff --git a/documentation/markdown/architecture/features/notifications.md b/documentation/markdown/architecture/features/notifications.md index bbeaa0fb76..653f56eff0 100644 --- a/documentation/markdown/architecture/features/notifications.md +++ b/documentation/markdown/architecture/features/notifications.md @@ -174,13 +174,13 @@ so that class can emit events later on, as mentioned above. The state handler will make sure that a notification gets sent out if the subscription has a `state` feature request, as defined in the notification specification. -## WebHookSubscription2021 +## WebHookChannel2023 The additions required to support -[WebHookSubscription2021](https://github.com/solid/notifications/blob/main/webhook-subscription-2021.md) +[WebHookChannel2023](https://solid.github.io/notifications/webhook-channel-2023) are quite similar to those needed for WebSocketChannel2023: * For discovery, there is a `WebHookDescriber`, which is an extension of a `NotificationDescriber`. -* The `WebHookSubscription2021` class contains all the necessary typing information. +* The `WebHookChannel2023Type` class contains all the necessary typing information. * `WebHookEmitter` is the `NotificationEmitter` that sends the request. * `WebHookUnsubscriber` and `WebHookWebId` are additional utility classes to support the spec requirements. diff --git a/documentation/markdown/usage/notifications.md b/documentation/markdown/usage/notifications.md index 8d942ad29e..b089c1c41a 100644 --- a/documentation/markdown/usage/notifications.md +++ b/documentation/markdown/usage/notifications.md @@ -27,7 +27,7 @@ Doing a GET to `http://localhost:3000/.well-known/solid` then gives the followin a ; notify:subscription , - . + . notify:channelType notify:WebSocketChannel2023 ; notify:feature notify:accept , @@ -36,7 +36,7 @@ Doing a GET to `http://localhost:3000/.well-known/solid` then gives the followin notify:startAt , notify:state . - notify:channelType notify:WebHookSubscription2021; + notify:channelType notify:WebHookChannel2023; notify:feature notify:accept , notify:endAt , notify:rate , @@ -61,10 +61,7 @@ Requests without `Read` permission will be rejected. There are currently up to two supported ways to get notifications in CSS, depending on your configuration: the notification channel types [`WebSocketChannel2023`](https://solid.github.io/notifications/websocket-channel-2023); -and [`WebHookSubscription2021`](https://github.com/solid/notifications/blob/main/webhook-subscription-2021.md). -_**Note:** `WebHookSubscription2021` has been deprecated, and will be replaced -by the newer `WebHookChannel2023` implementation once that specification is published; -the practical differences are expected to be minor._ +and [`WebHookChannel2023`](https://solid.github.io/notifications/webhook-channel-2023). ### WebSockets @@ -104,18 +101,18 @@ ws.on('message', (notification) => console.log(notification)); ### WebHooks Similar to the WebSocket subscription, below is sample JSON-LD -that would be sent to `http://localhost:3000/.notifications/WebHookSubscription2021/`: +that would be sent to `http://localhost:3000/.notifications/WebHookChannel2023/`: ```json { "@context": [ "https://www.w3.org/ns/solid/notification/v1" ], - "type": "http://www.w3.org/ns/solid/notifications#WebHookSubscription2021", + "type": "http://www.w3.org/ns/solid/notifications#WebHookChannel2023", "topic": "http://localhost:3000/foo", - "target": "https://example.com/webhook" + "sendTo": "https://example.com/webhook" } ``` -Note that this document has an additional `target` field. +Note that this document has an additional `sendTo` field. This is the WebHook URL of your server, the URL to which you want the notifications to be sent. The response would then be something like this: @@ -123,18 +120,13 @@ The response would then be something like this: ```json { "@context": [ "https://www.w3.org/ns/solid/notification/v1" ], - "id": "http://localhost:3000/.notifications/WebHookSubscription2021/eeaf2c17-699a-4e53-8355-e91d13807e5f", - "type": "http://www.w3.org/ns/solid/notifications#WebHookSubscription2021", + "id": "http://localhost:3000/.notifications/WebHookChannel2023/eeaf2c17-699a-4e53-8355-e91d13807e5f", + "type": "http://www.w3.org/ns/solid/notifications#WebHookChannel2023", "topic": "http://localhost:3000/foo", - "target": "https://example.com/webhook", - "unsubscribe_endpoint": "http://localhost:3000/.notifications/WebHookSubscription2021/eeaf2c17-699a-4e53-8355-e91d13807e5f" + "sendTo": "https://example.com/webhook" } ``` -The `unsubscribe_endpoint` field is new here. -Once created, the notification channel can be removed and notifications stopped -by sending a `DELETE` request to the URL found in that field. - ## Unsubscribing from a notification channel !!! note diff --git a/src/index.ts b/src/index.ts index b2886dfe6a..866a5faaf7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -321,10 +321,10 @@ export * from './server/notifications/serialize/ConvertingNotificationSerializer export * from './server/notifications/serialize/JsonLdNotificationSerializer'; export * from './server/notifications/serialize/NotificationSerializer'; -// Server/Notifications/WebHookSubscription2021 -export * from './server/notifications/WebHookSubscription2021/WebHookEmitter'; -export * from './server/notifications/WebHookSubscription2021/WebHookSubscription2021'; -export * from './server/notifications/WebHookSubscription2021/WebHookWebId'; +// Server/Notifications/WebHookChannel2023 +export * from './server/notifications/WebHookChannel2023/WebhookChannel2023Type'; +export * from './server/notifications/WebHookChannel2023/WebHookEmitter'; +export * from './server/notifications/WebHookChannel2023/WebHookWebId'; // Server/Notifications/WebSocketChannel2023 export * from './server/notifications/WebSocketChannel2023/WebSocket2023Emitter'; diff --git a/src/server/notifications/WebHookSubscription2021/WebHookEmitter.ts b/src/server/notifications/WebHookChannel2023/WebHookEmitter.ts similarity index 80% rename from src/server/notifications/WebHookSubscription2021/WebHookEmitter.ts rename to src/server/notifications/WebHookChannel2023/WebHookEmitter.ts index 7d7b1408e7..150ddb3ead 100644 --- a/src/server/notifications/WebHookSubscription2021/WebHookEmitter.ts +++ b/src/server/notifications/WebHookChannel2023/WebHookEmitter.ts @@ -9,11 +9,14 @@ import { trimTrailingSlashes } from '../../../util/PathUtil'; import { readableToString } from '../../../util/StreamUtil'; import type { NotificationEmitterInput } from '../NotificationEmitter'; import { NotificationEmitter } from '../NotificationEmitter'; -import type { WebHookSubscription2021Channel } from './WebHookSubscription2021'; -import { isWebHook2021Channel } from './WebHookSubscription2021'; +import type { WebhookChannel2023 } from './WebhookChannel2023Type'; +import { isWebHook2023Channel } from './WebhookChannel2023Type'; /** - * Emits a notification representation using the WebHookSubscription2021 specification. + * Emits a notification representation using the WebHookChannel2023 specification. + * + * At the time of writing it is not specified how exactly a notification sender should make its requests verifiable, + * so for now we use a token similar to those from Solid-OIDC, signed by the server itself. * * Generates a DPoP token and proof, and adds those to the HTTP request that is sent to the target. * @@ -37,15 +40,15 @@ export class WebHookEmitter extends NotificationEmitter { } public async canHandle({ channel }: NotificationEmitterInput): Promise { - if (!isWebHook2021Channel(channel)) { - throw new NotImplementedHttpError(`${channel.id} is not a WebHookSubscription2021 channel.`); + if (!isWebHook2023Channel(channel)) { + throw new NotImplementedHttpError(`${channel.id} is not a WebHookChannel2023 channel.`); } } public async handle({ channel, representation }: NotificationEmitterInput): Promise { // Cast was checked in `canHandle` - const webHookChannel = channel as WebHookSubscription2021Channel; - this.logger.debug(`Emitting WebHook notification with target ${webHookChannel.target}`); + const webHookChannel = channel as WebhookChannel2023; + this.logger.debug(`Emitting WebHook notification with target ${webHookChannel.sendTo}`); const privateKey = await this.jwkGenerator.getPrivateKey(); const publicKey = await this.jwkGenerator.getPublicKey(); @@ -55,8 +58,7 @@ export class WebHookEmitter extends NotificationEmitter { // Make sure both header and proof have the same timestamp const time = Date.now(); - // The spec is not completely clear on which fields actually need to be present in the token, - // only that it needs to contain the WebID somehow. + // Currently the spec does not define how the notification sender should identify. // The format used here has been chosen to be similar // to how ID tokens are described in the Solid-OIDC specification for consistency. const dpopToken = await new SignJWT({ @@ -76,14 +78,14 @@ export class WebHookEmitter extends NotificationEmitter { // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop#section-4.2 const dpopProof = await new SignJWT({ - htu: webHookChannel.target, + htu: webHookChannel.sendTo, htm: 'POST', }).setProtectedHeader({ alg: privateKey.alg, jwk: publicKey, typ: 'dpop+jwt' }) .setIssuedAt(time) .setJti(v4()) .sign(privateKeyObject); - const response = await fetch(webHookChannel.target, { + const response = await fetch(webHookChannel.sendTo, { method: 'POST', headers: { 'content-type': representation.metadata.contentType!, @@ -93,7 +95,7 @@ export class WebHookEmitter extends NotificationEmitter { body: await readableToString(representation.data), }); if (response.status >= 400) { - this.logger.error(`There was an issue emitting a WebHook notification with target ${webHookChannel.target}: ${ + this.logger.error(`There was an issue emitting a WebHook notification with target ${webHookChannel.sendTo}: ${ await response.text()}`); } } diff --git a/src/server/notifications/WebHookSubscription2021/WebHookWebId.ts b/src/server/notifications/WebHookChannel2023/WebHookWebId.ts similarity index 80% rename from src/server/notifications/WebHookSubscription2021/WebHookWebId.ts rename to src/server/notifications/WebHookChannel2023/WebHookWebId.ts index 9e538167d1..651231ee60 100644 --- a/src/server/notifications/WebHookSubscription2021/WebHookWebId.ts +++ b/src/server/notifications/WebHookChannel2023/WebHookWebId.ts @@ -9,11 +9,9 @@ import type { OperationHttpHandlerInput } from '../../OperationHttpHandler'; import { OperationHttpHandler } from '../../OperationHttpHandler'; /** - * The WebHookSubscription2021 requires the server to have a WebID - * that is used during the generation of the DPoP headers. - * There are no real specifications about what this should contain or look like, - * so we just return a Turtle document that contains a solid:oidcIssuer triple for now. - * This way we confirm that our server was allowed to sign the token. + * Generates a fixed WebID that we use to identify the server for notifications sent using a WebHookChannel2023. + * This is used in tandem with the tokens generated by the {@link WebHookEmitter}. + * This is a minimal WebID with only the `solid:oidcIssuer` triple. */ export class WebHookWebId extends OperationHttpHandler { private readonly turtle: string; diff --git a/src/server/notifications/WebHookChannel2023/WebhookChannel2023Type.ts b/src/server/notifications/WebHookChannel2023/WebhookChannel2023Type.ts new file mode 100644 index 0000000000..d05839391e --- /dev/null +++ b/src/server/notifications/WebHookChannel2023/WebhookChannel2023Type.ts @@ -0,0 +1,88 @@ +import type { Store } from 'n3'; +import type { InteractionRoute } from '../../../identity/interaction/routing/InteractionRoute'; +import { getLoggerFor } from '../../../logging/LogUtil'; +import { createErrorMessage } from '../../../util/errors/ErrorUtil'; +import { NOTIFY } from '../../../util/Vocabularies'; +import { BaseChannelType } from '../BaseChannelType'; +import type { NotificationChannel } from '../NotificationChannel'; +import type { StateHandler } from '../StateHandler'; + +/** + * A {@link NotificationChannel} containing the necessary fields for a WebHookChannel2023 channel. + */ +export interface WebhookChannel2023 extends NotificationChannel { + /** + * The "WebHookChannel2023" type. + */ + type: typeof NOTIFY.WebHookChannel2023; + /** + * Where the notifications have to be sent. + */ + sendTo: string; +} + +export function isWebHook2023Channel(channel: NotificationChannel): channel is WebhookChannel2023 { + return channel.type === NOTIFY.WebHookChannel2023; +} + +/** + * The notification channel type WebHookChannel2023 as described in + * https://solid.github.io/notifications/webhook-channel-2023 + * + * Requires read permissions on a resource to be able to receive notifications. + * + * Also handles the `state` feature if present. + */ +export class WebhookChannel2023Type extends BaseChannelType { + protected readonly logger = getLoggerFor(this); + + private readonly stateHandler: StateHandler; + private readonly webId: string; + + /** + * @param route - The route corresponding to the URL of the subscription service of this channel type. + * @param webIdRoute - The route to the WebID that needs to be used when generating DPoP tokens for notifications. + * @param stateHandler - The {@link StateHandler} that will be called after a successful subscription. + * @param features - The features that need to be enabled for this channel type. + */ + public constructor(route: InteractionRoute, webIdRoute: InteractionRoute, stateHandler: StateHandler, + features?: string[]) { + super(NOTIFY.terms.WebHookChannel2023, + route, + features, + [{ path: NOTIFY.sendTo, minCount: 1, maxCount: 1 }]); + this.stateHandler = stateHandler; + this.webId = webIdRoute.getPath(); + } + + public async initChannel(data: Store): Promise { + const subject = await this.validateSubscription(data); + const channel = await this.quadsToChannel(data, subject); + const sendTo = data.getObjects(subject, NOTIFY.terms.sendTo, null)[0]; + + return { + ...channel, + type: NOTIFY.WebHookChannel2023, + sendTo: sendTo.value, + }; + } + + public async toJsonLd(channel: NotificationChannel): Promise> { + const json = await super.toJsonLd(channel); + + // Add the stored WebID as sender. + // We don't store it in the channel object itself as we always know what it is anyway. + json.sender = this.webId; + + return json; + } + + public async completeChannel(channel: NotificationChannel): Promise { + try { + // Send the state notification, if there is one + await this.stateHandler.handleSafe({ channel }); + } catch (error: unknown) { + this.logger.error(`Error emitting state notification: ${createErrorMessage(error)}`); + } + } +} diff --git a/src/server/notifications/WebHookSubscription2021/WebHookSubscription2021.ts b/src/server/notifications/WebHookSubscription2021/WebHookSubscription2021.ts deleted file mode 100644 index 67eed3ddf0..0000000000 --- a/src/server/notifications/WebHookSubscription2021/WebHookSubscription2021.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { Store } from 'n3'; -import type { Credentials } from '../../../authentication/Credentials'; -import type { InteractionRoute } from '../../../identity/interaction/routing/InteractionRoute'; -import { getLoggerFor } from '../../../logging/LogUtil'; -import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError'; -import { createErrorMessage } from '../../../util/errors/ErrorUtil'; -import { NOTIFY } from '../../../util/Vocabularies'; -import { BaseChannelType, DEFAULT_NOTIFICATION_FEATURES } from '../BaseChannelType'; -import type { NotificationChannel } from '../NotificationChannel'; -import type { SubscriptionService } from '../NotificationChannelType'; -import type { StateHandler } from '../StateHandler'; - -/** - * A {@link NotificationChannel} containing the necessary fields for a WebHookSubscription2021 channel. - */ -export interface WebHookSubscription2021Channel extends NotificationChannel { - /** - * The "WebHookSubscription2021" type. - */ - type: typeof NOTIFY.WebHookSubscription2021; - /** - * Where the notifications have to be sent. - */ - target: string; - /** - * The WebID of the agent subscribing to the channel. - */ - webId: string; - /** - * Where the agent can unsubscribe from the channel. - */ - // eslint-disable-next-line @typescript-eslint/naming-convention - unsubscribe_endpoint: string; -} - -/** - * An extension of {@link SubscriptionService} adding the necessary `webid` field. - * This is currently not part of a context so the terms are added in full to make sure the resulting RDF is valid. - */ -export interface WebHookSubscriptionService extends SubscriptionService { - [NOTIFY.webid]: { id: string }; -} - -export function isWebHook2021Channel(channel: NotificationChannel): channel is WebHookSubscription2021Channel { - return channel.type === NOTIFY.WebHookSubscription2021; -} - -/** - * The notification channel type WebHookSubscription2021 as described in - * https://github.com/solid/notifications/blob/main/webhook-subscription-2021.md - * - * Requires read permissions on a resource to be able to receive notifications. - * - * Also handles the `state` feature if present. - */ -export class WebHookSubscription2021 extends BaseChannelType { - protected readonly logger = getLoggerFor(this); - - private readonly stateHandler: StateHandler; - private readonly webId: string; - - /** - * @param route - The route corresponding to the URL of the subscription service of this channel type. - * @param webIdRoute - The route to the WebID that needs to be used when generating DPoP tokens for notifications. - * @param stateHandler - The {@link StateHandler} that will be called after a successful subscription. - * @param features - The features that need to be enabled for this channel type. - */ - public constructor(route: InteractionRoute, webIdRoute: InteractionRoute, stateHandler: StateHandler, - features: string[] = DEFAULT_NOTIFICATION_FEATURES) { - super(NOTIFY.terms.WebHookSubscription2021, - route, - [ ...features, NOTIFY.webhookAuth ], - // Need to remember to remove `target` from the vocabulary again once this is updated to webhooks 2023, - // as it is not actually part of the vocabulary. - // Technically we should also require that this node is a named node, - // but that would require clients to send `target: { '@id': 'http://example.com/target' }`, - // which would make this more annoying so we are lenient here. - // Could change in the future once this field is updated and part of the context. - [{ path: NOTIFY.target, minCount: 1, maxCount: 1 }]); - this.stateHandler = stateHandler; - this.webId = webIdRoute.getPath(); - } - - public getDescription(): WebHookSubscriptionService { - const base = super.getDescription(); - - return { - ...base, - [NOTIFY.webid]: { id: this.webId }, - }; - } - - public async initChannel(data: Store, credentials: Credentials): Promise { - // The WebID is used to verify who can unsubscribe - const webId = credentials.agent?.webId; - - if (!webId) { - throw new BadRequestHttpError( - 'A WebHookSubscription2021 subscription request needs to be authenticated with a WebID.', - ); - } - - const subject = await this.validateSubscription(data); - const channel = await this.quadsToChannel(data, subject); - const target = data.getObjects(subject, NOTIFY.terms.target, null)[0]; - - return { - ...channel, - type: NOTIFY.WebHookSubscription2021, - webId, - target: target.value, - // eslint-disable-next-line @typescript-eslint/naming-convention - unsubscribe_endpoint: channel.id, - }; - } - - public async toJsonLd(channel: NotificationChannel): Promise> { - const json = await super.toJsonLd(channel); - - // We don't want to expose the WebID that initialized the notification channel. - // This is not really specified either way in the spec so this might change in the future. - delete json.webId; - - return json; - } - - public async completeChannel(channel: NotificationChannel): Promise { - try { - // Send the state notification, if there is one - await this.stateHandler.handleSafe({ channel }); - } catch (error: unknown) { - this.logger.error(`Error emitting state notification: ${createErrorMessage(error)}`); - } - } -} diff --git a/src/server/notifications/WebSocketChannel2023/WebSocketChannel2023Type.ts b/src/server/notifications/WebSocketChannel2023/WebSocketChannel2023Type.ts index 3692d9e3d9..1569872c98 100644 --- a/src/server/notifications/WebSocketChannel2023/WebSocketChannel2023Type.ts +++ b/src/server/notifications/WebSocketChannel2023/WebSocketChannel2023Type.ts @@ -27,7 +27,7 @@ export function isWebSocket2023Channel(channel: NotificationChannel): channel is /** * The notification channel type WebSocketChannel2023 as described in - * https://solidproject.org/TR/websocket-subscription-2021 + * https://solid.github.io/notifications/websocket-channel-2023 * * Requires read permissions on a resource to be able to receive notifications. */ diff --git a/src/util/Vocabularies.ts b/src/util/Vocabularies.ts index 8ea4dd018a..772bf0ef7c 100644 --- a/src/util/Vocabularies.ts +++ b/src/util/Vocabularies.ts @@ -204,13 +204,13 @@ export const NOTIFY = createVocabulary('http://www.w3.org/ns/solid/notifications 'receiveFrom', 'startAt', 'state', + 'sender', + 'sendTo', 'subscription', - 'target', 'topic', 'webhookAuth', - 'webid', - 'WebHookSubscription2021', + 'WebHookChannel2023', 'WebSocketChannel2023', ); diff --git a/test/integration/WebHookSubscription2021.test.ts b/test/integration/WebHookChannel2023.test.ts similarity index 89% rename from test/integration/WebHookSubscription2021.test.ts rename to test/integration/WebHookChannel2023.test.ts index 77258619ff..41f1853da5 100644 --- a/test/integration/WebHookSubscription2021.test.ts +++ b/test/integration/WebHookChannel2023.test.ts @@ -23,14 +23,14 @@ import { import quad = DataFactory.quad; import namedNode = DataFactory.namedNode; -const port = getPort('WebHookSubscription2021'); +const port = getPort('WebHookChannel2023'); const baseUrl = `http://localhost:${port}/`; -const clientPort = getPort('WebHookSubscription2021-client'); +const clientPort = getPort('WebHookChannel2023-client'); const target = `http://localhost:${clientPort}/`; const webId = 'http://example.com/card/#me'; -const notificationType = NOTIFY.WebHookSubscription2021; +const notificationType = NOTIFY.WebHookChannel2023; -const rootFilePath = getTestFolder('WebHookSubscription2021'); +const rootFilePath = getTestFolder('WebHookChannel2023'); const stores: [string, any][] = [ [ 'in-memory storage', { configs: [ 'storage/backend/memory.json', 'util/resource-locker/memory.json' ], @@ -43,7 +43,7 @@ const stores: [string, any][] = [ }], ]; -describe.each(stores)('A server supporting WebHookSubscription2021 using %s', (name, { configs, teardown }): void => { +describe.each(stores)('A server supporting WebHookChannel2023 using %s', (name, { configs, teardown }): void => { let app: App; const topic = joinUrl(baseUrl, '/foo'); let storageDescriptionUrl: string; @@ -99,19 +99,16 @@ describe.each(stores)('A server supporting WebHookSubscription2021 using %s', (n // Find the notification channel for websockets const subscriptions = quads.getObjects(storageDescriptionUrl, NOTIFY.terms.subscription, null); const webhookSubscriptions = subscriptions.filter((channel): boolean => quads.has( - quad(channel as NamedNode, NOTIFY.terms.channelType, namedNode(`${NOTIFY.namespace}WebHookSubscription2021`)), + quad(channel as NamedNode, NOTIFY.terms.channelType, namedNode(`${NOTIFY.namespace}WebHookChannel2023`)), )); expect(webhookSubscriptions).toHaveLength(1); subscriptionUrl = webhookSubscriptions[0].value; - - // It should also link to the server WebID - const webIds = quads.getObjects(webhookSubscriptions[0], NOTIFY.terms.webid, null); - expect(webIds).toHaveLength(1); - serverWebId = webIds[0].value; }); it('supports subscribing.', async(): Promise => { - await subscribe(notificationType, webId, subscriptionUrl, topic, { [NOTIFY.target]: target }); + const { sender } = + await subscribe(notificationType, webId, subscriptionUrl, topic, { [NOTIFY.sendTo]: target }) as any; + serverWebId = sender; }); it('emits Created events.', async(): Promise => { @@ -170,7 +167,7 @@ describe.each(stores)('A server supporting WebHookSubscription2021 using %s', (n }); }); - await subscribe(notificationType, webId, subscriptionUrl, topic, { [NOTIFY.target]: target, state: 'abc' }); + await subscribe(notificationType, webId, subscriptionUrl, topic, { [NOTIFY.sendTo]: target, state: 'abc' }); // Will resolve even though the resource did not change since subscribing const { request, response } = await clientPromise; @@ -184,7 +181,7 @@ describe.each(stores)('A server supporting WebHookSubscription2021 using %s', (n }); it('can remove notification channels.', async(): Promise => { - const { id } = await subscribe(notificationType, webId, subscriptionUrl, topic, { [NOTIFY.target]: target }) as any; + const { id } = await subscribe(notificationType, webId, subscriptionUrl, topic, { [NOTIFY.sendTo]: target }) as any; const response = await fetch(id, { method: 'DELETE' }); expect(response.status).toBe(205); diff --git a/test/unit/server/notifications/WebHookSubscription2021/WebHookSubscription2021.test.ts b/test/unit/server/notifications/WebHookChannel2023/WebHookChannel2023Type.test.ts similarity index 57% rename from test/unit/server/notifications/WebHookSubscription2021/WebHookSubscription2021.test.ts rename to test/unit/server/notifications/WebHookChannel2023/WebHookChannel2023Type.test.ts index ab0bd5c4ab..3ffbbaaaaa 100644 --- a/test/unit/server/notifications/WebHookSubscription2021/WebHookSubscription2021.test.ts +++ b/test/unit/server/notifications/WebHookChannel2023/WebHookChannel2023Type.test.ts @@ -1,5 +1,4 @@ import { DataFactory, Store } from 'n3'; -import type { Credentials } from '../../../../../src/authentication/Credentials'; import { AbsolutePathInteractionRoute, } from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute'; @@ -12,12 +11,12 @@ import { CONTEXT_NOTIFICATION } from '../../../../../src/server/notifications/No import type { NotificationChannel } from '../../../../../src/server/notifications/NotificationChannel'; import type { StateHandler } from '../../../../../src/server/notifications/StateHandler'; import type { - WebHookSubscription2021Channel, -} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookSubscription2021'; + WebhookChannel2023, +} from '../../../../../src/server/notifications/WebHookChannel2023/WebhookChannel2023Type'; import { - isWebHook2021Channel, - WebHookSubscription2021, -} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookSubscription2021'; + isWebHook2023Channel, + WebhookChannel2023Type, +} from '../../../../../src/server/notifications/WebHookChannel2023/WebhookChannel2023Type'; import { NOTIFY, RDF } from '../../../../../src/util/Vocabularies'; import quad = DataFactory.quad; import blankNode = DataFactory.blankNode; @@ -31,79 +30,59 @@ jest.mock('../../../../../src/logging/LogUtil', (): any => { jest.mock('uuid', (): any => ({ v4: (): string => '4c9b88c1-7502-4107-bb79-2a3a590c7aa3' })); -describe('A WebHookSubscription2021', (): void => { - const credentials: Credentials = { agent: { webId: 'http://example.org/alice' }}; - const target = 'http://example.org/somewhere-else'; +describe('A WebhookChannel2023Type', (): void => { + const sendTo = 'http://example.org/somewhere-else'; const topic = 'https://storage.example/resource'; const subject = blankNode(); let data: Store; - let channel: WebHookSubscription2021Channel; + let channel: WebhookChannel2023; const route = new AbsolutePathInteractionRoute('http://example.com/webhooks/'); const webIdRoute = new RelativePathInteractionRoute(route, '/webid'); let stateHandler: jest.Mocked; - let channelType: WebHookSubscription2021; + let channelType: WebhookChannel2023Type; beforeEach(async(): Promise => { data = new Store(); - data.addQuad(quad(subject, RDF.terms.type, NOTIFY.terms.WebHookSubscription2021)); + data.addQuad(quad(subject, RDF.terms.type, NOTIFY.terms.WebHookChannel2023)); data.addQuad(quad(subject, NOTIFY.terms.topic, namedNode(topic))); - data.addQuad(quad(subject, NOTIFY.terms.target, namedNode(target))); + data.addQuad(quad(subject, NOTIFY.terms.sendTo, namedNode(sendTo))); const id = 'http://example.com/webhooks/4c9b88c1-7502-4107-bb79-2a3a590c7aa3'; channel = { id, - type: NOTIFY.WebHookSubscription2021, + type: NOTIFY.WebHookChannel2023, topic: 'https://storage.example/resource', - target, - webId: 'http://example.org/alice', - // eslint-disable-next-line @typescript-eslint/naming-convention - unsubscribe_endpoint: id, + sendTo, }; stateHandler = { handleSafe: jest.fn(), } as any; - channelType = new WebHookSubscription2021(route, webIdRoute, stateHandler); + channelType = new WebhookChannel2023Type(route, webIdRoute, stateHandler); }); it('exposes a utility function to verify if a channel is a webhook channel.', async(): Promise => { - expect(isWebHook2021Channel(channel)).toBe(true); + expect(isWebHook2023Channel(channel)).toBe(true); (channel as NotificationChannel).type = 'something else'; - expect(isWebHook2021Channel(channel)).toBe(false); - }); - - it('returns a correct description of the subscription service.', async(): Promise => { - expect(channelType.getDescription()).toEqual({ - '@context': [ 'https://www.w3.org/ns/solid/notification/v1' ], - id: 'http://example.com/webhooks/', - channelType: 'http://www.w3.org/ns/solid/notifications#WebHookSubscription2021', - feature: [ 'accept', 'endAt', 'rate', 'startAt', 'state', 'notify:webhookAuth' ], - 'http://www.w3.org/ns/solid/notifications#webid': { id: 'http://example.com/webhooks/webid' }, - }); + expect(isWebHook2023Channel(channel)).toBe(false); }); it('correctly parses notification channel bodies.', async(): Promise => { - await expect(channelType.initChannel(data, credentials)).resolves.toEqual(channel); - }); - - it('errors if the credentials do not contain a WebID.', async(): Promise => { - await expect(channelType.initChannel(data, {})).rejects - .toThrow('A WebHookSubscription2021 subscription request needs to be authenticated with a WebID.'); + await expect(channelType.initChannel(data)).resolves.toEqual(channel); }); - it('removes the WebID when converting back to JSON-LD.', async(): Promise => { + it('adds the WebID when generating a JSON-LD representation of a channel.', async(): Promise => { await expect(channelType.toJsonLd(channel)).resolves.toEqual({ '@context': [ CONTEXT_NOTIFICATION, ], id: channel.id, - type: NOTIFY.WebHookSubscription2021, - target, + type: NOTIFY.WebHookChannel2023, + sendTo, topic, - // eslint-disable-next-line @typescript-eslint/naming-convention - unsubscribe_endpoint: channel.unsubscribe_endpoint, + sender: 'http://example.com/webhooks/webid', }); }); diff --git a/test/unit/server/notifications/WebHookSubscription2021/WebHookEmitter.test.ts b/test/unit/server/notifications/WebHookChannel2023/WebHookEmitter.test.ts similarity index 90% rename from test/unit/server/notifications/WebHookSubscription2021/WebHookEmitter.test.ts rename to test/unit/server/notifications/WebHookChannel2023/WebHookEmitter.test.ts index 0382c79f2b..b1047deeb0 100644 --- a/test/unit/server/notifications/WebHookSubscription2021/WebHookEmitter.test.ts +++ b/test/unit/server/notifications/WebHookChannel2023/WebHookEmitter.test.ts @@ -9,10 +9,10 @@ import { import type { Logger } from '../../../../../src/logging/Logger'; import { getLoggerFor } from '../../../../../src/logging/LogUtil'; import type { Notification } from '../../../../../src/server/notifications/Notification'; -import { WebHookEmitter } from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookEmitter'; import type { - WebHookSubscription2021Channel, -} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookSubscription2021'; + WebhookChannel2023, +} from '../../../../../src/server/notifications/WebHookChannel2023/WebhookChannel2023Type'; +import { WebHookEmitter } from '../../../../../src/server/notifications/WebHookChannel2023/WebHookEmitter'; import { NotImplementedHttpError } from '../../../../../src/util/errors/NotImplementedHttpError'; import { matchesAuthorizationScheme } from '../../../../../src/util/HeaderUtil'; import { trimTrailingSlashes } from '../../../../../src/util/PathUtil'; @@ -42,14 +42,11 @@ describe('A WebHookEmitter', (): void => { published: '123', }; let representation: Representation; - const channel: WebHookSubscription2021Channel = { + const channel: WebhookChannel2023 = { id: 'id', topic: 'http://example.com/foo', - type: NOTIFY.WebHookSubscription2021, - target: 'http://example.org/somewhere-else', - webId: 'http://example.org/other/#me', - // eslint-disable-next-line @typescript-eslint/naming-convention - unsubscribe_endpoint: 'http://example.org/unsubscribe', + type: NOTIFY.WebHookChannel2023, + sendTo: 'http://example.org/somewhere-else', }; let privateJwk: AlgJwk; @@ -120,7 +117,7 @@ describe('A WebHookEmitter', (): void => { // CHeck the DPoP proof const decodedDpopProof = await jwtVerify(dpop, publicObject); expect(decodedDpopProof.payload).toMatchObject({ - htu: channel.target, + htu: channel.sendTo, htm: 'POST', iat: now, jti: expect.stringContaining('-'), @@ -142,7 +139,7 @@ describe('A WebHookEmitter', (): void => { expect(logger.error).toHaveBeenCalledTimes(1); expect(logger.error).toHaveBeenLastCalledWith( - `There was an issue emitting a WebHook notification with target ${channel.target}: invalid request`, + `There was an issue emitting a WebHook notification with target ${channel.sendTo}: invalid request`, ); }); }); diff --git a/test/unit/server/notifications/WebHookSubscription2021/WebHookWebId.test.ts b/test/unit/server/notifications/WebHookChannel2023/WebHookWebId.test.ts similarity index 97% rename from test/unit/server/notifications/WebHookSubscription2021/WebHookWebId.test.ts rename to test/unit/server/notifications/WebHookChannel2023/WebHookWebId.test.ts index 3dca6d8202..5a19bfa65f 100644 --- a/test/unit/server/notifications/WebHookSubscription2021/WebHookWebId.test.ts +++ b/test/unit/server/notifications/WebHookChannel2023/WebHookWebId.test.ts @@ -3,7 +3,7 @@ import type { Operation } from '../../../../../src/http/Operation'; import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation'; import type { HttpRequest } from '../../../../../src/server/HttpRequest'; import type { HttpResponse } from '../../../../../src/server/HttpResponse'; -import { WebHookWebId } from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookWebId'; +import { WebHookWebId } from '../../../../../src/server/notifications/WebHookChannel2023/WebHookWebId'; import { readableToString } from '../../../../../src/util/StreamUtil'; import { SOLID } from '../../../../../src/util/Vocabularies'; const { namedNode, quad } = DataFactory; diff --git a/test/util/NotificationUtil.ts b/test/util/NotificationUtil.ts index 55bce5dd70..3abc4e54d3 100644 --- a/test/util/NotificationUtil.ts +++ b/test/util/NotificationUtil.ts @@ -2,7 +2,7 @@ import { fetch } from 'cross-fetch'; /** * Subscribes to a notification channel. - * @param type - The type of the notification channel, e.g., "NOTIFY.WebHookSubscription2021". + * @param type - The type of the notification channel, e.g., "NOTIFY.WebHookChannel2023". * @param webId - The WebID to spoof in the authorization header. This assumes the config uses the debug auth import. * @param subscriptionUrl - The subscription URL to which the request needs to be sent. * @param topic - The topic to subscribe to. diff --git a/test/util/Util.ts b/test/util/Util.ts index f254918030..59c585f0fb 100644 --- a/test/util/Util.ts +++ b/test/util/Util.ts @@ -29,8 +29,8 @@ const portNames = [ 'SetupMemory', 'SparqlStorage', 'Subdomains', - 'WebHookSubscription2021', - 'WebHookSubscription2021-client', + 'WebHookChannel2023', + 'WebHookChannel2023-client', 'WebSocketChannel2023', // Unit