feat: Replace WebHookSubscription2021 with WebHookChannel2023

This commit is contained in:
Joachim Van Herwegen 2023-04-21 15:47:33 +02:00
parent e9463483f4
commit d59a1595d5
19 changed files with 188 additions and 270 deletions

View File

@ -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

View File

@ -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" }
] ]
}, }
] ]
} }

View File

@ -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" }

View File

@ -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"
} }
] ]
} }

View File

@ -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.

View File

@ -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

View File

@ -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';

View File

@ -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()}`);
} }
} }

View File

@ -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;

View File

@ -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)}`);
}
}
}

View File

@ -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)}`);
}
}
}

View File

@ -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.
*/ */

View File

@ -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',
); );

View File

@ -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);

View File

@ -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,
}); });
}); });

View File

@ -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`,
); );
}); });
}); });

View File

@ -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;

View File

@ -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.

View File

@ -29,8 +29,8 @@ const portNames = [
'SetupMemory', 'SetupMemory',
'SparqlStorage', 'SparqlStorage',
'Subdomains', 'Subdomains',
'WebHookSubscription2021', 'WebHookChannel2023',
'WebHookSubscription2021-client', 'WebHookChannel2023-client',
'WebSocketChannel2023', 'WebSocketChannel2023',
// Unit // Unit