mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Replace WebHookSubscription2021 with WebHookChannel2023
This commit is contained in:
parent
e9463483f4
commit
d59a1595d5
@ -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
|
* *all*: Supports all available notification types of the Solid Notifications protocol
|
||||||
[specification](https://solidproject.org/TR/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.
|
* *disabled*: No notifications are sent out.
|
||||||
* *legacy-websocket*: Follows the legacy Solid WebSocket
|
* *legacy-websocket*: Follows the legacy Solid WebSocket
|
||||||
[specification](https://github.com/solid/solid-spec/blob/master/api-websockets.md).
|
[specification](https://github.com/solid/solid-spec/blob/master/api-websockets.md).
|
||||||
* *webhooks*: Follows the WebHookSubscription2021
|
* *webhooks*: Follows the WebHookChannel2023
|
||||||
[specification](https://github.com/solid/notifications/blob/main/webhook-subscription-2021.md) draft.
|
[specification](https://solid.github.io/notifications/webhook-channel-2023) draft.
|
||||||
* *websockets*: Follows the WebSocketSubscription2021
|
* *websockets*: Follows the WebSocketChannel2023
|
||||||
[specification](https://solidproject.org/TR/websocket-subscription-2021).
|
[specification](https://solid.github.io/notifications/websocket-channel-2023).
|
||||||
|
|
||||||
## Server-Factory
|
## Server-Factory
|
||||||
|
|
||||||
|
@ -2,10 +2,10 @@
|
|||||||
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld",
|
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld",
|
||||||
"@graph": [
|
"@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",
|
"@id": "urn:solid-server:default:WebHookNotificationHandler",
|
||||||
"@type": "TypedNotificationHandler",
|
"@type": "TypedNotificationHandler",
|
||||||
"type": "http://www.w3.org/ns/solid/notifications#WebHookSubscription2021",
|
"type": "http://www.w3.org/ns/solid/notifications#WebHookChannel2023",
|
||||||
"source": {
|
"source": {
|
||||||
"@type": "ComposedNotificationHandler",
|
"@type": "ComposedNotificationHandler",
|
||||||
"generator": { "@id": "urn:solid-server:default:BaseNotificationGenerator" },
|
"generator": { "@id": "urn:solid-server:default:BaseNotificationGenerator" },
|
||||||
@ -28,6 +28,6 @@
|
|||||||
"handlers": [
|
"handlers": [
|
||||||
{ "@id": "urn:solid-server:default:WebHookNotificationHandler" }
|
{ "@id": "urn:solid-server:default:WebHookNotificationHandler" }
|
||||||
]
|
]
|
||||||
},
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
"@id": "urn:solid-server:default:WebHookRoute",
|
"@id": "urn:solid-server:default:WebHookRoute",
|
||||||
"@type": "RelativePathInteractionRoute",
|
"@type": "RelativePathInteractionRoute",
|
||||||
"base": { "@id": "urn:solid-server:default:NotificationRoute" },
|
"base": { "@id": "urn:solid-server:default:NotificationRoute" },
|
||||||
"relativePath": "/WebHookSubscription2021/"
|
"relativePath": "/WebHookChannel2023/"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"@id": "urn:solid-server:default:WebHookWebIdRoute",
|
"@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",
|
"@id": "urn:solid-server:default:WebHookWebId",
|
||||||
"@type": "OperationRouterHandler",
|
"@type": "OperationRouterHandler",
|
||||||
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
||||||
"allowedPathNames": [ "/WebHookSubscription2021/webId$" ],
|
"allowedPathNames": [ "/WebHookChannel2023/webId$" ],
|
||||||
"handler": {
|
"handler": {
|
||||||
"@type": "WebHookWebId",
|
"@type": "WebHookWebId",
|
||||||
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
|
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
|
||||||
|
@ -2,16 +2,16 @@
|
|||||||
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld",
|
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld",
|
||||||
"@graph": [
|
"@graph": [
|
||||||
{
|
{
|
||||||
"comment": "Handles the subscriptions targeting a WebHookSubscription2021.",
|
"comment": "Handles the subscriptions targeting a WebHookChannel2023.",
|
||||||
"@id": "urn:solid-server:default:WebHookRouter",
|
"@id": "urn:solid-server:default:WebHookRouter",
|
||||||
"@type": "OperationRouterHandler",
|
"@type": "OperationRouterHandler",
|
||||||
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
||||||
"allowedMethods": [ "HEAD", "GET", "POST" ],
|
"allowedMethods": [ "HEAD", "GET", "POST" ],
|
||||||
"allowedPathNames": [ "/WebHookSubscription2021/$" ],
|
"allowedPathNames": [ "/WebHookChannel2023/$" ],
|
||||||
"handler": {
|
"handler": {
|
||||||
"@id": "urn:solid-server:default:WebHookSubscriber",
|
"@id": "urn:solid-server:default:WebHookSubscriber",
|
||||||
"@type": "NotificationSubscriber",
|
"@type": "NotificationSubscriber",
|
||||||
"channelType": { "@id": "urn:solid-server:default:WebHookSubscription2021" },
|
"channelType": { "@id": "urn:solid-server:default:WebHookChannel2023Type" },
|
||||||
"converter": { "@id": "urn:solid-server:default:RepresentationConverter" },
|
"converter": { "@id": "urn:solid-server:default:RepresentationConverter" },
|
||||||
"credentialsExtractor": { "@id": "urn:solid-server:default:CredentialsExtractor" },
|
"credentialsExtractor": { "@id": "urn:solid-server:default:CredentialsExtractor" },
|
||||||
"permissionReader": { "@id": "urn:solid-server:default:PermissionReader" },
|
"permissionReader": { "@id": "urn:solid-server:default:PermissionReader" },
|
||||||
@ -20,9 +20,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"comment": "Contains all the metadata relevant for a WebHookSubscription2021.",
|
"comment": "Contains all the metadata relevant for a WebHookChannel2023.",
|
||||||
"@id": "urn:solid-server:default:WebHookSubscription2021",
|
"@id": "urn:solid-server:default:WebHookChannel2023Type",
|
||||||
"@type": "WebHookSubscription2021",
|
"@type": "WebhookChannel2023Type",
|
||||||
"route": { "@id": "urn:solid-server:default:WebHookRoute" },
|
"route": { "@id": "urn:solid-server:default:WebHookRoute" },
|
||||||
"webIdRoute": { "@id": "urn:solid-server:default:WebHookWebIdRoute" },
|
"webIdRoute": { "@id": "urn:solid-server:default:WebHookWebIdRoute" },
|
||||||
"stateHandler": {
|
"stateHandler": {
|
||||||
@ -37,7 +37,7 @@
|
|||||||
"@type": "NotificationDescriber",
|
"@type": "NotificationDescriber",
|
||||||
"subscriptions": [
|
"subscriptions": [
|
||||||
{
|
{
|
||||||
"@id": "urn:solid-server:default:WebHookSubscription2021"
|
"@id": "urn:solid-server:default:WebHookChannel2023Type"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -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,
|
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.
|
as defined in the notification specification.
|
||||||
|
|
||||||
## WebHookSubscription2021
|
## WebHookChannel2023
|
||||||
|
|
||||||
The additions required to support
|
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:
|
are quite similar to those needed for WebSocketChannel2023:
|
||||||
|
|
||||||
* For discovery, there is a `WebHookDescriber`, which is an extension of a `NotificationDescriber`.
|
* 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.
|
* `WebHookEmitter` is the `NotificationEmitter` that sends the request.
|
||||||
* `WebHookUnsubscriber` and `WebHookWebId` are additional utility classes to support the spec requirements.
|
* `WebHookUnsubscriber` and `WebHookWebId` are additional utility classes to support the spec requirements.
|
||||||
|
@ -27,7 +27,7 @@ Doing a GET to `http://localhost:3000/.well-known/solid` then gives the followin
|
|||||||
<http://localhost:3000/.well-known/solid>
|
<http://localhost:3000/.well-known/solid>
|
||||||
a <http://www.w3.org/ns/pim/space#Storage> ;
|
a <http://www.w3.org/ns/pim/space#Storage> ;
|
||||||
notify:subscription <http://localhost:3000/.notifications/WebSocketChannel2023/> ,
|
notify:subscription <http://localhost:3000/.notifications/WebSocketChannel2023/> ,
|
||||||
<http://localhost:3000/.notifications/WebHookSubscription2021/> .
|
<http://localhost:3000/.notifications/WebHookChannel2023/> .
|
||||||
<http://localhost:3000/.notifications/WebSocketChannel2023/>
|
<http://localhost:3000/.notifications/WebSocketChannel2023/>
|
||||||
notify:channelType notify:WebSocketChannel2023 ;
|
notify:channelType notify:WebSocketChannel2023 ;
|
||||||
notify:feature notify:accept ,
|
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:startAt ,
|
||||||
notify:state .
|
notify:state .
|
||||||
<http://localhost:3000/.notifications/WebSocketChannel2023/>
|
<http://localhost:3000/.notifications/WebSocketChannel2023/>
|
||||||
notify:channelType notify:WebHookSubscription2021;
|
notify:channelType notify:WebHookChannel2023;
|
||||||
notify:feature notify:accept ,
|
notify:feature notify:accept ,
|
||||||
notify:endAt ,
|
notify:endAt ,
|
||||||
notify:rate ,
|
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:
|
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);
|
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).
|
and [`WebHookChannel2023`](https://solid.github.io/notifications/webhook-channel-2023).
|
||||||
_**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._
|
|
||||||
|
|
||||||
### WebSockets
|
### WebSockets
|
||||||
|
|
||||||
@ -104,18 +101,18 @@ ws.on('message', (notification) => console.log(notification));
|
|||||||
### WebHooks
|
### WebHooks
|
||||||
|
|
||||||
Similar to the WebSocket subscription, below is sample JSON-LD
|
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
|
```json
|
||||||
{
|
{
|
||||||
"@context": [ "https://www.w3.org/ns/solid/notification/v1" ],
|
"@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",
|
"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.
|
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:
|
The response would then be something like this:
|
||||||
@ -123,18 +120,13 @@ The response would then be something like this:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"@context": [ "https://www.w3.org/ns/solid/notification/v1" ],
|
"@context": [ "https://www.w3.org/ns/solid/notification/v1" ],
|
||||||
"id": "http://localhost:3000/.notifications/WebHookSubscription2021/eeaf2c17-699a-4e53-8355-e91d13807e5f",
|
"id": "http://localhost:3000/.notifications/WebHookChannel2023/eeaf2c17-699a-4e53-8355-e91d13807e5f",
|
||||||
"type": "http://www.w3.org/ns/solid/notifications#WebHookSubscription2021",
|
"type": "http://www.w3.org/ns/solid/notifications#WebHookChannel2023",
|
||||||
"topic": "http://localhost:3000/foo",
|
"topic": "http://localhost:3000/foo",
|
||||||
"target": "https://example.com/webhook",
|
"sendTo": "https://example.com/webhook"
|
||||||
"unsubscribe_endpoint": "http://localhost:3000/.notifications/WebHookSubscription2021/eeaf2c17-699a-4e53-8355-e91d13807e5f"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
## Unsubscribing from a notification channel
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
@ -321,10 +321,10 @@ export * from './server/notifications/serialize/ConvertingNotificationSerializer
|
|||||||
export * from './server/notifications/serialize/JsonLdNotificationSerializer';
|
export * from './server/notifications/serialize/JsonLdNotificationSerializer';
|
||||||
export * from './server/notifications/serialize/NotificationSerializer';
|
export * from './server/notifications/serialize/NotificationSerializer';
|
||||||
|
|
||||||
// Server/Notifications/WebHookSubscription2021
|
// Server/Notifications/WebHookChannel2023
|
||||||
export * from './server/notifications/WebHookSubscription2021/WebHookEmitter';
|
export * from './server/notifications/WebHookChannel2023/WebhookChannel2023Type';
|
||||||
export * from './server/notifications/WebHookSubscription2021/WebHookSubscription2021';
|
export * from './server/notifications/WebHookChannel2023/WebHookEmitter';
|
||||||
export * from './server/notifications/WebHookSubscription2021/WebHookWebId';
|
export * from './server/notifications/WebHookChannel2023/WebHookWebId';
|
||||||
|
|
||||||
// Server/Notifications/WebSocketChannel2023
|
// Server/Notifications/WebSocketChannel2023
|
||||||
export * from './server/notifications/WebSocketChannel2023/WebSocket2023Emitter';
|
export * from './server/notifications/WebSocketChannel2023/WebSocket2023Emitter';
|
||||||
|
@ -9,11 +9,14 @@ import { trimTrailingSlashes } from '../../../util/PathUtil';
|
|||||||
import { readableToString } from '../../../util/StreamUtil';
|
import { readableToString } from '../../../util/StreamUtil';
|
||||||
import type { NotificationEmitterInput } from '../NotificationEmitter';
|
import type { NotificationEmitterInput } from '../NotificationEmitter';
|
||||||
import { NotificationEmitter } from '../NotificationEmitter';
|
import { NotificationEmitter } from '../NotificationEmitter';
|
||||||
import type { WebHookSubscription2021Channel } from './WebHookSubscription2021';
|
import type { WebhookChannel2023 } from './WebhookChannel2023Type';
|
||||||
import { isWebHook2021Channel } from './WebHookSubscription2021';
|
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.
|
* 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<void> {
|
public async canHandle({ channel }: NotificationEmitterInput): Promise<void> {
|
||||||
if (!isWebHook2021Channel(channel)) {
|
if (!isWebHook2023Channel(channel)) {
|
||||||
throw new NotImplementedHttpError(`${channel.id} is not a WebHookSubscription2021 channel.`);
|
throw new NotImplementedHttpError(`${channel.id} is not a WebHookChannel2023 channel.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async handle({ channel, representation }: NotificationEmitterInput): Promise<void> {
|
public async handle({ channel, representation }: NotificationEmitterInput): Promise<void> {
|
||||||
// Cast was checked in `canHandle`
|
// Cast was checked in `canHandle`
|
||||||
const webHookChannel = channel as WebHookSubscription2021Channel;
|
const webHookChannel = channel as WebhookChannel2023;
|
||||||
this.logger.debug(`Emitting WebHook notification with target ${webHookChannel.target}`);
|
this.logger.debug(`Emitting WebHook notification with target ${webHookChannel.sendTo}`);
|
||||||
|
|
||||||
const privateKey = await this.jwkGenerator.getPrivateKey();
|
const privateKey = await this.jwkGenerator.getPrivateKey();
|
||||||
const publicKey = await this.jwkGenerator.getPublicKey();
|
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
|
// Make sure both header and proof have the same timestamp
|
||||||
const time = Date.now();
|
const time = Date.now();
|
||||||
|
|
||||||
// The spec is not completely clear on which fields actually need to be present in the token,
|
// Currently the spec does not define how the notification sender should identify.
|
||||||
// only that it needs to contain the WebID somehow.
|
|
||||||
// The format used here has been chosen to be similar
|
// The format used here has been chosen to be similar
|
||||||
// to how ID tokens are described in the Solid-OIDC specification for consistency.
|
// to how ID tokens are described in the Solid-OIDC specification for consistency.
|
||||||
const dpopToken = await new SignJWT({
|
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
|
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop#section-4.2
|
||||||
const dpopProof = await new SignJWT({
|
const dpopProof = await new SignJWT({
|
||||||
htu: webHookChannel.target,
|
htu: webHookChannel.sendTo,
|
||||||
htm: 'POST',
|
htm: 'POST',
|
||||||
}).setProtectedHeader({ alg: privateKey.alg, jwk: publicKey, typ: 'dpop+jwt' })
|
}).setProtectedHeader({ alg: privateKey.alg, jwk: publicKey, typ: 'dpop+jwt' })
|
||||||
.setIssuedAt(time)
|
.setIssuedAt(time)
|
||||||
.setJti(v4())
|
.setJti(v4())
|
||||||
.sign(privateKeyObject);
|
.sign(privateKeyObject);
|
||||||
|
|
||||||
const response = await fetch(webHookChannel.target, {
|
const response = await fetch(webHookChannel.sendTo, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': representation.metadata.contentType!,
|
'content-type': representation.metadata.contentType!,
|
||||||
@ -93,7 +95,7 @@ export class WebHookEmitter extends NotificationEmitter {
|
|||||||
body: await readableToString(representation.data),
|
body: await readableToString(representation.data),
|
||||||
});
|
});
|
||||||
if (response.status >= 400) {
|
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()}`);
|
await response.text()}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -9,11 +9,9 @@ import type { OperationHttpHandlerInput } from '../../OperationHttpHandler';
|
|||||||
import { OperationHttpHandler } from '../../OperationHttpHandler';
|
import { OperationHttpHandler } from '../../OperationHttpHandler';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The WebHookSubscription2021 requires the server to have a WebID
|
* Generates a fixed WebID that we use to identify the server for notifications sent using a WebHookChannel2023.
|
||||||
* that is used during the generation of the DPoP headers.
|
* This is used in tandem with the tokens generated by the {@link WebHookEmitter}.
|
||||||
* There are no real specifications about what this should contain or look like,
|
* This is a minimal WebID with only the `solid:oidcIssuer` triple.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export class WebHookWebId extends OperationHttpHandler {
|
export class WebHookWebId extends OperationHttpHandler {
|
||||||
private readonly turtle: string;
|
private readonly turtle: string;
|
@ -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<WebhookChannel2023> {
|
||||||
|
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<Record<string, unknown>> {
|
||||||
|
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<void> {
|
||||||
|
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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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<WebHookSubscription2021Channel> {
|
|
||||||
// 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<Record<string, unknown>> {
|
|
||||||
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<void> {
|
|
||||||
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)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -27,7 +27,7 @@ export function isWebSocket2023Channel(channel: NotificationChannel): channel is
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The notification channel type WebSocketChannel2023 as described in
|
* 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.
|
* Requires read permissions on a resource to be able to receive notifications.
|
||||||
*/
|
*/
|
||||||
|
@ -204,13 +204,13 @@ export const NOTIFY = createVocabulary('http://www.w3.org/ns/solid/notifications
|
|||||||
'receiveFrom',
|
'receiveFrom',
|
||||||
'startAt',
|
'startAt',
|
||||||
'state',
|
'state',
|
||||||
|
'sender',
|
||||||
|
'sendTo',
|
||||||
'subscription',
|
'subscription',
|
||||||
'target',
|
|
||||||
'topic',
|
'topic',
|
||||||
'webhookAuth',
|
'webhookAuth',
|
||||||
'webid',
|
|
||||||
|
|
||||||
'WebHookSubscription2021',
|
'WebHookChannel2023',
|
||||||
'WebSocketChannel2023',
|
'WebSocketChannel2023',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -23,14 +23,14 @@ import {
|
|||||||
import quad = DataFactory.quad;
|
import quad = DataFactory.quad;
|
||||||
import namedNode = DataFactory.namedNode;
|
import namedNode = DataFactory.namedNode;
|
||||||
|
|
||||||
const port = getPort('WebHookSubscription2021');
|
const port = getPort('WebHookChannel2023');
|
||||||
const baseUrl = `http://localhost:${port}/`;
|
const baseUrl = `http://localhost:${port}/`;
|
||||||
const clientPort = getPort('WebHookSubscription2021-client');
|
const clientPort = getPort('WebHookChannel2023-client');
|
||||||
const target = `http://localhost:${clientPort}/`;
|
const target = `http://localhost:${clientPort}/`;
|
||||||
const webId = 'http://example.com/card/#me';
|
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][] = [
|
const stores: [string, any][] = [
|
||||||
[ 'in-memory storage', {
|
[ 'in-memory storage', {
|
||||||
configs: [ 'storage/backend/memory.json', 'util/resource-locker/memory.json' ],
|
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;
|
let app: App;
|
||||||
const topic = joinUrl(baseUrl, '/foo');
|
const topic = joinUrl(baseUrl, '/foo');
|
||||||
let storageDescriptionUrl: string;
|
let storageDescriptionUrl: string;
|
||||||
@ -99,19 +99,16 @@ describe.each(stores)('A server supporting WebHookSubscription2021 using %s', (n
|
|||||||
// Find the notification channel for websockets
|
// Find the notification channel for websockets
|
||||||
const subscriptions = quads.getObjects(storageDescriptionUrl, NOTIFY.terms.subscription, null);
|
const subscriptions = quads.getObjects(storageDescriptionUrl, NOTIFY.terms.subscription, null);
|
||||||
const webhookSubscriptions = subscriptions.filter((channel): boolean => quads.has(
|
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);
|
expect(webhookSubscriptions).toHaveLength(1);
|
||||||
subscriptionUrl = webhookSubscriptions[0].value;
|
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<void> => {
|
it('supports subscribing.', async(): Promise<void> => {
|
||||||
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<void> => {
|
it('emits Created events.', async(): Promise<void> => {
|
||||||
@ -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
|
// Will resolve even though the resource did not change since subscribing
|
||||||
const { request, response } = await clientPromise;
|
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<void> => {
|
it('can remove notification channels.', async(): Promise<void> => {
|
||||||
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' });
|
const response = await fetch(id, { method: 'DELETE' });
|
||||||
expect(response.status).toBe(205);
|
expect(response.status).toBe(205);
|
@ -1,5 +1,4 @@
|
|||||||
import { DataFactory, Store } from 'n3';
|
import { DataFactory, Store } from 'n3';
|
||||||
import type { Credentials } from '../../../../../src/authentication/Credentials';
|
|
||||||
import {
|
import {
|
||||||
AbsolutePathInteractionRoute,
|
AbsolutePathInteractionRoute,
|
||||||
} from '../../../../../src/identity/interaction/routing/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 { NotificationChannel } from '../../../../../src/server/notifications/NotificationChannel';
|
||||||
import type { StateHandler } from '../../../../../src/server/notifications/StateHandler';
|
import type { StateHandler } from '../../../../../src/server/notifications/StateHandler';
|
||||||
import type {
|
import type {
|
||||||
WebHookSubscription2021Channel,
|
WebhookChannel2023,
|
||||||
} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookSubscription2021';
|
} from '../../../../../src/server/notifications/WebHookChannel2023/WebhookChannel2023Type';
|
||||||
import {
|
import {
|
||||||
isWebHook2021Channel,
|
isWebHook2023Channel,
|
||||||
WebHookSubscription2021,
|
WebhookChannel2023Type,
|
||||||
} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookSubscription2021';
|
} from '../../../../../src/server/notifications/WebHookChannel2023/WebhookChannel2023Type';
|
||||||
import { NOTIFY, RDF } from '../../../../../src/util/Vocabularies';
|
import { NOTIFY, RDF } from '../../../../../src/util/Vocabularies';
|
||||||
import quad = DataFactory.quad;
|
import quad = DataFactory.quad;
|
||||||
import blankNode = DataFactory.blankNode;
|
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' }));
|
jest.mock('uuid', (): any => ({ v4: (): string => '4c9b88c1-7502-4107-bb79-2a3a590c7aa3' }));
|
||||||
|
|
||||||
describe('A WebHookSubscription2021', (): void => {
|
describe('A WebhookChannel2023Type', (): void => {
|
||||||
const credentials: Credentials = { agent: { webId: 'http://example.org/alice' }};
|
const sendTo = 'http://example.org/somewhere-else';
|
||||||
const target = 'http://example.org/somewhere-else';
|
|
||||||
const topic = 'https://storage.example/resource';
|
const topic = 'https://storage.example/resource';
|
||||||
const subject = blankNode();
|
const subject = blankNode();
|
||||||
let data: Store;
|
let data: Store;
|
||||||
let channel: WebHookSubscription2021Channel;
|
let channel: WebhookChannel2023;
|
||||||
const route = new AbsolutePathInteractionRoute('http://example.com/webhooks/');
|
const route = new AbsolutePathInteractionRoute('http://example.com/webhooks/');
|
||||||
const webIdRoute = new RelativePathInteractionRoute(route, '/webid');
|
const webIdRoute = new RelativePathInteractionRoute(route, '/webid');
|
||||||
let stateHandler: jest.Mocked<StateHandler>;
|
let stateHandler: jest.Mocked<StateHandler>;
|
||||||
let channelType: WebHookSubscription2021;
|
let channelType: WebhookChannel2023Type;
|
||||||
|
|
||||||
beforeEach(async(): Promise<void> => {
|
beforeEach(async(): Promise<void> => {
|
||||||
data = new Store();
|
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.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';
|
const id = 'http://example.com/webhooks/4c9b88c1-7502-4107-bb79-2a3a590c7aa3';
|
||||||
channel = {
|
channel = {
|
||||||
id,
|
id,
|
||||||
type: NOTIFY.WebHookSubscription2021,
|
type: NOTIFY.WebHookChannel2023,
|
||||||
topic: 'https://storage.example/resource',
|
topic: 'https://storage.example/resource',
|
||||||
target,
|
sendTo,
|
||||||
webId: 'http://example.org/alice',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
unsubscribe_endpoint: id,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
stateHandler = {
|
stateHandler = {
|
||||||
handleSafe: jest.fn(),
|
handleSafe: jest.fn(),
|
||||||
} as any;
|
} 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<void> => {
|
it('exposes a utility function to verify if a channel is a webhook channel.', async(): Promise<void> => {
|
||||||
expect(isWebHook2021Channel(channel)).toBe(true);
|
expect(isWebHook2023Channel(channel)).toBe(true);
|
||||||
|
|
||||||
(channel as NotificationChannel).type = 'something else';
|
(channel as NotificationChannel).type = 'something else';
|
||||||
expect(isWebHook2021Channel(channel)).toBe(false);
|
expect(isWebHook2023Channel(channel)).toBe(false);
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a correct description of the subscription service.', async(): Promise<void> => {
|
|
||||||
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' },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('correctly parses notification channel bodies.', async(): Promise<void> => {
|
it('correctly parses notification channel bodies.', async(): Promise<void> => {
|
||||||
await expect(channelType.initChannel(data, credentials)).resolves.toEqual(channel);
|
await expect(channelType.initChannel(data)).resolves.toEqual(channel);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('errors if the credentials do not contain a WebID.', async(): Promise<void> => {
|
it('adds the WebID when generating a JSON-LD representation of a channel.', async(): Promise<void> => {
|
||||||
await expect(channelType.initChannel(data, {})).rejects
|
|
||||||
.toThrow('A WebHookSubscription2021 subscription request needs to be authenticated with a WebID.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('removes the WebID when converting back to JSON-LD.', async(): Promise<void> => {
|
|
||||||
await expect(channelType.toJsonLd(channel)).resolves.toEqual({
|
await expect(channelType.toJsonLd(channel)).resolves.toEqual({
|
||||||
'@context': [
|
'@context': [
|
||||||
CONTEXT_NOTIFICATION,
|
CONTEXT_NOTIFICATION,
|
||||||
],
|
],
|
||||||
id: channel.id,
|
id: channel.id,
|
||||||
type: NOTIFY.WebHookSubscription2021,
|
type: NOTIFY.WebHookChannel2023,
|
||||||
target,
|
sendTo,
|
||||||
topic,
|
topic,
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
sender: 'http://example.com/webhooks/webid',
|
||||||
unsubscribe_endpoint: channel.unsubscribe_endpoint,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -9,10 +9,10 @@ import {
|
|||||||
import type { Logger } from '../../../../../src/logging/Logger';
|
import type { Logger } from '../../../../../src/logging/Logger';
|
||||||
import { getLoggerFor } from '../../../../../src/logging/LogUtil';
|
import { getLoggerFor } from '../../../../../src/logging/LogUtil';
|
||||||
import type { Notification } from '../../../../../src/server/notifications/Notification';
|
import type { Notification } from '../../../../../src/server/notifications/Notification';
|
||||||
import { WebHookEmitter } from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookEmitter';
|
|
||||||
import type {
|
import type {
|
||||||
WebHookSubscription2021Channel,
|
WebhookChannel2023,
|
||||||
} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookSubscription2021';
|
} from '../../../../../src/server/notifications/WebHookChannel2023/WebhookChannel2023Type';
|
||||||
|
import { WebHookEmitter } from '../../../../../src/server/notifications/WebHookChannel2023/WebHookEmitter';
|
||||||
import { NotImplementedHttpError } from '../../../../../src/util/errors/NotImplementedHttpError';
|
import { NotImplementedHttpError } from '../../../../../src/util/errors/NotImplementedHttpError';
|
||||||
import { matchesAuthorizationScheme } from '../../../../../src/util/HeaderUtil';
|
import { matchesAuthorizationScheme } from '../../../../../src/util/HeaderUtil';
|
||||||
import { trimTrailingSlashes } from '../../../../../src/util/PathUtil';
|
import { trimTrailingSlashes } from '../../../../../src/util/PathUtil';
|
||||||
@ -42,14 +42,11 @@ describe('A WebHookEmitter', (): void => {
|
|||||||
published: '123',
|
published: '123',
|
||||||
};
|
};
|
||||||
let representation: Representation;
|
let representation: Representation;
|
||||||
const channel: WebHookSubscription2021Channel = {
|
const channel: WebhookChannel2023 = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
topic: 'http://example.com/foo',
|
topic: 'http://example.com/foo',
|
||||||
type: NOTIFY.WebHookSubscription2021,
|
type: NOTIFY.WebHookChannel2023,
|
||||||
target: 'http://example.org/somewhere-else',
|
sendTo: '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',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let privateJwk: AlgJwk;
|
let privateJwk: AlgJwk;
|
||||||
@ -120,7 +117,7 @@ describe('A WebHookEmitter', (): void => {
|
|||||||
// CHeck the DPoP proof
|
// CHeck the DPoP proof
|
||||||
const decodedDpopProof = await jwtVerify(dpop, publicObject);
|
const decodedDpopProof = await jwtVerify(dpop, publicObject);
|
||||||
expect(decodedDpopProof.payload).toMatchObject({
|
expect(decodedDpopProof.payload).toMatchObject({
|
||||||
htu: channel.target,
|
htu: channel.sendTo,
|
||||||
htm: 'POST',
|
htm: 'POST',
|
||||||
iat: now,
|
iat: now,
|
||||||
jti: expect.stringContaining('-'),
|
jti: expect.stringContaining('-'),
|
||||||
@ -142,7 +139,7 @@ describe('A WebHookEmitter', (): void => {
|
|||||||
|
|
||||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||||
expect(logger.error).toHaveBeenLastCalledWith(
|
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`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
@ -3,7 +3,7 @@ import type { Operation } from '../../../../../src/http/Operation';
|
|||||||
import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation';
|
import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation';
|
||||||
import type { HttpRequest } from '../../../../../src/server/HttpRequest';
|
import type { HttpRequest } from '../../../../../src/server/HttpRequest';
|
||||||
import type { HttpResponse } from '../../../../../src/server/HttpResponse';
|
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 { readableToString } from '../../../../../src/util/StreamUtil';
|
||||||
import { SOLID } from '../../../../../src/util/Vocabularies';
|
import { SOLID } from '../../../../../src/util/Vocabularies';
|
||||||
const { namedNode, quad } = DataFactory;
|
const { namedNode, quad } = DataFactory;
|
@ -2,7 +2,7 @@ import { fetch } from 'cross-fetch';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribes to a notification channel.
|
* 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 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 subscriptionUrl - The subscription URL to which the request needs to be sent.
|
||||||
* @param topic - The topic to subscribe to.
|
* @param topic - The topic to subscribe to.
|
||||||
|
@ -29,8 +29,8 @@ const portNames = [
|
|||||||
'SetupMemory',
|
'SetupMemory',
|
||||||
'SparqlStorage',
|
'SparqlStorage',
|
||||||
'Subdomains',
|
'Subdomains',
|
||||||
'WebHookSubscription2021',
|
'WebHookChannel2023',
|
||||||
'WebHookSubscription2021-client',
|
'WebHookChannel2023-client',
|
||||||
'WebSocketChannel2023',
|
'WebSocketChannel2023',
|
||||||
|
|
||||||
// Unit
|
// Unit
|
||||||
|
Loading…
x
Reference in New Issue
Block a user