feat: Generalize and extend notification channel type behaviour

This commit is contained in:
Joachim Van Herwegen 2023-01-27 11:53:30 +01:00
parent 7d029a9465
commit c36f15e2da
32 changed files with 1291 additions and 624 deletions

View File

@ -3,6 +3,7 @@
"Adapter", "Adapter",
"AlgJwk", "AlgJwk",
"BaseActivityEmitter", "BaseActivityEmitter",
"BaseChannelType",
"BaseHttpError", "BaseHttpError",
"BaseRouterHandler", "BaseRouterHandler",
"BasicConditions", "BasicConditions",

View File

@ -5,7 +5,7 @@
"comment": "Handles the generation and serialization of notifications for WebHookSubscription2021.", "comment": "Handles the generation and serialization of notifications for WebHookSubscription2021.",
"@id": "urn:solid-server:default:WebHookNotificationHandler", "@id": "urn:solid-server:default:WebHookNotificationHandler",
"@type": "TypedNotificationHandler", "@type": "TypedNotificationHandler",
"type": "WebHookSubscription2021", "type": "http://www.w3.org/ns/solid/notifications#WebHookSubscription2021",
"source": { "source": {
"@type": "ComposedNotificationHandler", "@type": "ComposedNotificationHandler",
"generator": { "@id": "urn:solid-server:default:BaseNotificationGenerator" }, "generator": { "@id": "urn:solid-server:default:BaseNotificationGenerator" },

View File

@ -11,16 +11,17 @@
"handler": { "handler": {
"@type": "NotificationSubscriber", "@type": "NotificationSubscriber",
"channelType": { "@id": "urn:solid-server:default:WebHookSubscription2021" }, "channelType": { "@id": "urn:solid-server:default:WebHookSubscription2021" },
"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" },
"authorizer": { "@id": "urn:solid-server:default:Authorizer" } "authorizer": { "@id": "urn:solid-server:default:Authorizer" },
"storage": { "@id": "urn:solid-server:default:SubscriptionStorage" }
} }
}, },
{ {
"comment": "Contains all the metadata relevant for a WebHookSubscription2021.", "comment": "Contains all the metadata relevant for a WebHookSubscription2021.",
"@id": "urn:solid-server:default:WebHookSubscription2021", "@id": "urn:solid-server:default:WebHookSubscription2021",
"@type": "WebHookSubscription2021", "@type": "WebHookSubscription2021",
"storage": { "@id": "urn:solid-server:default:SubscriptionStorage" },
"unsubscribeRoute": { "@id": "urn:solid-server:default:WebHookUnsubscribeRoute" }, "unsubscribeRoute": { "@id": "urn:solid-server:default:WebHookUnsubscribeRoute" },
"stateHandler": { "stateHandler": {
"@type": "BaseStateHandler", "@type": "BaseStateHandler",

View File

@ -5,7 +5,7 @@
"comment": "Handles the generation and serialization of notifications for WebSocketSubscription2021.", "comment": "Handles the generation and serialization of notifications for WebSocketSubscription2021.",
"@id": "urn:solid-server:default:WebSocket2021NotificationHandler", "@id": "urn:solid-server:default:WebSocket2021NotificationHandler",
"@type": "TypedNotificationHandler", "@type": "TypedNotificationHandler",
"type": "WebSocketSubscription2021", "type": "http://www.w3.org/ns/solid/notifications#WebSocketSubscription2021",
"source": { "source": {
"@type": "ComposedNotificationHandler", "@type": "ComposedNotificationHandler",
"generator": { "@id": "urn:solid-server:default:BaseNotificationGenerator" }, "generator": { "@id": "urn:solid-server:default:BaseNotificationGenerator" },

View File

@ -11,9 +11,11 @@
"handler": { "handler": {
"@type": "NotificationSubscriber", "@type": "NotificationSubscriber",
"channelType": { "@id": "urn:solid-server:default:WebSocketSubscription2021" }, "channelType": { "@id": "urn:solid-server:default:WebSocketSubscription2021" },
"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" },
"authorizer": { "@id": "urn:solid-server:default:Authorizer" } "authorizer": { "@id": "urn:solid-server:default:Authorizer" },
"storage": { "@id": "urn:solid-server:default:SubscriptionStorage" }
} }
}, },
{ {
@ -26,7 +28,6 @@
"comment": "Contains all the metadata relevant for a WebSocketSubscription2021.", "comment": "Contains all the metadata relevant for a WebSocketSubscription2021.",
"@id": "urn:solid-server:default:WebSocketSubscription2021", "@id": "urn:solid-server:default:WebSocketSubscription2021",
"@type": "WebSocketSubscription2021", "@type": "WebSocketSubscription2021",
"storage": { "@id": "urn:solid-server:default:SubscriptionStorage" },
"route": { "@id": "urn:solid-server:default:WebSocket2021Route" } "route": { "@id": "urn:solid-server:default:WebSocket2021Route" }
}, },

View File

@ -1,9 +1,9 @@
# Notifications # Notifications
This section covers the architecture used to support Notifications protocol This section covers the architecture used to support the Notifications protocol
as described in <https://solidproject.org/TR/notifications-protocol>. as described in <https://solidproject.org/TR/2022/notifications-protocol-20221231>.
There are 3 core architectural components to this that each have separate entry points: There are three core architectural components, that have distinct entry points:
* Exposing metadata to allow discovery of the subscription type. * Exposing metadata to allow discovery of the subscription type.
* Handling subscriptions targeting a resource. * Handling subscriptions targeting a resource.
@ -19,12 +19,13 @@ as the notification subscription URL is always located in the root of the server
flowchart LR flowchart LR
StorageDescriptionHandler("<br>StorageDescriptionHandler") StorageDescriptionHandler("<br>StorageDescriptionHandler")
StorageDescriptionHandler --> StorageDescriber("<strong>StorageDescriber</strong><br>ArrayUnionHandler") StorageDescriptionHandler --> StorageDescriber("<strong>StorageDescriber</strong><br>ArrayUnionHandler")
StorageDescriber --> StorageDescriberArgs StorageDescriber --> NotificationDescriber("NotificationDescriber<br>NotificationDescriber")
NotificationDescriber --> NotificationDescriberArgs
subgraph StorageDescriberArgs[" "] subgraph NotificationDescriberArgs[" "]
direction LR direction LR
NotificationDescriber("<br>NotificationDescriber") NotificationChannelType("<br>NotificationChannelType")
NotificationDescriber2("<br>NotificationDescriber") NotificationChannelType2("<br>NotificationChannelType")
end end
``` ```
@ -33,14 +34,16 @@ and to handle content negotiation.
To generate the data we have multiple `StorageDescriber`s, To generate the data we have multiple `StorageDescriber`s,
whose results get merged together in an `ArrayUnionHandler`. whose results get merged together in an `ArrayUnionHandler`.
A `NotificationChannelType` contains the specific details of a specification notification channel type,
including a JSON-LD representation of the corresponding subscription resource.
One specific instance of a `StorageDescriber` is a `NotificationSubcriber`, One specific instance of a `StorageDescriber` is a `NotificationSubcriber`,
that contains all the necessary presets to describe a notification subscription type. which merges those JSON-LD descriptions into a single set of RDF quads.
When adding a new subscription type, When adding a new subscription type,
a new instance of such a class should be added to the `urn:solid-server:default:StorageDescriber`. a new instance of such a class should be added to the `urn:solid-server:default:StorageDescriber`.
## NotificationChannel ## NotificationChannel
To subscribe, a client has to send a specific JSON-LD request to the URl found during discovery. To subscribe, a client has to send a specific JSON-LD request to the URL found during discovery.
```mermaid ```mermaid
flowchart LR flowchart LR
@ -50,9 +53,9 @@ flowchart LR
subgraph NotificationTypeHandlerArgs[" "] subgraph NotificationTypeHandlerArgs[" "]
direction LR direction LR
OperationRouterHandler("<br>OperationRouterHandler") --> NotificationSubscriber("<br>NotificationSubscriber") OperationRouterHandler("<br>OperationRouterHandler") --> NotificationSubscriber("<br>NotificationSubscriber")
NotificationSubscriber --> SubscriptionType("<br><i>SubscriptionType</i>") NotificationChannelType --> NotificationChannelType("<br><i>NotificationChannelType</i>")
OperationRouterHandler2("<br>OperationRouterHandler") --> NotificationSubscriber2("<br>NotificationSubscriber") OperationRouterHandler2("<br>OperationRouterHandler") --> NotificationSubscriber2("<br>NotificationSubscriber")
NotificationSubscriber2 --> SubscriptionType2("<br><i>SubscriptionType</i>") NotificationChannelType2 --> NotificationChannelType2("<br><i>NotificationChannelType</i>")
end end
``` ```
@ -60,7 +63,7 @@ Every subscription type should have a subscription URL relative to the root noti
which in our configs is set to `/.notifications/`. which in our configs is set to `/.notifications/`.
For every type there is then a `OperationRouterHandler` that accepts requests to that specific URL, For every type there is then a `OperationRouterHandler` that accepts requests to that specific URL,
after which a `NotificationSubscriber` handles all checks related to subscribing, after which a `NotificationSubscriber` handles all checks related to subscribing,
for which it uses a `SubscriptionType` that contains all the information necessary for a specific type. for which it uses a `NotificationChannelType`.
If the subscription is valid and has authorization, the results will be saved in a `NotificationChannelStorage`. If the subscription is valid and has authorization, the results will be saved in a `NotificationChannelStorage`.
## Activity ## Activity
@ -99,13 +102,13 @@ To add support for [WebSocketSubscription2021](https://solidproject.org/TR/2022/
notifications, notifications,
components were added as described in the documentation above. components were added as described in the documentation above.
For discovery a `NotificationDescriber` was added with the corresponding settings. For discovery, a `NotificationDescriber` was added with the corresponding settings.
As `SubscriptionType` there is a specific `WebSocketSubscription2021` that contains all the necessary information. As `SubscriptionType`, there is a specific `WebSocketSubscription2021` that contains all the necessary information.
### Handling notifications ### Handling notifications
As `NotificationHandler` the following architecture is used: As `NotificationHandler`, the following architecture is used:
```mermaid ```mermaid
flowchart TB flowchart TB
@ -137,8 +140,8 @@ and also caches the result so it can be reused by multiple subscriptions.
`urn:solid-server:default:BaseNotificationSerializer` converts the Notification to a JSON-LD representation `urn:solid-server:default:BaseNotificationSerializer` converts the Notification to a JSON-LD representation
and handles any necessary content negotiation based on the `accept` notification feature. and handles any necessary content negotiation based on the `accept` notification feature.
A `WebSocket2021Emitter` is a specific emitter that checks the current open WebSockets A `WebSocket2021Emitter` is a specific emitter that checks
if they correspond to the subscription. whether the current open WebSockets correspond to the subscription.
### WebSockets ### WebSockets
@ -163,12 +166,12 @@ flowchart TB
``` ```
To detect and store WebSocket connections, the `WebSocket2021Listener` is added as a listener to the HTTP server. To detect and store WebSocket connections, the `WebSocket2021Listener` is added as a listener to the HTTP server.
For all WebSocket connections that get opened, it verifies if they correspond to an existing subscription. For all WebSocket connections that get opened, it verifies whether they correspond to an existing subscription.
If yes the information gets sent out to its stored `WebSocket2021Handler`. If yes, the information gets sent out to its stored `WebSocket2021Handler`.
In this case this is a `SequenceHandler` which contains a `WebSocket2021Storer` and a `BaseStateHandler`. In this case, this is a `SequenceHandler`, which contains a `WebSocket2021Storer` and a `BaseStateHandler`.
The `WebSocket2021Storer` will store the WebSocket in the same map used by the `WebSocket2021Emitter`, The `WebSocket2021Storer` will store the WebSocket in the same map used by the `WebSocket2021Emitter`,
so that class can later on emit events as mentioned above. 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.

320
package-lock.json generated
View File

@ -30,6 +30,7 @@
"@types/proper-lockfile": "^4.1.2", "@types/proper-lockfile": "^4.1.2",
"@types/pump": "^1.1.1", "@types/pump": "^1.1.1",
"@types/punycode": "^2.1.0", "@types/punycode": "^2.1.0",
"@types/rdf-validate-shacl": "^0.4.1",
"@types/sparqljs": "^3.1.3", "@types/sparqljs": "^3.1.3",
"@types/url-join": "^4.0.1", "@types/url-join": "^4.0.1",
"@types/uuid": "^9.0.0", "@types/uuid": "^9.0.0",
@ -65,6 +66,7 @@
"rdf-serialize": "^2.0.0", "rdf-serialize": "^2.0.0",
"rdf-string": "^1.6.1", "rdf-string": "^1.6.1",
"rdf-terms": "^1.9.0", "rdf-terms": "^1.9.0",
"rdf-validate-shacl": "^0.4.5",
"sparqlalgebrajs": "^4.0.3", "sparqlalgebrajs": "^4.0.3",
"sparqljs": "^3.5.2", "sparqljs": "^3.5.2",
"url-join": "^4.0.1", "url-join": "^4.0.1",
@ -72,8 +74,7 @@
"winston": "^3.8.1", "winston": "^3.8.1",
"winston-transport": "^4.5.0", "winston-transport": "^4.5.0",
"ws": "^8.8.1", "ws": "^8.8.1",
"yargs": "^17.5.1", "yargs": "^17.5.1"
"yup": "^0.32.11"
}, },
"bin": { "bin": {
"community-solid-server": "bin/server.js" "community-solid-server": "bin/server.js"
@ -666,17 +667,6 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.19.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.19.0.tgz",
"integrity": "sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==",
"dependencies": {
"regenerator-runtime": "^0.13.4"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.14.5", "version": "7.14.5",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.14.5.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.14.5.tgz",
@ -3804,6 +3794,52 @@
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==",
"dev": true "dev": true
}, },
"node_modules/@rdfjs/data-model": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@rdfjs/data-model/-/data-model-1.3.4.tgz",
"integrity": "sha512-iKzNcKvJotgbFDdti7GTQDCYmL7GsGldkYStiP0K8EYtN7deJu5t7U11rKTz+nR7RtesUggT+lriZ7BakFv8QQ==",
"dependencies": {
"@rdfjs/types": ">=1.0.1"
},
"bin": {
"rdfjs-data-model-test": "bin/test.js"
}
},
"node_modules/@rdfjs/dataset": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@rdfjs/dataset/-/dataset-1.1.1.tgz",
"integrity": "sha512-BNwCSvG0cz0srsG5esq6CQKJc1m8g/M0DZpLuiEp0MMpfwguXX7VeS8TCg4UUG3DV/DqEvhy83ZKSEjdsYseeA==",
"dependencies": {
"@rdfjs/data-model": "^1.2.0"
},
"bin": {
"rdfjs-dataset-test": "bin/test.js"
}
},
"node_modules/@rdfjs/namespace": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rdfjs/namespace/-/namespace-1.1.0.tgz",
"integrity": "sha512-utO5rtaOKxk8B90qzaQ0N+J5WrCI28DtfAY/zExCmXE7cOfC5uRI/oMKbLaVEPj2P7uArekt/T4IPATtj7Tjug==",
"dependencies": {
"@rdfjs/data-model": "^1.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@rdfjs/term-set": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rdfjs/term-set/-/term-set-1.1.0.tgz",
"integrity": "sha512-QQ4yzVe1Rvae/GN9SnOhweHNpaxQtnAjeOVciP/yJ0Gfxtbphy2tM56ZsRLV04Qq5qMcSclZIe6irYyEzx/UwQ==",
"dependencies": {
"@rdfjs/to-ntriples": "^2.0.0"
}
},
"node_modules/@rdfjs/to-ntriples": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@rdfjs/to-ntriples/-/to-ntriples-2.0.0.tgz",
"integrity": "sha512-nDhpfhx6W6HKsy4HjyLp3H1nbrX1CiUCWhWQwKcYZX1s9GOjcoQTwY7GUUbVec0hzdJDQBR6gnjxtENBDt482Q=="
},
"node_modules/@rdfjs/types": { "node_modules/@rdfjs/types": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rdfjs/types/-/types-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rdfjs/types/-/types-1.1.0.tgz",
@ -3990,6 +4026,14 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/clownface": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@types/clownface/-/clownface-1.5.1.tgz",
"integrity": "sha512-jYRGdXZu5BD6gp+Rfml9eAYovhj0Sf2ovufleMS9PEg8Un9Mc+ZbdbHt6nlutsuSk3QEqluTSzkYr1lno2FnHw==",
"dependencies": {
"rdf-js": "^4.0.2"
}
},
"node_modules/@types/connect": { "node_modules/@types/connect": {
"version": "3.4.34", "version": "3.4.34",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.34.tgz", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.34.tgz",
@ -4312,13 +4356,13 @@
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz",
"integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==" "integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA=="
}, },
"node_modules/@types/rdf-js": { "node_modules/@types/rdf-validate-shacl": {
"version": "4.0.2", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/@types/rdf-js/-/rdf-js-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/rdf-validate-shacl/-/rdf-validate-shacl-0.4.1.tgz",
"integrity": "sha512-soR/+RMogGiDU1lrpuQl5ZL55/L1eq/JlR2dWx052Uh/RYs9okh3XZHFlIJXHZqjqyjEn4WdbOMfBj7vvc2WVQ==", "integrity": "sha512-ol9l4scrPhYgOVNiylIGjdk9H5EzIOMV6ecue10T5IKGNlEE2ySFDEgxPPTVslmiyVO+3vV32GSQvsf+aQ0hKw==",
"deprecated": "This is a stub types definition. rdf-js provides its own type definitions, so you do not need this installed.",
"dependencies": { "dependencies": {
"rdf-js": "*" "@types/clownface": "*",
"rdf-js": "^4.0.2"
} }
}, },
"node_modules/@types/readable-stream": { "node_modules/@types/readable-stream": {
@ -5878,6 +5922,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/clownface": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/clownface/-/clownface-1.5.1.tgz",
"integrity": "sha512-Ko8N/UFsnhEGmPlyE1bUFhbRhVgDbxqlIjcqxtLysc4dWaY0A7iCdg3savhAxs7Lheb7FCygIyRh7ADYZWVIng==",
"dependencies": {
"@rdfjs/data-model": "^1.1.0",
"@rdfjs/namespace": "^1.0.0"
}
},
"node_modules/cluster-key-slot": { "node_modules/cluster-key-slot": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz",
@ -11372,12 +11425,8 @@
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
}, "dev": true
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
}, },
"node_modules/lodash.clonedeep": { "node_modules/lodash.clonedeep": {
"version": "4.5.0", "version": "4.5.0",
@ -11931,11 +11980,6 @@
"node": ">=8.0" "node": ">=8.0"
} }
}, },
"node_modules/nanoclone": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/nanoclone/-/nanoclone-0.2.1.tgz",
"integrity": "sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA=="
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.0.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.0.tgz",
@ -12850,11 +12894,6 @@
"signal-exit": "^3.0.2" "signal-exit": "^3.0.2"
} }
}, },
"node_modules/property-expr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.5.tgz",
"integrity": "sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA=="
},
"node_modules/psl": { "node_modules/psl": {
"version": "1.9.0", "version": "1.9.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
@ -13049,12 +13088,12 @@
} }
}, },
"node_modules/rdf-literal": { "node_modules/rdf-literal": {
"version": "1.2.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/rdf-literal/-/rdf-literal-1.2.0.tgz", "resolved": "https://registry.npmjs.org/rdf-literal/-/rdf-literal-1.3.1.tgz",
"integrity": "sha512-N7nyfp/xzoiUuJt0xZ80BvBGkCPwWejgVDkCxWDSuooXKSows4ToW+KouYkMHLcoFzGg1Rlw2lk6btjMJg5aSA==", "integrity": "sha512-+o/PGOfJchyay9Rjrvi/oveRJACnt2WFO3LhEvtPlsRD1tFmwVUCMU+s33FtQprMo+z1ohFrv/yfEQ6Eym4KgQ==",
"dependencies": { "dependencies": {
"@types/rdf-js": "*", "@rdfjs/types": "*",
"rdf-data-factory": "^1.0.1" "rdf-data-factory": "^1.1.0"
} }
}, },
"node_modules/rdf-object": { "node_modules/rdf-object": {
@ -13163,6 +13202,32 @@
"rdf-string": "^1.6.0" "rdf-string": "^1.6.0"
} }
}, },
"node_modules/rdf-validate-datatype": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/rdf-validate-datatype/-/rdf-validate-datatype-0.1.5.tgz",
"integrity": "sha512-gU+cD+AT1LpFwbemuEmTDjwLyFwJDiw21XHyIofKhFnEpXODjShBuxhgDGnZqW3qIEwu/vECjOecuD60e5ngiQ==",
"dependencies": {
"@rdfjs/namespace": "^1.1.0",
"@rdfjs/to-ntriples": "^2.0.0"
},
"engines": {
"node": ">=10.4"
}
},
"node_modules/rdf-validate-shacl": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/rdf-validate-shacl/-/rdf-validate-shacl-0.4.5.tgz",
"integrity": "sha512-tGYnssuPzmsPua1dju4hEtGkT1zouvwzVTNrFhNiqj2aZFO5pQ7lvLd9Cv9H9vKAlpIdC/x0zL6btxG3PCss0w==",
"dependencies": {
"@rdfjs/dataset": "^1.1.1",
"@rdfjs/namespace": "^1.0.0",
"@rdfjs/term-set": "^1.1.0",
"clownface": "^1.4.0",
"debug": "^4.3.2",
"rdf-literal": "^1.3.0",
"rdf-validate-datatype": "^0.1.5"
}
},
"node_modules/rdfa-streaming-parser": { "node_modules/rdfa-streaming-parser": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/rdfa-streaming-parser/-/rdfa-streaming-parser-1.5.0.tgz", "resolved": "https://registry.npmjs.org/rdfa-streaming-parser/-/rdfa-streaming-parser-1.5.0.tgz",
@ -13335,11 +13400,6 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/regenerator-runtime": {
"version": "0.13.9",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",
"integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA=="
},
"node_modules/regexp-tree": { "node_modules/regexp-tree": {
"version": "0.1.24", "version": "0.1.24",
"resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.24.tgz", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.24.tgz",
@ -14419,11 +14479,6 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/toposort": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg=="
},
"node_modules/touch": { "node_modules/touch": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz",
@ -15232,23 +15287,6 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
},
"node_modules/yup": {
"version": "0.32.11",
"resolved": "https://registry.npmjs.org/yup/-/yup-0.32.11.tgz",
"integrity": "sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg==",
"dependencies": {
"@babel/runtime": "^7.15.4",
"@types/lodash": "^4.14.175",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"nanoclone": "^0.2.1",
"property-expr": "^2.0.4",
"toposort": "^2.0.2"
},
"engines": {
"node": ">=10"
}
} }
}, },
"dependencies": { "dependencies": {
@ -15656,14 +15694,6 @@
"@babel/helper-plugin-utils": "^7.18.6" "@babel/helper-plugin-utils": "^7.18.6"
} }
}, },
"@babel/runtime": {
"version": "7.19.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.19.0.tgz",
"integrity": "sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
},
"@babel/template": { "@babel/template": {
"version": "7.14.5", "version": "7.14.5",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.14.5.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.14.5.tgz",
@ -18583,6 +18613,43 @@
} }
} }
}, },
"@rdfjs/data-model": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@rdfjs/data-model/-/data-model-1.3.4.tgz",
"integrity": "sha512-iKzNcKvJotgbFDdti7GTQDCYmL7GsGldkYStiP0K8EYtN7deJu5t7U11rKTz+nR7RtesUggT+lriZ7BakFv8QQ==",
"requires": {
"@rdfjs/types": ">=1.0.1"
}
},
"@rdfjs/dataset": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@rdfjs/dataset/-/dataset-1.1.1.tgz",
"integrity": "sha512-BNwCSvG0cz0srsG5esq6CQKJc1m8g/M0DZpLuiEp0MMpfwguXX7VeS8TCg4UUG3DV/DqEvhy83ZKSEjdsYseeA==",
"requires": {
"@rdfjs/data-model": "^1.2.0"
}
},
"@rdfjs/namespace": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rdfjs/namespace/-/namespace-1.1.0.tgz",
"integrity": "sha512-utO5rtaOKxk8B90qzaQ0N+J5WrCI28DtfAY/zExCmXE7cOfC5uRI/oMKbLaVEPj2P7uArekt/T4IPATtj7Tjug==",
"requires": {
"@rdfjs/data-model": "^1.1.0"
}
},
"@rdfjs/term-set": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rdfjs/term-set/-/term-set-1.1.0.tgz",
"integrity": "sha512-QQ4yzVe1Rvae/GN9SnOhweHNpaxQtnAjeOVciP/yJ0Gfxtbphy2tM56ZsRLV04Qq5qMcSclZIe6irYyEzx/UwQ==",
"requires": {
"@rdfjs/to-ntriples": "^2.0.0"
}
},
"@rdfjs/to-ntriples": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@rdfjs/to-ntriples/-/to-ntriples-2.0.0.tgz",
"integrity": "sha512-nDhpfhx6W6HKsy4HjyLp3H1nbrX1CiUCWhWQwKcYZX1s9GOjcoQTwY7GUUbVec0hzdJDQBR6gnjxtENBDt482Q=="
},
"@rdfjs/types": { "@rdfjs/types": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rdfjs/types/-/types-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rdfjs/types/-/types-1.1.0.tgz",
@ -18757,6 +18824,14 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"@types/clownface": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@types/clownface/-/clownface-1.5.1.tgz",
"integrity": "sha512-jYRGdXZu5BD6gp+Rfml9eAYovhj0Sf2ovufleMS9PEg8Un9Mc+ZbdbHt6nlutsuSk3QEqluTSzkYr1lno2FnHw==",
"requires": {
"rdf-js": "^4.0.2"
}
},
"@types/connect": { "@types/connect": {
"version": "3.4.34", "version": "3.4.34",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.34.tgz", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.34.tgz",
@ -19079,12 +19154,13 @@
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz",
"integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==" "integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA=="
}, },
"@types/rdf-js": { "@types/rdf-validate-shacl": {
"version": "4.0.2", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/@types/rdf-js/-/rdf-js-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/rdf-validate-shacl/-/rdf-validate-shacl-0.4.1.tgz",
"integrity": "sha512-soR/+RMogGiDU1lrpuQl5ZL55/L1eq/JlR2dWx052Uh/RYs9okh3XZHFlIJXHZqjqyjEn4WdbOMfBj7vvc2WVQ==", "integrity": "sha512-ol9l4scrPhYgOVNiylIGjdk9H5EzIOMV6ecue10T5IKGNlEE2ySFDEgxPPTVslmiyVO+3vV32GSQvsf+aQ0hKw==",
"requires": { "requires": {
"rdf-js": "*" "@types/clownface": "*",
"rdf-js": "^4.0.2"
} }
}, },
"@types/readable-stream": { "@types/readable-stream": {
@ -20188,6 +20264,15 @@
"mimic-response": "^1.0.0" "mimic-response": "^1.0.0"
} }
}, },
"clownface": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/clownface/-/clownface-1.5.1.tgz",
"integrity": "sha512-Ko8N/UFsnhEGmPlyE1bUFhbRhVgDbxqlIjcqxtLysc4dWaY0A7iCdg3savhAxs7Lheb7FCygIyRh7ADYZWVIng==",
"requires": {
"@rdfjs/data-model": "^1.1.0",
"@rdfjs/namespace": "^1.0.0"
}
},
"cluster-key-slot": { "cluster-key-slot": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz",
@ -24367,12 +24452,8 @@
"lodash": { "lodash": {
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
}, "dev": true
"lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
}, },
"lodash.clonedeep": { "lodash.clonedeep": {
"version": "4.5.0", "version": "4.5.0",
@ -24801,11 +24882,6 @@
"readable-stream": "^3.6.0" "readable-stream": "^3.6.0"
} }
}, },
"nanoclone": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/nanoclone/-/nanoclone-0.2.1.tgz",
"integrity": "sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA=="
},
"nanoid": { "nanoid": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.0.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.0.tgz",
@ -25476,11 +25552,6 @@
"signal-exit": "^3.0.2" "signal-exit": "^3.0.2"
} }
}, },
"property-expr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.5.tgz",
"integrity": "sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA=="
},
"psl": { "psl": {
"version": "1.9.0", "version": "1.9.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
@ -25635,12 +25706,12 @@
} }
}, },
"rdf-literal": { "rdf-literal": {
"version": "1.2.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/rdf-literal/-/rdf-literal-1.2.0.tgz", "resolved": "https://registry.npmjs.org/rdf-literal/-/rdf-literal-1.3.1.tgz",
"integrity": "sha512-N7nyfp/xzoiUuJt0xZ80BvBGkCPwWejgVDkCxWDSuooXKSows4ToW+KouYkMHLcoFzGg1Rlw2lk6btjMJg5aSA==", "integrity": "sha512-+o/PGOfJchyay9Rjrvi/oveRJACnt2WFO3LhEvtPlsRD1tFmwVUCMU+s33FtQprMo+z1ohFrv/yfEQ6Eym4KgQ==",
"requires": { "requires": {
"@types/rdf-js": "*", "@rdfjs/types": "*",
"rdf-data-factory": "^1.0.1" "rdf-data-factory": "^1.1.0"
} }
}, },
"rdf-object": { "rdf-object": {
@ -25749,6 +25820,29 @@
"rdf-string": "^1.6.0" "rdf-string": "^1.6.0"
} }
}, },
"rdf-validate-datatype": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/rdf-validate-datatype/-/rdf-validate-datatype-0.1.5.tgz",
"integrity": "sha512-gU+cD+AT1LpFwbemuEmTDjwLyFwJDiw21XHyIofKhFnEpXODjShBuxhgDGnZqW3qIEwu/vECjOecuD60e5ngiQ==",
"requires": {
"@rdfjs/namespace": "^1.1.0",
"@rdfjs/to-ntriples": "^2.0.0"
}
},
"rdf-validate-shacl": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/rdf-validate-shacl/-/rdf-validate-shacl-0.4.5.tgz",
"integrity": "sha512-tGYnssuPzmsPua1dju4hEtGkT1zouvwzVTNrFhNiqj2aZFO5pQ7lvLd9Cv9H9vKAlpIdC/x0zL6btxG3PCss0w==",
"requires": {
"@rdfjs/dataset": "^1.1.1",
"@rdfjs/namespace": "^1.0.0",
"@rdfjs/term-set": "^1.1.0",
"clownface": "^1.4.0",
"debug": "^4.3.2",
"rdf-literal": "^1.3.0",
"rdf-validate-datatype": "^0.1.5"
}
},
"rdfa-streaming-parser": { "rdfa-streaming-parser": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/rdfa-streaming-parser/-/rdfa-streaming-parser-1.5.0.tgz", "resolved": "https://registry.npmjs.org/rdfa-streaming-parser/-/rdfa-streaming-parser-1.5.0.tgz",
@ -25881,11 +25975,6 @@
"redis-errors": "^1.0.0" "redis-errors": "^1.0.0"
} }
}, },
"regenerator-runtime": {
"version": "0.13.9",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",
"integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA=="
},
"regexp-tree": { "regexp-tree": {
"version": "0.1.24", "version": "0.1.24",
"resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.24.tgz", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.24.tgz",
@ -26733,11 +26822,6 @@
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz",
"integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw=="
}, },
"toposort": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg=="
},
"touch": { "touch": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz",
@ -27342,20 +27426,6 @@
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true "dev": true
},
"yup": {
"version": "0.32.11",
"resolved": "https://registry.npmjs.org/yup/-/yup-0.32.11.tgz",
"integrity": "sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg==",
"requires": {
"@babel/runtime": "^7.15.4",
"@types/lodash": "^4.14.175",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"nanoclone": "^0.2.1",
"property-expr": "^2.0.4",
"toposort": "^2.0.2"
}
} }
} }
} }

View File

@ -120,6 +120,7 @@
"@types/proper-lockfile": "^4.1.2", "@types/proper-lockfile": "^4.1.2",
"@types/pump": "^1.1.1", "@types/pump": "^1.1.1",
"@types/punycode": "^2.1.0", "@types/punycode": "^2.1.0",
"@types/rdf-validate-shacl": "^0.4.1",
"@types/sparqljs": "^3.1.3", "@types/sparqljs": "^3.1.3",
"@types/url-join": "^4.0.1", "@types/url-join": "^4.0.1",
"@types/uuid": "^9.0.0", "@types/uuid": "^9.0.0",
@ -155,6 +156,7 @@
"rdf-serialize": "^2.0.0", "rdf-serialize": "^2.0.0",
"rdf-string": "^1.6.1", "rdf-string": "^1.6.1",
"rdf-terms": "^1.9.0", "rdf-terms": "^1.9.0",
"rdf-validate-shacl": "^0.4.5",
"sparqlalgebrajs": "^4.0.3", "sparqlalgebrajs": "^4.0.3",
"sparqljs": "^3.5.2", "sparqljs": "^3.5.2",
"url-join": "^4.0.1", "url-join": "^4.0.1",
@ -162,8 +164,7 @@
"winston": "^3.8.1", "winston": "^3.8.1",
"winston-transport": "^4.5.0", "winston-transport": "^4.5.0",
"ws": "^8.8.1", "ws": "^8.8.1",
"yargs": "^17.5.1", "yargs": "^17.5.1"
"yup": "^0.32.11"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^17.0.3", "@commitlint/cli": "^17.0.3",

View File

@ -338,6 +338,7 @@ export * from './server/notifications/WebSocketSubscription2021/WebSocketSubscri
// Server/Notifications // Server/Notifications
export * from './server/notifications/ActivityEmitter'; export * from './server/notifications/ActivityEmitter';
export * from './server/notifications/BaseChannelType';
export * from './server/notifications/BaseStateHandler'; export * from './server/notifications/BaseStateHandler';
export * from './server/notifications/ComposedNotificationHandler'; export * from './server/notifications/ComposedNotificationHandler';
export * from './server/notifications/KeyValueChannelStorage'; export * from './server/notifications/KeyValueChannelStorage';

View File

@ -0,0 +1,241 @@
import { Readable } from 'stream';
import { KeysRdfParseJsonLd } from '@comunica/context-entries';
import { parse, toSeconds } from 'iso8601-duration';
import type { Store } from 'n3';
import type { NamedNode, Term } from 'rdf-js';
import rdfParser from 'rdf-parse';
import SHACLValidator from 'rdf-validate-shacl';
import { v4 } from 'uuid';
import type { Credentials } from '../../authentication/Credentials';
import type { AccessMap } from '../../authorization/permissions/Permissions';
import { AccessMode } from '../../authorization/permissions/Permissions';
import { ContextDocumentLoader } from '../../storage/conversion/ConversionUtil';
import { UnprocessableEntityHttpError } from '../../util/errors/UnprocessableEntityHttpError';
import { IdentifierSetMultiMap } from '../../util/map/IdentifierMap';
import { readableToQuads } from '../../util/StreamUtil';
import { msToDuration } from '../../util/StringUtil';
import { NOTIFY, RDF, XSD } from '../../util/Vocabularies';
import { CONTEXT_NOTIFICATION } from './Notification';
import type { NotificationChannel } from './NotificationChannel';
import type { NotificationChannelType } from './NotificationChannelType';
import { DEFAULT_NOTIFICATION_FEATURES } from './NotificationDescriber';
/**
* Helper type used to store information about the default features.
*/
type Feature = {
predicate: NamedNode;
key: keyof NotificationChannel;
dataType: string;
};
/**
* All the necessary fields of the default features that are possible for all Notification Channels.
*/
const featureDefinitions: Feature[] = [
{ predicate: NOTIFY.terms.accept, key: 'accept', dataType: XSD.string },
{ predicate: NOTIFY.terms.endAt, key: 'endAt', dataType: XSD.dateTime },
{ predicate: NOTIFY.terms.rate, key: 'rate', dataType: XSD.duration },
{ predicate: NOTIFY.terms.startAt, key: 'startAt', dataType: XSD.dateTime },
{ predicate: NOTIFY.terms.state, key: 'state', dataType: XSD.string },
];
// This context is slightly outdated but seems to be the only "official" source for a SHACL context.
const CONTEXT_SHACL = 'https://w3c.github.io/shacl/shacl-jsonld-context/shacl.context.ld.json';
/**
* The SHACL shape for the minimum requirements on a notification channel subscription request.
*/
export const DEFAULT_SUBSCRIPTION_SHACL = {
'@context': [ CONTEXT_SHACL ],
'@type': 'sh:NodeShape',
// Use the topic predicate to find the focus node
targetSubjectsOf: NOTIFY.topic,
closed: true,
property: [
{ path: RDF.type, minCount: 1, maxCount: 1, nodeKind: 'sh:IRI' },
{ path: NOTIFY.topic, minCount: 1, maxCount: 1, nodeKind: 'sh:IRI' },
...featureDefinitions.map((feat): unknown =>
({ path: feat.predicate.value, maxCount: 1, datatype: feat.dataType })),
],
} as const;
/**
* A {@link NotificationChannelType} that handles the base case of parsing and serializing a notification channel.
* Note that the `extractModes` call always requires Read permissions on the target resource.
*
* Uses SHACL to validate the incoming data in `initChannel`.
* Classes extending this can pass extra SHACL properties in the constructor to extend the validation check.
*
* The `completeChannel` implementation is an empty function.
*/
export abstract class BaseChannelType implements NotificationChannelType {
protected readonly type: NamedNode;
protected readonly shacl: unknown;
protected shaclQuads?: Store;
/**
* @param type - The URI of the notification channel type.
* This will be added to the SHACL shape to validate incoming subscription data.
* @param additionalShaclProperties - Any additional properties that need to be added to the default SHACL shape.
*/
protected constructor(type: NamedNode, additionalShaclProperties: unknown[] = []) {
this.type = type;
// Inject requested properties into default SHACL shape
this.shacl = {
...DEFAULT_SUBSCRIPTION_SHACL,
property: [
...DEFAULT_SUBSCRIPTION_SHACL.property,
// Add type check
{ path: RDF.type, hasValue: { '@id': type.value }},
...additionalShaclProperties,
],
};
}
/**
* Initiates the channel by first calling {@link validateSubscription} followed by {@link quadsToChannel}.
* Subclasses can override either function safely to impact the result of the function.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async initChannel(data: Store, credentials: Credentials): Promise<NotificationChannel> {
const subject = await this.validateSubscription(data);
return this.quadsToChannel(data, subject);
}
/**
* Returns an N3.js {@link Store} containing quads corresponding to the stored SHACL representation.
* Caches this result so the conversion from JSON-LD to quads only has to happen once.
*/
protected async getShaclQuads(): Promise<Store> {
if (!this.shaclQuads) {
const shaclStream = rdfParser.parse(
Readable.from(JSON.stringify(this.shacl)),
{
contentType: 'application/ld+json',
// Make sure our internal version of the context gets used
[KeysRdfParseJsonLd.documentLoader.name]: new ContextDocumentLoader({
[CONTEXT_SHACL]: '@css:templates/contexts/shacl.jsonld',
}),
},
);
this.shaclQuads = await readableToQuads(shaclStream);
}
return this.shaclQuads;
}
/**
* Validates whether the given data conforms to the stored SHACL shape.
* Will throw an {@link UnprocessableEntityHttpError} if validation fails.
* Along with the SHACL check, this also makes sure there is only one matching entry in the dataset.
*
* @param data - The data to validate.
*
* @returns The focus node that corresponds to the subject of the found notification channel description.
*/
protected async validateSubscription(data: Store): Promise<Term> {
// Need to make sure there is exactly one matching entry, which can't be done with SHACL.
// The predicate used here must be the same as is used for `targetSubjectsOf` in the SHACL shape.
const focusNodes = data.getSubjects(NOTIFY.terms.topic, null, null);
if (focusNodes.length === 0) {
throw new UnprocessableEntityHttpError('Missing topic value.');
}
if (focusNodes.length > 1) {
throw new UnprocessableEntityHttpError('Only one subscription can be done at the same time.');
}
const validator = new SHACLValidator(await this.getShaclQuads());
const report = validator.validate(data);
if (!report.conforms) {
// Use the first error to generate error message
const result = report.results[0];
const message = result.message[0];
throw new UnprocessableEntityHttpError(`${message.value} - ${result.path?.value}`);
}
// From this point on, we can assume the subject corresponds to a valid subscription request
return focusNodes[0] as NamedNode;
}
/**
* Converts a set of quads to a {@link NotificationChannel}.
* Assumes the data is valid, so this should be called after {@link validateSubscription}
*
* The values of the default features will be added to the resulting channel,
* subclasses with additional features that need to be added are responsible for parsing those quads.
*
* @param data - Data to convert.
* @param subject - The identifier of the notification channel description in the dataset.
*
* @returns The generated {@link NotificationChannel}.
*/
protected async quadsToChannel(data: Store, subject: Term): Promise<NotificationChannel> {
const topic = data.getObjects(subject, NOTIFY.terms.topic, null)[0] as NamedNode;
const type = data.getObjects(subject, RDF.terms.type, null)[0] as NamedNode;
const channel: NotificationChannel = {
id: `${v4()}:${topic.value}`,
type: type.value,
topic: topic.value,
};
// Apply the values for all present features that are enabled
for (const feature of DEFAULT_NOTIFICATION_FEATURES) {
const objects = data.getObjects(subject, feature, null);
if (objects.length === 1) {
// Will always succeed since we are iterating over a list which was built using `featureDefinitions`
const { dataType, key } = featureDefinitions.find((feat): boolean => feat.predicate.value === feature)!;
let val: string | number = objects[0].value;
if (dataType === XSD.dateTime) {
val = Date.parse(val);
} else if (dataType === XSD.duration) {
val = toSeconds(parse(val)) * 1000;
}
// Need to convince TS that we can assign `string | number` to this key
(channel as Record<typeof key, string | number>)[key] = val;
}
}
return channel;
}
/**
* Converts the given channel to a JSON-LD description.
* All fields found in the channel, except `lastEmit`, will be part of the result subject,
* so subclasses should remove any fields that should not be exposed.
*/
public async toJsonLd(channel: NotificationChannel): Promise<Record<string, unknown>> {
const result: Record<string, unknown> = {
'@context': [
CONTEXT_NOTIFICATION,
],
...channel,
};
// No need to expose this field
delete result.lastEmit;
// Convert all the epoch values back to the expected date/rate format
for (const { key, dataType } of featureDefinitions) {
const value = channel[key];
if (value) {
if (dataType === XSD.dateTime) {
result[key] = new Date(value).toISOString();
} else if (dataType === XSD.duration) {
result[key] = msToDuration(value as number);
}
}
}
return result;
}
public async extractModes(channel: NotificationChannel): Promise<AccessMap> {
return new IdentifierSetMultiMap<AccessMode>([[{ path: channel.topic }, AccessMode.read ]]);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async completeChannel(channel: NotificationChannel): Promise<void> {
// Do nothing
}
}

View File

@ -1,10 +1,9 @@
import { v4 } from 'uuid';
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
import { getLoggerFor } from '../../logging/LogUtil'; import { getLoggerFor } from '../../logging/LogUtil';
import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage'; import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage';
import { InternalServerError } from '../../util/errors/InternalServerError'; import { InternalServerError } from '../../util/errors/InternalServerError';
import type { ReadWriteLocker } from '../../util/locking/ReadWriteLocker'; import type { ReadWriteLocker } from '../../util/locking/ReadWriteLocker';
import type { NotificationChannel, NotificationChannelJson } from './NotificationChannel'; import type { NotificationChannel } from './NotificationChannel';
import type { NotificationChannelStorage } from './NotificationChannelStorage'; import type { NotificationChannelStorage } from './NotificationChannelStorage';
type StorageValue = string | string[] | NotificationChannel; type StorageValue = string | string[] | NotificationChannel;
@ -25,20 +24,6 @@ export class KeyValueChannelStorage implements NotificationChannelStorage {
this.locker = locker; this.locker = locker;
} }
public create(channel: NotificationChannelJson, features: Record<string, unknown>): NotificationChannel {
return {
id: `${channel.type}:${v4()}:${channel.topic}`,
topic: channel.topic,
type: channel.type,
startAt: channel.startAt,
endAt: channel.endAt,
accept: channel.accept,
rate: channel.rate,
state: channel.state,
...features,
};
}
public async get(id: string): Promise<NotificationChannel | undefined> { public async get(id: string): Promise<NotificationChannel | undefined> {
const channel = await this.storage.get(id); const channel = await this.storage.get(id);
if (channel && this.isChannel(channel)) { if (channel && this.isChannel(channel)) {

View File

@ -1,46 +1,59 @@
import { parse, toSeconds } from 'iso8601-duration';
import type { InferType } from 'yup';
import { array, number, object, string } from 'yup';
import { CONTEXT_NOTIFICATION } from './Notification';
/** /**
* A JSON parsing schema that can be used to parse a notification channel sent during subscription. * Internal representation of a notification channel.
* Specific notification channels can extend this schema with their own custom keys. * Most of the fields are those defined in
* https://solid.github.io/notifications/protocol#notification-channel-data-model
*
* We only support notification channels with a single topic.
*/ */
export const NOTIFICATION_CHANNEL_SCHEMA = object({ export interface NotificationChannel {
'@context': array(string()).ensure().required().test({
name: 'RequireNotificationContext',
message: `The ${CONTEXT_NOTIFICATION} context is required in the notification channel JSON-LD body.`,
test: (context): boolean => Boolean(context?.includes(CONTEXT_NOTIFICATION)),
}),
type: string().required(),
topic: string().required(),
state: string().optional(),
startAt: number().transform((value, original): number | undefined =>
// Convert the date string to milliseconds
Date.parse(original)).optional(),
endAt: number().transform((value, original): number | undefined =>
// Convert the date string to milliseconds
Date.parse(original)).optional(),
rate: number().transform((value, original): number | undefined =>
// Convert the rate string to milliseconds
toSeconds(parse(original)) * 1000).optional(),
accept: string().optional(),
});
export type NotificationChannelJson = InferType<typeof NOTIFICATION_CHANNEL_SCHEMA>;
/** /**
* The info provided for a notification channel during a subscription. * The unique identifier of the channel.
* `features` can contain custom values relevant for a specific channel type.
*/ */
export type NotificationChannel = {
id: string; id: string;
topic: string; /**
* The channel type.
*/
type: string; type: string;
startAt?: number; /**
endAt?: number; * The resource this channel sends notifications about.
accept?: string; */
rate?: number; topic: string;
/**
* The state parameter sent by the receiver.
* This is used to send a notification when the channel is established and the topic resource has a different state.
*/
state?: string; state?: string;
/**
* When the channel should start sending notifications, in milliseconds since epoch.
*/
startAt?: number;
/**
* When the channel should stop existing, in milliseconds since epoch.
*/
endAt?: number;
/**
* The minimal time required between notifications, in milliseconds.
*/
rate?: number;
/**
* The media type in which the receiver expects the notifications.
*/
accept?: string;
/**
* The resource receivers can use to establish a connection and receive notifications.
*/
receiveFrom?: string;
/**
* The resource on the receiver where notifications can be sent.
*/
sendTo?: string;
/**
* Can be used to identify the sender.
*/
sender?: string;
/**
* Internal value that we use to track when this channel last sent a notification.
*/
lastEmit?: number; lastEmit?: number;
}; }

View File

@ -1,5 +1,5 @@
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
import type { NotificationChannel, NotificationChannelJson } from './NotificationChannel'; import type { NotificationChannel } from './NotificationChannel';
/** /**
* Stores all the information necessary to keep track of notification channels. * Stores all the information necessary to keep track of notification channels.
@ -9,15 +9,7 @@ import type { NotificationChannel, NotificationChannelJson } from './Notificatio
*/ */
export interface NotificationChannelStorage { export interface NotificationChannelStorage {
/** /**
* Creates channel corresponding to the given channel and features. * Returns the requested channel.
* This does not store the generated channel in the storage.
* @param channel - Notification channel to generate channel of.
* @param features - Features to add to the channel
*/
create: (channel: NotificationChannelJson, features: Record<string, unknown>) => NotificationChannel;
/**
* Returns the channel for the requested notification channel.
* `undefined` if no match was found or if the notification channel expired. * `undefined` if no match was found or if the notification channel expired.
* @param id - The identifier of the notification channel. * @param id - The identifier of the notification channel.
*/ */

View File

@ -1,42 +1,41 @@
import type { InferType } from 'yup'; import type { Store } from 'n3';
import type { Credentials } from '../../authentication/Credentials'; import type { Credentials } from '../../authentication/Credentials';
import type { AccessMap } from '../../authorization/permissions/Permissions'; import type { AccessMap } from '../../authorization/permissions/Permissions';
import type { Representation } from '../../http/representation/Representation'; import type { NotificationChannel } from './NotificationChannel';
import type { NOTIFICATION_CHANNEL_SCHEMA, NotificationChannel } from './NotificationChannel';
export interface NotificationChannelResponse {
response: Representation;
channel: NotificationChannel;
}
/** /**
* A specific channel type as defined at * A specific channel type as defined at
* https://solidproject.org/TR/2022/notifications-protocol-20221231#notification-channel-types. * https://solidproject.org/TR/2022/notifications-protocol-20221231#notification-channel-types.
*
* All functions that take a {@link NotificationChannel} as input
* only need to support channels generated by an `initChannel` on the same class.
*/ */
export interface NotificationChannelType< export interface NotificationChannelType {
TSub extends typeof NOTIFICATION_CHANNEL_SCHEMA = typeof NOTIFICATION_CHANNEL_SCHEMA> {
/** /**
* The expected type value in the JSON-LD body of requests subscribing for this notification channel type. * Validate and convert the input quads into a {@link NotificationChannel}.
* @param data - The input quads.
* @param credentials - The credentials of the agent doing the request.
*/ */
readonly type: string; initChannel: (data: Store, credentials: Credentials) => Promise<NotificationChannel>;
/** /**
* An extension of {@link NOTIFICATION_CHANNEL_SCHEMA} * Converts a {@link NotificationChannel} to a serialized JSON-LD representation.
* that can be used to parse and validate an incoming subscription request with a notification channel body. * @param channel - The notification channel to serialize.
*/ */
readonly schema: TSub; toJsonLd: (channel: NotificationChannel) => Promise<Record<string, unknown>>;
/** /**
* Determines which modes are required to allow the given notification channel. * Determines which modes are required to allow the given notification channel.
* @param channel - The notification channel to verify. * @param channel - The notification channel to verify.
* *
* @returns The required modes. * @returns The required modes.
*/ */
extractModes: (json: InferType<TSub>) => Promise<AccessMap>; extractModes: (channel: NotificationChannel) => Promise<AccessMap>;
/** /**
* Registers the given notification channel. * This function will be called after the serialized channel is sent back as a response,
* @param channel - The notification channel to register. * allowing for any final actions that need to happen.
* @param credentials - The credentials of the client trying to subscribe. * @param channel - The notification channel that is completed.
*
* @returns A {@link Representation} to return as a response and the generated {@link NotificationChannel}.
*/ */
subscribe: (json: InferType<TSub>, credentials: Credentials) => Promise<NotificationChannelResponse>; completeChannel: (channel: NotificationChannel) => Promise<void>;
} }

View File

@ -4,16 +4,17 @@ import type { Authorizer } from '../../authorization/Authorizer';
import type { PermissionReader } from '../../authorization/PermissionReader'; import type { PermissionReader } from '../../authorization/PermissionReader';
import { OkResponseDescription } from '../../http/output/response/OkResponseDescription'; import { OkResponseDescription } from '../../http/output/response/OkResponseDescription';
import type { ResponseDescription } from '../../http/output/response/ResponseDescription'; import type { ResponseDescription } from '../../http/output/response/ResponseDescription';
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
import { getLoggerFor } from '../../logging/LogUtil'; import { getLoggerFor } from '../../logging/LogUtil';
import { APPLICATION_LD_JSON } from '../../util/ContentTypes'; import type { RepresentationConverter } from '../../storage/conversion/RepresentationConverter';
import { APPLICATION_LD_JSON, INTERNAL_QUADS } from '../../util/ContentTypes';
import { createErrorMessage } from '../../util/errors/ErrorUtil'; import { createErrorMessage } from '../../util/errors/ErrorUtil';
import { UnprocessableEntityHttpError } from '../../util/errors/UnprocessableEntityHttpError'; import { UnprocessableEntityHttpError } from '../../util/errors/UnprocessableEntityHttpError';
import { UnsupportedMediaTypeHttpError } from '../../util/errors/UnsupportedMediaTypeHttpError'; import { endOfStream, readableToQuads } from '../../util/StreamUtil';
import { readableToString } from '../../util/StreamUtil';
import type { HttpRequest } from '../HttpRequest';
import type { OperationHttpHandlerInput } from '../OperationHttpHandler'; import type { OperationHttpHandlerInput } from '../OperationHttpHandler';
import { OperationHttpHandler } from '../OperationHttpHandler'; import { OperationHttpHandler } from '../OperationHttpHandler';
import type { NotificationChannelJson } from './NotificationChannel'; import type { NotificationChannel } from './NotificationChannel';
import type { NotificationChannelStorage } from './NotificationChannelStorage';
import type { NotificationChannelType } from './NotificationChannelType'; import type { NotificationChannelType } from './NotificationChannelType';
export interface NotificationSubscriberArgs { export interface NotificationSubscriberArgs {
@ -21,6 +22,10 @@ export interface NotificationSubscriberArgs {
* The {@link NotificationChannelType} with all the necessary information. * The {@link NotificationChannelType} with all the necessary information.
*/ */
channelType: NotificationChannelType; channelType: NotificationChannelType;
/**
* {@link RepresentationConverter} used to convert input data into RDF.
*/
converter: RepresentationConverter;
/** /**
* Used to extract the credentials from the request. * Used to extract the credentials from the request.
*/ */
@ -33,6 +38,10 @@ export interface NotificationSubscriberArgs {
* Used to determine if the request has the necessary permissions. * Used to determine if the request has the necessary permissions.
*/ */
authorizer: Authorizer; authorizer: Authorizer;
/**
* Storage used to store the channels.
*/
storage: NotificationChannelStorage;
/** /**
* Overrides the expiration feature of channels, by making sure they always expire after the `maxDuration` value. * Overrides the expiration feature of channels, by making sure they always expire after the `maxDuration` value.
* If the expiration of the channel is shorter than `maxDuration`, the original value will be kept. * If the expiration of the channel is shorter than `maxDuration`, the original value will be kept.
@ -46,34 +55,42 @@ export interface NotificationSubscriberArgs {
* *
* Uses the information from the provided {@link NotificationChannelType} to validate the input * Uses the information from the provided {@link NotificationChannelType} to validate the input
* and verify the request has the required permissions available. * and verify the request has the required permissions available.
* If successful the generated channel will be stored in a {@link NotificationChannelStorage}.
*/ */
export class NotificationSubscriber extends OperationHttpHandler { export class NotificationSubscriber extends OperationHttpHandler {
protected logger = getLoggerFor(this); protected logger = getLoggerFor(this);
private readonly channelType: NotificationChannelType; private readonly channelType: NotificationChannelType;
private readonly converter: RepresentationConverter;
private readonly credentialsExtractor: CredentialsExtractor; private readonly credentialsExtractor: CredentialsExtractor;
private readonly permissionReader: PermissionReader; private readonly permissionReader: PermissionReader;
private readonly authorizer: Authorizer; private readonly authorizer: Authorizer;
private readonly storage: NotificationChannelStorage;
private readonly maxDuration: number; private readonly maxDuration: number;
public constructor(args: NotificationSubscriberArgs) { public constructor(args: NotificationSubscriberArgs) {
super(); super();
this.channelType = args.channelType; this.channelType = args.channelType;
this.converter = args.converter;
this.credentialsExtractor = args.credentialsExtractor; this.credentialsExtractor = args.credentialsExtractor;
this.permissionReader = args.permissionReader; this.permissionReader = args.permissionReader;
this.authorizer = args.authorizer; this.authorizer = args.authorizer;
this.storage = args.storage;
this.maxDuration = (args.maxDuration ?? 0) * 60 * 1000; this.maxDuration = (args.maxDuration ?? 0) * 60 * 1000;
} }
public async handle({ operation, request }: OperationHttpHandlerInput): Promise<ResponseDescription> { public async handle({ operation, request }: OperationHttpHandlerInput): Promise<ResponseDescription> {
if (operation.body.metadata.contentType !== APPLICATION_LD_JSON) { const credentials = await this.credentialsExtractor.handleSafe(request);
throw new UnsupportedMediaTypeHttpError('Subscribe bodies need to be application/ld+json.'); this.logger.debug(`Extracted credentials: ${JSON.stringify(credentials)}`);
}
let channel: NotificationChannelJson; let channel: NotificationChannel;
try { try {
const json = JSON.parse(await readableToString(operation.body.data)); const quadStream = await this.converter.handleSafe({
channel = await this.channelType.schema.validate(json); identifier: operation.target,
representation: operation.body,
preferences: { type: { [INTERNAL_QUADS]: 1 }},
});
channel = await this.channelType.initChannel(await readableToQuads(quadStream.data), credentials);
} catch (error: unknown) { } catch (error: unknown) {
throw new UnprocessableEntityHttpError(`Unable to process notification channel: ${createErrorMessage(error)}`); throw new UnprocessableEntityHttpError(`Unable to process notification channel: ${createErrorMessage(error)}`);
} }
@ -86,17 +103,27 @@ export class NotificationSubscriber extends OperationHttpHandler {
} }
// Verify if the client is allowed to subscribe // Verify if the client is allowed to subscribe
const credentials = await this.authorize(request, channel); await this.authorize(credentials, channel);
const { response } = await this.channelType.subscribe(channel, credentials); // Store the channel once it has been authorized
await this.storage.add(channel);
// Generate the response JSON-LD
const jsonld = await this.channelType.toJsonLd(channel);
const response = new BasicRepresentation(JSON.stringify(jsonld), APPLICATION_LD_JSON);
// Complete the channel once the response has been sent out
endOfStream(response.data)
.then((): Promise<void> => this.channelType.completeChannel(channel))
.catch((error): void => {
this.logger.error(`There was an issue completing notification channel ${channel.id}: ${
createErrorMessage(error)}`);
});
return new OkResponseDescription(response.metadata, response.data); return new OkResponseDescription(response.metadata, response.data);
} }
private async authorize(request: HttpRequest, channel: NotificationChannelJson): Promise<Credentials> { private async authorize(credentials: Credentials, channel: NotificationChannel): Promise<void> {
const credentials = await this.credentialsExtractor.handleSafe(request);
this.logger.debug(`Extracted credentials: ${JSON.stringify(credentials)}`);
const requestedModes = await this.channelType.extractModes(channel); const requestedModes = await this.channelType.extractModes(channel);
this.logger.debug(`Retrieved required modes: ${[ ...requestedModes.entrySets() ]}`); this.logger.debug(`Retrieved required modes: ${[ ...requestedModes.entrySets() ]}`);
@ -104,8 +131,6 @@ export class NotificationSubscriber extends OperationHttpHandler {
this.logger.debug(`Available permissions are ${[ ...availablePermissions.entries() ]}`); this.logger.debug(`Available permissions are ${[ ...availablePermissions.entries() ]}`);
await this.authorizer.handleSafe({ credentials, requestedModes, availablePermissions }); await this.authorizer.handleSafe({ credentials, requestedModes, availablePermissions });
this.logger.verbose(`Authorization succeeded, creating notification channel`); this.logger.debug(`Authorization succeeded, creating notification channel`);
return credentials;
} }
} }

View File

@ -1,31 +1,15 @@
import type { InferType } from 'yup'; import type { Store } from 'n3';
import { string } from 'yup';
import type { Credentials } from '../../../authentication/Credentials'; import type { Credentials } from '../../../authentication/Credentials';
import type { AccessMap } from '../../../authorization/permissions/Permissions';
import { AccessMode } from '../../../authorization/permissions/Permissions';
import { BasicRepresentation } from '../../../http/representation/BasicRepresentation';
import type { InteractionRoute } from '../../../identity/interaction/routing/InteractionRoute'; import type { InteractionRoute } from '../../../identity/interaction/routing/InteractionRoute';
import { getLoggerFor } from '../../../logging/LogUtil'; import { getLoggerFor } from '../../../logging/LogUtil';
import { APPLICATION_LD_JSON } from '../../../util/ContentTypes';
import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError'; import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError';
import { createErrorMessage } from '../../../util/errors/ErrorUtil'; import { createErrorMessage } from '../../../util/errors/ErrorUtil';
import { IdentifierSetMultiMap } from '../../../util/map/IdentifierMap'; import { NOTIFY } from '../../../util/Vocabularies';
import { endOfStream } from '../../../util/StreamUtil'; import { BaseChannelType } from '../BaseChannelType';
import { CONTEXT_NOTIFICATION } from '../Notification';
import type { NotificationChannel } from '../NotificationChannel'; import type { NotificationChannel } from '../NotificationChannel';
import { NOTIFICATION_CHANNEL_SCHEMA } from '../NotificationChannel';
import type { NotificationChannelStorage } from '../NotificationChannelStorage';
import type { NotificationChannelResponse, NotificationChannelType } from '../NotificationChannelType';
import type { StateHandler } from '../StateHandler'; import type { StateHandler } from '../StateHandler';
import { generateWebHookUnsubscribeUrl } from './WebHook2021Util'; import { generateWebHookUnsubscribeUrl } from './WebHook2021Util';
const type = 'WebHookSubscription2021';
const schema = NOTIFICATION_CHANNEL_SCHEMA.shape({
type: string().required().oneOf([ type ]),
// Not using `.url()` validator since it does not support localhost URLs
target: string().required(),
});
/** /**
* A {@link NotificationChannel} containing the necessary fields for a WebHookSubscription2021 channel. * A {@link NotificationChannel} containing the necessary fields for a WebHookSubscription2021 channel.
*/ */
@ -33,7 +17,7 @@ export interface WebHookSubscription2021Channel extends NotificationChannel {
/** /**
* The "WebHookSubscription2021" type. * The "WebHookSubscription2021" type.
*/ */
type: typeof type; type: typeof NOTIFY.WebHookSubscription2021;
/** /**
* Where the notifications have to be sent. * Where the notifications have to be sent.
*/ */
@ -50,7 +34,7 @@ export interface WebHookSubscription2021Channel extends NotificationChannel {
} }
export function isWebHook2021Channel(channel: NotificationChannel): channel is WebHookSubscription2021Channel { export function isWebHook2021Channel(channel: NotificationChannel): channel is WebHookSubscription2021Channel {
return channel.type === type; return channel.type === NOTIFY.WebHookSubscription2021;
} }
/** /**
@ -61,29 +45,27 @@ export function isWebHook2021Channel(channel: NotificationChannel): channel is W
* *
* Also handles the `state` feature if present. * Also handles the `state` feature if present.
*/ */
export class WebHookSubscription2021 implements NotificationChannelType<typeof schema> { export class WebHookSubscription2021 extends BaseChannelType {
protected readonly logger = getLoggerFor(this); protected readonly logger = getLoggerFor(this);
private readonly storage: NotificationChannelStorage;
private readonly unsubscribePath: string; private readonly unsubscribePath: string;
private readonly stateHandler: StateHandler; private readonly stateHandler: StateHandler;
public readonly type = type; public constructor(unsubscribeRoute: InteractionRoute, stateHandler: StateHandler) {
public readonly schema = schema; super(NOTIFY.terms.WebHookSubscription2021,
// Need to remember to remove `target` from the vocabulary again once this is updated to webhooks 2023,
public constructor(storage: NotificationChannelStorage, unsubscribeRoute: InteractionRoute, // as it is not actually part of the vocabulary.
stateHandler: StateHandler) { // Technically we should also require that this node is a named node,
this.storage = storage; // 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.unsubscribePath = unsubscribeRoute.getPath(); this.unsubscribePath = unsubscribeRoute.getPath();
this.stateHandler = stateHandler; this.stateHandler = stateHandler;
} }
public async extractModes(json: InferType<typeof schema>): Promise<AccessMap> { public async initChannel(data: Store, credentials: Credentials): Promise<WebHookSubscription2021Channel> {
return new IdentifierSetMultiMap<AccessMode>([[{ path: json.topic }, AccessMode.read ]]); // The WebID is used to verify who can unsubscribe
}
public async subscribe(json: InferType<typeof schema>, credentials: Credentials):
Promise<NotificationChannelResponse> {
const webId = credentials.agent?.webId; const webId = credentials.agent?.webId;
if (!webId) { if (!webId) {
@ -92,27 +74,36 @@ export class WebHookSubscription2021 implements NotificationChannelType<typeof s
); );
} }
const channel = this.storage.create(json, { target: json.target, webId }); const subject = await this.validateSubscription(data);
await this.storage.add(channel); const channel = await this.quadsToChannel(data, subject);
const target = data.getObjects(subject, NOTIFY.terms.target, null)[0];
const jsonld = { return {
'@context': [ CONTEXT_NOTIFICATION ], ...channel,
type: this.type, type: NOTIFY.WebHookSubscription2021,
target: json.target, webId,
target: target.value,
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
unsubscribe_endpoint: generateWebHookUnsubscribeUrl(this.unsubscribePath, channel.id), unsubscribe_endpoint: generateWebHookUnsubscribeUrl(this.unsubscribePath, channel.id),
}; };
const response = new BasicRepresentation(JSON.stringify(jsonld), APPLICATION_LD_JSON); }
// We want to send the state notification, if there is one, public async toJsonLd(channel: NotificationChannel): Promise<Record<string, unknown>> {
// right after we send the response for subscribing. const json = await super.toJsonLd(channel);
// We do this by waiting for the response to be closed.
endOfStream(response.data) // We don't want to expose the WebID that initialized the notification channel.
.then((): Promise<void> => this.stateHandler.handleSafe({ channel })) // This is not really specified either way in the spec so this might change in the future.
.catch((error): void => { 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)}`); this.logger.error(`Error emitting state notification: ${createErrorMessage(error)}`);
}); }
return { response, channel };
} }
} }

View File

@ -1,22 +1,29 @@
import { string } from 'yup'; import type { Store } from 'n3';
import type { AccessMap } from '../../../authorization/permissions/Permissions'; import type { Credentials } from '../../../authentication/Credentials';
import { AccessMode } from '../../../authorization/permissions/Permissions';
import { BasicRepresentation } from '../../../http/representation/BasicRepresentation';
import type { InteractionRoute } from '../../../identity/interaction/routing/InteractionRoute'; import type { InteractionRoute } from '../../../identity/interaction/routing/InteractionRoute';
import { getLoggerFor } from '../../../logging/LogUtil'; import { getLoggerFor } from '../../../logging/LogUtil';
import { APPLICATION_LD_JSON } from '../../../util/ContentTypes'; import { NOTIFY } from '../../../util/Vocabularies';
import { IdentifierSetMultiMap } from '../../../util/map/IdentifierMap'; import { BaseChannelType } from '../BaseChannelType';
import { CONTEXT_NOTIFICATION } from '../Notification'; import type { NotificationChannel } from '../NotificationChannel';
import type { NotificationChannelJson } from '../NotificationChannel';
import { NOTIFICATION_CHANNEL_SCHEMA } from '../NotificationChannel';
import type { NotificationChannelStorage } from '../NotificationChannelStorage';
import type { NotificationChannelResponse, NotificationChannelType } from '../NotificationChannelType';
import { generateWebSocketUrl } from './WebSocket2021Util'; import { generateWebSocketUrl } from './WebSocket2021Util';
const type = 'WebSocketSubscription2021'; /**
const schema = NOTIFICATION_CHANNEL_SCHEMA.shape({ * A {@link NotificationChannel} containing the necessary fields for a WebSocketSubscription2021 channel.
type: string().required().oneOf([ type ]), */
}); export interface WebSocketSubscription2021Channel extends NotificationChannel {
/**
* The "notify:WebSocketSubscription2021" type.
*/
type: typeof NOTIFY.WebSocketSubscription2021;
/**
* The WebSocket through which the channel will send notifications.
*/
source: string;
}
export function isWebSocket2021Channel(channel: NotificationChannel): channel is WebSocketSubscription2021Channel {
return channel.type === NOTIFY.WebSocketSubscription2021;
}
/** /**
* The notification channel type WebSocketSubscription2021 as described in * The notification channel type WebSocketSubscription2021 as described in
@ -24,35 +31,22 @@ const schema = NOTIFICATION_CHANNEL_SCHEMA.shape({
* *
* Requires read permissions on a resource to be able to receive notifications. * Requires read permissions on a resource to be able to receive notifications.
*/ */
export class WebSocketSubscription2021 implements NotificationChannelType<typeof schema> { export class WebSocketSubscription2021 extends BaseChannelType {
protected readonly logger = getLoggerFor(this); protected readonly logger = getLoggerFor(this);
private readonly storage: NotificationChannelStorage;
private readonly path: string; private readonly path: string;
public readonly type = type; public constructor(route: InteractionRoute) {
public readonly schema = schema; super(NOTIFY.terms.WebSocketSubscription2021);
public constructor(storage: NotificationChannelStorage, route: InteractionRoute) {
this.storage = storage;
this.path = route.getPath(); this.path = route.getPath();
} }
public async extractModes(json: NotificationChannelJson): Promise<AccessMap> { public async initChannel(data: Store, credentials: Credentials): Promise<WebSocketSubscription2021Channel> {
return new IdentifierSetMultiMap<AccessMode>([[{ path: json.topic }, AccessMode.read ]]); const channel = await super.initChannel(data, credentials);
} return {
...channel,
public async subscribe(json: NotificationChannelJson): Promise<NotificationChannelResponse> { type: NOTIFY.WebSocketSubscription2021,
const channel = this.storage.create(json, {});
await this.storage.add(channel);
const jsonld = {
'@context': [ CONTEXT_NOTIFICATION ],
type: this.type,
source: generateWebSocketUrl(this.path, channel.id), source: generateWebSocketUrl(this.path, channel.id),
}; };
const response = new BasicRepresentation(JSON.stringify(jsonld), APPLICATION_LD_JSON);
return { response, channel };
} }
} }

View File

@ -28,3 +28,38 @@ export function sanitizeUrlPart(urlPart: string): string {
export function isValidFileName(name: string): boolean { export function isValidFileName(name: string): boolean {
return /^[\w.-]+$/u.test(name); return /^[\w.-]+$/u.test(name);
} }
/**
* Converts milliseconds to an ISO 8601 duration string.
* The only categories used are days, hours, minutes, and seconds,
* because months have no fixed size in milliseconds.
* @param ms - The duration in ms to convert.
*/
export function msToDuration(ms: number): string {
let totalSeconds = ms / 1000;
const days = Math.floor(totalSeconds / (60 * 60 * 24));
totalSeconds -= days * 60 * 60 * 24;
const hours = Math.floor(totalSeconds / (60 * 60));
totalSeconds -= hours * 60 * 60;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds - (minutes * 60);
const stringParts: string[] = [ 'P' ];
if (days > 0) {
stringParts.push(`${days}D`);
}
if (hours > 0 || minutes > 0 || seconds > 0) {
stringParts.push('T');
}
if (hours > 0) {
stringParts.push(`${hours}H`);
}
if (minutes > 0) {
stringParts.push(`${minutes}M`);
}
if (seconds > 0) {
stringParts.push(`${seconds}S`);
}
return stringParts.join('');
}

View File

@ -200,10 +200,13 @@ export const NOTIFY = createVocabulary('http://www.w3.org/ns/solid/notifications
'startAt', 'startAt',
'state', 'state',
'subscription', 'subscription',
'target',
'topic',
'webhookAuth', 'webhookAuth',
'webid', 'webid',
'WebHookSubscription2021', 'WebHookSubscription2021',
'WebSocketSubscription2021',
); );
export const OIDC = createVocabulary('http://www.w3.org/ns/solid/oidc#', export const OIDC = createVocabulary('http://www.w3.org/ns/solid/oidc#',
@ -276,7 +279,9 @@ export const VCARD = createVocabulary('http://www.w3.org/2006/vcard/ns#',
export const XSD = createVocabulary('http://www.w3.org/2001/XMLSchema#', export const XSD = createVocabulary('http://www.w3.org/2001/XMLSchema#',
'dateTime', 'dateTime',
'duration',
'integer', 'integer',
'string',
); );
// Alias for commonly used types // Alias for commonly used types

View File

@ -0,0 +1,177 @@
{
"@context": {
"@vocab": "http://www.w3.org/ns/shacl#",
"sh": "http://www.w3.org/ns/shacl#",
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
"rdfs": "http://www.w3.org/2000/01/rdf-schema#",
"xsd": "http://www.w3.org/2001/XMLSchema#",
"comment": "rdfs:comment",
"target": {
"@type": "@id"
},
"targetClass": {
"@type": "@id"
},
"targetObjectsOf": {
"@type": "@id"
},
"targetSubjectsOf": {
"@type": "@id"
},
"severity": {
"@type": "@id"
},
"result": {
"@type": "@id"
},
"detail": {
"@type": "@id"
},
"resultPath": {
"@type": "@id"
},
"resultSeverity": {
"@type": "@id"
},
"sourceShape": {
"@type": "@id"
},
"sourceConstraintComponent": {
"@type": "@id"
},
"prefixes": {
"@type": "@id"
},
"declare": {
"@type": "@id"
},
"namespace": {
"@type": "xsd:anyURI"
},
"shapesGraph": {
"@type": "@id"
},
"path": {
"@type": "@id"
},
"inversePath": {
"@type": "@id"
},
"alternativePath": {
"@type": "@id",
"@container": "@list"
},
"zeroOrMorePath": {
"@type": "@id"
},
"oneOrMorePath": {
"@type": "@id"
},
"zeroOrOnePath": {
"@type": "@id"
},
"parameter": {
"@type": "@id"
},
"validator": {
"@type": "@id"
},
"nodeValidator": {
"@type": "@id"
},
"propertyValidator": {
"@type": "@id"
},
"and": {
"@type": "@id",
"@container": "@list"
},
"class": {
"@type": "@id"
},
"ignoredProperties": {
"@type": "@id",
"@container": "@list"
},
"datatype": {
"@type": "@id"
},
"disjoint": {
"@type": "@id"
},
"equals": {
"@type": "@id"
},
"in": {
"@container": "@list"
},
"languageIn": {
"@container": "@list"
},
"lessThan": {
"@type": "@id"
},
"lessThanOrEquals": {
"@type": "@id"
},
"node": {
"@type": "@id"
},
"nodeKind": {
"@type": "@id"
},
"not": {
"@type": "@id"
},
"or": {
"@type": "@id",
"@container": "@list"
},
"property": {
"@type": "@id"
},
"qualifiedValueShape": {
"@type": "@id"
},
"xone": {
"@type": "@id",
"@container": "@list"
},
"sparql": {
"@type": "@id"
},
"derivedValues": {
"@type": "@id"
},
"group": {
"@type": "@id"
},
"returnType": {
"@type": "@id"
},
"resultAnnotation": {
"@type": "@id"
},
"annotationProperty": {
"@type": "@id"
},
"maxCount": {
"@type": "xsd:integer"
},
"maxLength": {
"@type": "xsd:integer"
},
"minCount": {
"@type": "xsd:integer"
},
"minLength": {
"@type": "xsd:integer"
},
"qualifiedMaxCount": {
"@type": "xsd:integer"
},
"qualifiedMinCount": {
"@type": "xsd:integer"
}
}
}

View File

@ -16,7 +16,8 @@ import {
getPresetConfigPath, getPresetConfigPath,
getTestConfigPath, getTestConfigPath,
getTestFolder, getTestFolder,
instantiateFromConfig, removeFolder, instantiateFromConfig,
removeFolder,
} from './Config'; } from './Config';
import quad = DataFactory.quad; import quad = DataFactory.quad;
import namedNode = DataFactory.namedNode; import namedNode = DataFactory.namedNode;
@ -26,7 +27,7 @@ const baseUrl = `http://localhost:${port}/`;
const clientPort = getPort('WebHookSubscription2021-client'); const clientPort = getPort('WebHookSubscription2021-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 = 'WebHookSubscription2021'; const notificationType = NOTIFY.WebHookSubscription2021;
const rootFilePath = getTestFolder('WebHookSubscription2021'); const rootFilePath = getTestFolder('WebHookSubscription2021');
const stores: [string, any][] = [ const stores: [string, any][] = [
@ -109,7 +110,7 @@ describe.each(stores)('A server supporting WebHookSubscription2021 using %s', (n
}); });
it('supports subscribing.', async(): Promise<void> => { it('supports subscribing.', async(): Promise<void> => {
await subscribe(notificationType, webId, subscriptionUrl, topic, { target }); await subscribe(notificationType, webId, subscriptionUrl, topic, { [NOTIFY.target]: target });
}); });
it('emits Created events.', async(): Promise<void> => { it('emits Created events.', async(): Promise<void> => {
@ -168,7 +169,7 @@ describe.each(stores)('A server supporting WebHookSubscription2021 using %s', (n
}); });
}); });
await subscribe(notificationType, webId, subscriptionUrl, topic, { target, state: 'abc' }); await subscribe(notificationType, webId, subscriptionUrl, topic, { [NOTIFY.target]: 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;

View File

@ -14,14 +14,15 @@ import {
getPresetConfigPath, getPresetConfigPath,
getTestConfigPath, getTestConfigPath,
getTestFolder, getTestFolder,
instantiateFromConfig, removeFolder, instantiateFromConfig,
removeFolder,
} from './Config'; } from './Config';
import quad = DataFactory.quad; import quad = DataFactory.quad;
import namedNode = DataFactory.namedNode; import namedNode = DataFactory.namedNode;
const port = getPort('WebSocketSubscription2021'); const port = getPort('WebSocketSubscription2021');
const baseUrl = `http://localhost:${port}/`; const baseUrl = `http://localhost:${port}/`;
const notificationType = 'WebSocketSubscription2021'; const notificationType = NOTIFY.WebSocketSubscription2021;
const rootFilePath = getTestFolder('WebSocketSubscription2021'); const rootFilePath = getTestFolder('WebSocketSubscription2021');
const stores: [string, any][] = [ const stores: [string, any][] = [
@ -166,7 +167,7 @@ describe.each(stores)('A server supporting WebSocketSubscription2021 using %s',
const channel = { const channel = {
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ], '@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
type: 'WebSocketSubscription2021', type: NOTIFY.WebSocketSubscription2021,
topic: restricted, topic: restricted,
}; };
@ -212,7 +213,8 @@ describe.each(stores)('A server supporting WebSocketSubscription2021 using %s',
}); });
it('removes expired channels.', async(): Promise<void> => { it('removes expired channels.', async(): Promise<void> => {
const { source } = await subscribe(notificationType, webId, subscriptionUrl, topic, { endAt: 1 }) as any; const { source } =
await subscribe(notificationType, webId, subscriptionUrl, topic, { endAt: '1988-03-09T14:48:00.000Z' }) as any;
const socket = new WebSocket(source); const socket = new WebSocket(source);
const messagePromise = new Promise<Buffer>((resolve): any => socket.on('message', resolve)); const messagePromise = new Promise<Buffer>((resolve): any => socket.on('message', resolve));

View File

@ -0,0 +1,190 @@
import { DataFactory, Store } from 'n3';
import type { Credentials } from '../../../../src/authentication/Credentials';
import { AccessMode } from '../../../../src/authorization/permissions/Permissions';
import { BaseChannelType } from '../../../../src/server/notifications/BaseChannelType';
import type { NotificationChannel } from '../../../../src/server/notifications/NotificationChannel';
import { DEFAULT_NOTIFICATION_FEATURES } from '../../../../src/server/notifications/NotificationDescriber';
import { UnprocessableEntityHttpError } from '../../../../src/util/errors/UnprocessableEntityHttpError';
import { IdentifierSetMultiMap } from '../../../../src/util/map/IdentifierMap';
import { NOTIFY, RDF, XSD } from '../../../../src/util/Vocabularies';
import namedNode = DataFactory.namedNode;
import quad = DataFactory.quad;
import blankNode = DataFactory.blankNode;
import literal = DataFactory.literal;
jest.mock('uuid', (): any => ({ v4: (): string => '4c9b88c1-7502-4107-bb79-2a3a590c7aa3' }));
const dummyType = namedNode('http://example.com/DummyType');
class DummyChannelType extends BaseChannelType {
public constructor(properties?: unknown[]) {
super(
dummyType,
properties,
);
}
}
describe('A BaseChannelType', (): void => {
const id = '4c9b88c1-7502-4107-bb79-2a3a590c7aa3:https://storage.example/resource';
const credentials: Credentials = {};
const channelType = new DummyChannelType();
describe('#initChannel', (): void => {
let data: Store;
const subject = blankNode();
beforeEach(async(): Promise<void> => {
data = new Store();
data.addQuad(quad(subject, RDF.terms.type, dummyType));
data.addQuad(quad(subject, NOTIFY.terms.topic, namedNode('https://storage.example/resource')));
});
it('converts the quads to a channel with an identifier.', async(): Promise<void> => {
await expect(channelType.initChannel(data, credentials)).resolves.toEqual({
id,
type: dummyType.value,
topic: 'https://storage.example/resource',
});
});
it('requires exactly 1 topic.', async(): Promise<void> => {
data.addQuad(quad(subject, NOTIFY.terms.topic, namedNode('https://storage.example/resource2')));
await expect(channelType.initChannel(data, credentials)).rejects.toThrow(UnprocessableEntityHttpError);
data.removeQuads(data.getQuads(subject, NOTIFY.terms.topic, null, null));
expect(data.size).toBe(1);
await expect(channelType.initChannel(data, credentials)).rejects.toThrow(UnprocessableEntityHttpError);
// Data is correct again now
data.addQuad(quad(subject, NOTIFY.terms.topic, namedNode('https://storage.example/resource')));
await expect(channelType.initChannel(data, credentials)).resolves.toBeDefined();
// Also make sure we can't have 2 different subjects with 1 topic each
data.addQuad(quad(blankNode(), NOTIFY.terms.topic, namedNode('https://storage.example/resource2')));
await expect(channelType.initChannel(data, credentials)).rejects.toThrow(UnprocessableEntityHttpError);
});
it('requires the correct type.', async(): Promise<void> => {
data = new Store();
data.addQuad(quad(subject, NOTIFY.terms.topic, namedNode('https://storage.example/resource')));
await expect(channelType.initChannel(data, credentials)).rejects.toThrow(UnprocessableEntityHttpError);
data.addQuad(quad(subject, RDF.terms.type, namedNode('http://example.com/wrongType')));
await expect(channelType.initChannel(data, credentials)).rejects.toThrow(UnprocessableEntityHttpError);
data.addQuad(quad(subject, RDF.terms.type, dummyType));
await expect(channelType.initChannel(data, credentials)).rejects.toThrow(UnprocessableEntityHttpError);
data.removeQuads(data.getQuads(subject, RDF.terms.type, namedNode('http://example.com/wrongType'), null));
data.addQuad(quad(subject, RDF.terms.type, dummyType));
await expect(channelType.initChannel(data, credentials)).resolves.toBeDefined();
});
it('converts the start date to a number.', async(): Promise<void> => {
const date = '1988-03-09T14:48:00.000Z';
const ms = Date.parse(date);
data.addQuad(quad(subject, NOTIFY.terms.startAt, literal(date, XSD.terms.dateTime)));
await expect(channelType.initChannel(data, credentials)).resolves.toEqual(expect.objectContaining({
startAt: ms,
}));
});
it('converts the end date to a number.', async(): Promise<void> => {
const date = '1988-03-09T14:48:00.000Z';
const ms = Date.parse(date);
data.addQuad(quad(subject, NOTIFY.terms.endAt, literal(date, XSD.terms.dateTime)));
await expect(channelType.initChannel(data, credentials)).resolves.toEqual(expect.objectContaining({
endAt: ms,
}));
});
it('converts the rate to a number.', async(): Promise<void> => {
data.addQuad(quad(subject, NOTIFY.terms.rate, literal('PT10S', XSD.terms.duration)));
await expect(channelType.initChannel(data, credentials)).resolves.toEqual(expect.objectContaining({
rate: 10 * 1000,
}));
});
it('requires correct datatypes on the features.', async(): Promise<void> => {
for (const feature of DEFAULT_NOTIFICATION_FEATURES) {
const badData = new Store(data.getQuads(null, null, null, null));
// No feature accepts an integer
badData.addQuad(quad(subject, namedNode(feature), literal(123456, XSD.terms.integer)));
await expect(channelType.initChannel(badData, credentials)).rejects.toThrow(UnprocessableEntityHttpError);
}
});
it('requires that features occur at most once.', async(): Promise<void> => {
const values = {
[NOTIFY.startAt]: [
literal('1988-03-09T14:48:00.000Z', XSD.terms.dateTime),
literal('2023-03-09T14:48:00.000Z', XSD.terms.dateTime),
],
[NOTIFY.endAt]: [
literal('1988-03-09T14:48:00.000Z', XSD.terms.dateTime),
literal('2023-03-09T14:48:00.000Z', XSD.terms.dateTime),
],
[NOTIFY.rate]: [ literal('PT10S', XSD.terms.duration), literal('PT11S', XSD.terms.duration) ],
[NOTIFY.accept]: [ literal('text/turtle'), literal('application/ld+json') ],
[NOTIFY.state]: [ literal('123456'), literal('654321') ],
};
for (const [ predicate, objects ] of Object.entries(values)) {
const badData = new Store(data.getQuads(null, null, null, null));
badData.addQuad(quad(subject, namedNode(predicate), objects[0]));
// One entry is fine
await expect(channelType.initChannel(badData, credentials)).resolves.toBeDefined();
badData.addQuad(quad(subject, namedNode(predicate), objects[1]));
await expect(channelType.initChannel(badData, credentials)).rejects.toThrow(UnprocessableEntityHttpError);
}
});
});
it('can convert a notification channel to a JSON-LD representation.', async(): Promise<void> => {
const startDate = '1988-03-09T14:48:00.000Z';
const endDate = '2022-03-09T14:48:00.000Z';
const channel: NotificationChannel = {
id,
type: 'DummyType',
topic: 'https://storage.example/resource',
state: 'state',
startAt: Date.parse(startDate),
endAt: Date.parse(endDate),
rate: 10 * 1000,
accept: 'text/turtle',
lastEmit: 123456789,
};
await expect(channelType.toJsonLd(channel)).resolves.toEqual({
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
id: channel.id,
type: channel.type,
topic: channel.topic,
state: channel.state,
startAt: startDate,
endAt: endDate,
rate: 'PT10S',
accept: channel.accept,
});
});
it('requires read permissions on the topic.', async(): Promise<void> => {
const channel: NotificationChannel = {
id,
type: 'DummyType',
topic: 'https://storage.example/resource',
};
await expect(channelType.extractModes(channel)).resolves
.toEqual(new IdentifierSetMultiMap([[{ path: channel.topic }, AccessMode.read ]]));
});
it('does nothing when completing the channel.', async(): Promise<void> => {
const channel: NotificationChannel = {
id,
type: 'DummyType',
topic: 'https://storage.example/resource',
};
await expect(channelType.completeChannel(channel)).resolves.toBeUndefined();
});
});

View File

@ -3,10 +3,7 @@ import type { ResourceIdentifier } from '../../../../src/http/representation/Res
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 { KeyValueChannelStorage } from '../../../../src/server/notifications/KeyValueChannelStorage'; import { KeyValueChannelStorage } from '../../../../src/server/notifications/KeyValueChannelStorage';
import type { import type { NotificationChannel } from '../../../../src/server/notifications/NotificationChannel';
NotificationChannel,
NotificationChannelJson,
} from '../../../../src/server/notifications/NotificationChannel';
import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage'; import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage';
import type { ReadWriteLocker } from '../../../../src/util/locking/ReadWriteLocker'; import type { ReadWriteLocker } from '../../../../src/util/locking/ReadWriteLocker';
import resetAllMocks = jest.resetAllMocks; import resetAllMocks = jest.resetAllMocks;
@ -21,12 +18,6 @@ describe('A KeyValueChannelStorage', (): void => {
const logger = getLoggerFor('mock'); const logger = getLoggerFor('mock');
const topic = 'http://example.com/foo'; const topic = 'http://example.com/foo';
const identifier = { path: topic }; const identifier = { path: topic };
const json = {
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
type: 'WebSocketSubscription2021',
topic,
} as NotificationChannelJson;
const features = {};
let channel: NotificationChannel; let channel: NotificationChannel;
let internalMap: Map<string, any>; let internalMap: Map<string, any>;
let internalStorage: KeyValueStorage<string, any>; let internalStorage: KeyValueStorage<string, any>;
@ -53,12 +44,6 @@ describe('A KeyValueChannelStorage', (): void => {
storage = new KeyValueChannelStorage(internalStorage, locker); storage = new KeyValueChannelStorage(internalStorage, locker);
}); });
describe('#create', (): void => {
it('creates channel based on a notification channel.', async(): Promise<void> => {
expect(storage.create(json, features)).toEqual(channel);
});
});
describe('#get', (): void => { describe('#get', (): void => {
it('returns undefined if there is no match.', async(): Promise<void> => { it('returns undefined if there is no match.', async(): Promise<void> => {
await expect(storage.get('notexists')).resolves.toBeUndefined(); await expect(storage.get('notexists')).resolves.toBeUndefined();

View File

@ -1,78 +0,0 @@
import { NOTIFICATION_CHANNEL_SCHEMA } from '../../../../src/server/notifications/NotificationChannel';
describe('A NotificationChannel', (): void => {
const validChannel = {
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
type: 'NotificationChannelType',
topic: 'http://example.com/foo',
};
it('requires a minimal set of values.', async(): Promise<void> => {
await expect(NOTIFICATION_CHANNEL_SCHEMA.isValid(validChannel)).resolves.toBe(true);
});
it('requires the notification context header to be present.', async(): Promise<void> => {
let channel: unknown = {
type: 'NotificationChannelType',
topic: 'http://example.com/foo',
};
await expect(NOTIFICATION_CHANNEL_SCHEMA.isValid(channel)).resolves.toBe(false);
channel = {
'@context': [ 'wrongContext' ],
type: 'NotificationChannelType',
topic: 'http://example.com/foo',
};
await expect(NOTIFICATION_CHANNEL_SCHEMA.isValid(channel)).resolves.toBe(false);
channel = {
'@context': [ 'contextA', 'https://www.w3.org/ns/solid/notification/v1', 'contextB' ],
type: 'NotificationChannelType',
topic: 'http://example.com/foo',
};
await expect(NOTIFICATION_CHANNEL_SCHEMA.isValid(channel)).resolves.toBe(true);
channel = {
'@context': 'https://www.w3.org/ns/solid/notification/v1',
type: 'NotificationChannelType',
topic: 'http://example.com/foo',
};
await expect(NOTIFICATION_CHANNEL_SCHEMA.isValid(channel)).resolves.toBe(true);
});
it('converts the start date to a number.', async(): Promise<void> => {
const date = '1988-03-09T14:48:00.000Z';
const ms = Date.parse(date);
const channel: unknown = {
...validChannel,
startAt: date,
};
await expect(NOTIFICATION_CHANNEL_SCHEMA.validate(channel)).resolves.toEqual(expect.objectContaining({
startAt: ms,
}));
});
it('converts the end date to a number.', async(): Promise<void> => {
const date = '1988-03-09T14:48:00.000Z';
const ms = Date.parse(date);
const channel: unknown = {
...validChannel,
endAt: date,
};
await expect(NOTIFICATION_CHANNEL_SCHEMA.validate(channel)).resolves.toEqual(expect.objectContaining({
endAt: ms,
}));
});
it('converts the rate to a number.', async(): Promise<void> => {
const channel: unknown = {
...validChannel,
rate: 'PT10S',
};
await expect(NOTIFICATION_CHANNEL_SCHEMA.validate(channel)).resolves.toEqual(expect.objectContaining({
rate: 10 * 1000,
}));
});
});

View File

@ -6,50 +6,67 @@ import { AccessMode } from '../../../../src/authorization/permissions/Permission
import type { Operation } from '../../../../src/http/Operation'; import type { Operation } from '../../../../src/http/Operation';
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier'; import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
import type { Logger } from '../../../../src/logging/Logger';
import { getLoggerFor } from '../../../../src/logging/LogUtil';
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 { NOTIFICATION_CHANNEL_SCHEMA } from '../../../../src/server/notifications/NotificationChannel'; import type { NotificationChannel } from '../../../../src/server/notifications/NotificationChannel';
import type { NotificationChannelStorage } from '../../../../src/server/notifications/NotificationChannelStorage';
import type { NotificationChannelType } from '../../../../src/server/notifications/NotificationChannelType'; import type { NotificationChannelType } from '../../../../src/server/notifications/NotificationChannelType';
import { NotificationSubscriber } from '../../../../src/server/notifications/NotificationSubscriber'; import { NotificationSubscriber } from '../../../../src/server/notifications/NotificationSubscriber';
import type { RepresentationConverter } from '../../../../src/storage/conversion/RepresentationConverter';
import { INTERNAL_QUADS } from '../../../../src/util/ContentTypes';
import { UnprocessableEntityHttpError } from '../../../../src/util/errors/UnprocessableEntityHttpError'; import { UnprocessableEntityHttpError } from '../../../../src/util/errors/UnprocessableEntityHttpError';
import { UnsupportedMediaTypeHttpError } from '../../../../src/util/errors/UnsupportedMediaTypeHttpError';
import { IdentifierMap, IdentifierSetMultiMap } from '../../../../src/util/map/IdentifierMap'; import { IdentifierMap, IdentifierSetMultiMap } from '../../../../src/util/map/IdentifierMap';
import { guardedStreamFrom } from '../../../../src/util/StreamUtil'; import { readableToString } from '../../../../src/util/StreamUtil';
import { flushPromises } from '../../../util/Util';
jest.mock('../../../../src/logging/LogUtil', (): any => {
const logger: Logger =
{ debug: jest.fn(), error: jest.fn() } as any;
return { getLoggerFor: (): Logger => logger };
});
describe('A NotificationSubscriber', (): void => { describe('A NotificationSubscriber', (): void => {
let channel: any;
const request: HttpRequest = {} as any; const request: HttpRequest = {} as any;
const response: HttpResponse = {} as any; const response: HttpResponse = {} as any;
let operation: Operation; let operation: Operation;
const topic: ResourceIdentifier = { path: 'http://example.com/foo' }; const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
let channel: NotificationChannel;
let channelType: jest.Mocked<NotificationChannelType>; let channelType: jest.Mocked<NotificationChannelType>;
let converter: jest.Mocked<RepresentationConverter>;
let credentialsExtractor: jest.Mocked<CredentialsExtractor>; let credentialsExtractor: jest.Mocked<CredentialsExtractor>;
let permissionReader: jest.Mocked<PermissionReader>; let permissionReader: jest.Mocked<PermissionReader>;
let authorizer: jest.Mocked<Authorizer>; let authorizer: jest.Mocked<Authorizer>;
let storage: jest.Mocked<NotificationChannelStorage>;
let subscriber: NotificationSubscriber; let subscriber: NotificationSubscriber;
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
channel = {
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
type: 'NotificationChannelType',
topic: topic.path,
};
operation = { operation = {
method: 'POST', method: 'POST',
target: { path: 'http://example.com/.notifications/websockets/' }, target: { path: 'http://example.com/.notifications/websockets/' },
body: new BasicRepresentation(JSON.stringify(channel), 'application/ld+json'), body: new BasicRepresentation(),
preferences: {}, preferences: {},
}; };
channelType = { channel = {
type: 'NotificationChannelType', type: 'NotificationChannelType',
schema: NOTIFICATION_CHANNEL_SCHEMA, topic: topic.path,
id: '123456',
};
channelType = {
initChannel: jest.fn().mockResolvedValue(channel),
toJsonLd: jest.fn().mockResolvedValue({}),
extractModes: jest.fn(async(subscription): Promise<AccessMap> => extractModes: jest.fn(async(subscription): Promise<AccessMap> =>
new IdentifierSetMultiMap([[{ path: subscription.topic }, AccessMode.read ]]) as AccessMap), new IdentifierSetMultiMap([[{ path: subscription.topic }, AccessMode.read ]]) as AccessMap),
subscribe: jest.fn().mockResolvedValue({ response: new BasicRepresentation(), channel: {}}), completeChannel: jest.fn(),
}; };
converter = {
handleSafe: jest.fn().mockResolvedValue(new BasicRepresentation([], INTERNAL_QUADS)),
} as any;
credentialsExtractor = { credentialsExtractor = {
handleSafe: jest.fn().mockResolvedValue({ public: {}}), handleSafe: jest.fn().mockResolvedValue({ public: {}}),
} as any; } as any;
@ -62,38 +79,40 @@ describe('A NotificationSubscriber', (): void => {
handleSafe: jest.fn(), handleSafe: jest.fn(),
} as any; } as any;
subscriber = new NotificationSubscriber({ channelType, credentialsExtractor, permissionReader, authorizer }); storage = {
}); add: jest.fn(),
} as any;
it('requires the request to be JSON-LD.', async(): Promise<void> => { subscriber = new NotificationSubscriber(
operation.body.metadata.contentType = 'text/turtle'; { channelType, converter, credentialsExtractor, permissionReader, authorizer, storage },
);
await expect(subscriber.handle({ operation, request, response })).rejects.toThrow(UnsupportedMediaTypeHttpError);
}); });
it('errors if the request can not be parsed correctly.', async(): Promise<void> => { it('errors if the request can not be parsed correctly.', async(): Promise<void> => {
operation.body.data = guardedStreamFrom('not json'); converter.handleSafe.mockRejectedValueOnce(new Error('bad data'));
await expect(subscriber.handle({ operation, request, response })).rejects.toThrow(UnprocessableEntityHttpError); await expect(subscriber.handle({ operation, request, response })).rejects.toThrow('bad data');
expect(storage.add).toHaveBeenCalledTimes(0);
// Type is missing
operation.body.data = guardedStreamFrom(JSON.stringify({
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
topic,
}));
await expect(subscriber.handle({ operation, request, response })).rejects.toThrow(UnprocessableEntityHttpError);
}); });
it('returns the representation generated by the subscribe call.', async(): Promise<void> => { it('errors if the channel type rejects the input.', async(): Promise<void> => {
channelType.initChannel.mockRejectedValueOnce(new Error('bad data'));
await expect(subscriber.handle({ operation, request, response })).rejects.toThrow(UnprocessableEntityHttpError);
expect(storage.add).toHaveBeenCalledTimes(0);
});
it('returns the JSON generated by the channel type.', async(): Promise<void> => {
const description = await subscriber.handle({ operation, request, response }); const description = await subscriber.handle({ operation, request, response });
expect(description.statusCode).toBe(200); expect(description.statusCode).toBe(200);
const subscribeResult = await channelType.subscribe.mock.results[0].value; expect(JSON.parse(await readableToString(description.data!))).toEqual({});
expect(description.data).toBe(subscribeResult.response.data); expect(description.metadata?.contentType).toBe('application/ld+json');
expect(description.metadata).toBe(subscribeResult.response.metadata); expect(storage.add).toHaveBeenCalledTimes(1);
expect(storage.add).toHaveBeenLastCalledWith(channel);
}); });
it('errors on requests the Authorizer rejects.', async(): Promise<void> => { it('errors on requests the Authorizer rejects.', async(): Promise<void> => {
authorizer.handleSafe.mockRejectedValue(new Error('not allowed')); authorizer.handleSafe.mockRejectedValue(new Error('not allowed'));
await expect(subscriber.handle({ operation, request, response })).rejects.toThrow('not allowed'); await expect(subscriber.handle({ operation, request, response })).rejects.toThrow('not allowed');
expect(storage.add).toHaveBeenCalledTimes(0);
}); });
it('updates the channel expiration if a max is defined.', async(): Promise<void> => { it('updates the channel expiration if a max is defined.', async(): Promise<void> => {
@ -102,35 +121,65 @@ describe('A NotificationSubscriber', (): void => {
subscriber = new NotificationSubscriber({ subscriber = new NotificationSubscriber({
channelType, channelType,
converter,
credentialsExtractor, credentialsExtractor,
permissionReader, permissionReader,
authorizer, authorizer,
storage,
maxDuration: 60, maxDuration: 60,
}); });
await subscriber.handle({ operation, request, response }); await subscriber.handle({ operation, request, response });
expect(channelType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({ expect(storage.add).toHaveBeenCalledTimes(1);
endAt: Date.now() + (60 * 60 * 1000), expect(storage.add).toHaveBeenLastCalledWith({
}), { public: {}});
operation.body.data = guardedStreamFrom(JSON.stringify({
...channel, ...channel,
endAt: new Date(Date.now() + 99999999999999).toISOString(),
}));
await subscriber.handle({ operation, request, response });
expect(channelType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
endAt: Date.now() + (60 * 60 * 1000), endAt: Date.now() + (60 * 60 * 1000),
}), { public: {}}); });
operation.body.data = guardedStreamFrom(JSON.stringify({ converter.handleSafe.mockResolvedValue(new BasicRepresentation());
...channel, channelType.initChannel.mockResolvedValueOnce({ ...channel, endAt: Date.now() + 99999999999999 });
endAt: new Date(Date.now() + 5).toISOString(),
}));
await subscriber.handle({ operation, request, response }); await subscriber.handle({ operation, request, response });
expect(channelType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({ expect(storage.add).toHaveBeenCalledTimes(2);
expect(storage.add).toHaveBeenLastCalledWith({
...channel,
endAt: Date.now() + (60 * 60 * 1000),
});
converter.handleSafe.mockResolvedValue(new BasicRepresentation());
channelType.initChannel.mockResolvedValueOnce({ ...channel, endAt: Date.now() + 5 });
await subscriber.handle({ operation, request, response });
expect(storage.add).toHaveBeenCalledTimes(3);
expect(storage.add).toHaveBeenLastCalledWith({
...channel,
endAt: Date.now() + 5, endAt: Date.now() + 5,
}), { public: {}}); });
jest.useRealTimers(); jest.useRealTimers();
}); });
it('calls the completeChannel function after sending the response.', async(): Promise<void> => {
const description = await subscriber.handle({ operation, request, response });
// Read out data to end stream correctly
await readableToString(description.data!);
await flushPromises();
expect(channelType.completeChannel).toHaveBeenCalledTimes(1);
});
it('logs an error if the completeChannel functions throws.', async(): Promise<void> => {
const logger = getLoggerFor('mock');
channelType.completeChannel.mockRejectedValue(new Error('notification error'));
const description = await subscriber.handle({ operation, request, response });
// Read out data to end stream correctly
await readableToString(description.data!);
await flushPromises();
expect(channelType.completeChannel).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error)
.toHaveBeenLastCalledWith(`There was an issue completing notification channel ${channel.id}: notification error`);
});
}); });

View File

@ -16,6 +16,7 @@ import type {
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';
import { NOTIFY } from '../../../../../src/util/Vocabularies';
jest.mock('cross-fetch'); jest.mock('cross-fetch');
@ -43,7 +44,7 @@ describe('A WebHookEmitter', (): void => {
const channel: WebHookSubscription2021Channel = { const channel: WebHookSubscription2021Channel = {
id: 'id', id: 'id',
topic: 'http://example.com/foo', topic: 'http://example.com/foo',
type: 'WebHookSubscription2021', type: NOTIFY.WebHookSubscription2021,
target: 'http://example.org/somewhere-else', target: 'http://example.org/somewhere-else',
webId: webIdRoute.getPath(), webId: webIdRoute.getPath(),
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention

View File

@ -1,24 +1,25 @@
import type { InferType } from 'yup'; import { DataFactory, Store } from 'n3';
import type { Credentials } from '../../../../../src/authentication/Credentials'; import type { Credentials } from '../../../../../src/authentication/Credentials';
import { AccessMode } from '../../../../../src/authorization/permissions/Permissions';
import { import {
AbsolutePathInteractionRoute, AbsolutePathInteractionRoute,
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute'; } from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
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 { CONTEXT_NOTIFICATION } from '../../../../../src/server/notifications/Notification';
import type { NotificationChannel } from '../../../../../src/server/notifications/NotificationChannel'; import type { NotificationChannel } from '../../../../../src/server/notifications/NotificationChannel';
import type {
NotificationChannelStorage,
} from '../../../../../src/server/notifications/NotificationChannelStorage';
import type { StateHandler } from '../../../../../src/server/notifications/StateHandler'; import type { StateHandler } from '../../../../../src/server/notifications/StateHandler';
import type {
WebHookSubscription2021Channel,
} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookSubscription2021';
import { import {
isWebHook2021Channel, isWebHook2021Channel,
WebHookSubscription2021, WebHookSubscription2021,
} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookSubscription2021'; } from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookSubscription2021';
import { IdentifierSetMultiMap } from '../../../../../src/util/map/IdentifierMap';
import { joinUrl } from '../../../../../src/util/PathUtil'; import { joinUrl } from '../../../../../src/util/PathUtil';
import { readableToString, readJsonStream } from '../../../../../src/util/StreamUtil'; import { NOTIFY, RDF } from '../../../../../src/util/Vocabularies';
import { flushPromises } from '../../../../util/Util'; import quad = DataFactory.quad;
import blankNode = DataFactory.blankNode;
import namedNode = DataFactory.namedNode;
jest.mock('../../../../../src/logging/LogUtil', (): any => { jest.mock('../../../../../src/logging/LogUtil', (): any => {
const logger: Logger = const logger: Logger =
@ -26,93 +27,75 @@ jest.mock('../../../../../src/logging/LogUtil', (): any => {
return { getLoggerFor: (): Logger => logger }; return { getLoggerFor: (): Logger => logger };
}); });
jest.mock('uuid', (): any => ({ v4: (): string => '4c9b88c1-7502-4107-bb79-2a3a590c7aa3' }));
describe('A WebHookSubscription2021', (): void => { describe('A WebHookSubscription2021', (): void => {
const credentials: Credentials = { agent: { webId: 'http://example.org/alice' }}; const credentials: Credentials = { agent: { webId: 'http://example.org/alice' }};
const target = 'http://example.org/somewhere-else'; const target = 'http://example.org/somewhere-else';
let json: InferType<WebHookSubscription2021['schema']>; const topic = 'https://storage.example/resource';
const subject = blankNode();
let data: Store;
let channel: WebHookSubscription2021Channel;
const unsubscribeRoute = new AbsolutePathInteractionRoute('http://example.com/unsubscribe'); const unsubscribeRoute = new AbsolutePathInteractionRoute('http://example.com/unsubscribe');
let storage: jest.Mocked<NotificationChannelStorage>;
let stateHandler: jest.Mocked<StateHandler>; let stateHandler: jest.Mocked<StateHandler>;
let channelType: WebHookSubscription2021; let channelType: WebHookSubscription2021;
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
json = { data = new Store();
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ], data.addQuad(quad(subject, RDF.terms.type, NOTIFY.terms.WebHookSubscription2021));
type: 'WebHookSubscription2021', data.addQuad(quad(subject, NOTIFY.terms.topic, namedNode(topic)));
data.addQuad(quad(subject, NOTIFY.terms.target, namedNode(target)));
const id = '4c9b88c1-7502-4107-bb79-2a3a590c7aa3:https://storage.example/resource';
channel = {
id,
type: NOTIFY.WebHookSubscription2021,
topic: 'https://storage.example/resource', topic: 'https://storage.example/resource',
target, target,
state: undefined, webId: 'http://example.org/alice',
startAt: undefined, // eslint-disable-next-line @typescript-eslint/naming-convention
endAt: undefined, unsubscribe_endpoint: joinUrl(unsubscribeRoute.getPath(), encodeURIComponent(id)),
accept: undefined,
rate: undefined,
}; };
storage = {
create: jest.fn((features: Record<string, unknown>): NotificationChannel => ({
id: '123',
topic: 'http://example.com/foo',
type: 'WebHookSubscription2021',
...features,
})),
add: jest.fn(),
} as any;
stateHandler = { stateHandler = {
handleSafe: jest.fn(), handleSafe: jest.fn(),
} as any; } as any;
channelType = new WebHookSubscription2021(storage, unsubscribeRoute, stateHandler); channelType = new WebHookSubscription2021(unsubscribeRoute, 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> => {
const channel = storage.create(json, {});
expect(isWebHook2021Channel(channel)).toBe(true); expect(isWebHook2021Channel(channel)).toBe(true);
channel.type = 'something else'; (channel as NotificationChannel).type = 'something else';
expect(isWebHook2021Channel(channel)).toBe(false); expect(isWebHook2021Channel(channel)).toBe(false);
}); });
it('has the correct type.', async(): Promise<void> => {
expect(channelType.type).toBe('WebHookSubscription2021');
});
it('correctly parses notification channel bodies.', async(): Promise<void> => { it('correctly parses notification channel bodies.', async(): Promise<void> => {
await expect(channelType.schema.isValid(json)).resolves.toBe(true); await expect(channelType.initChannel(data, credentials)).resolves.toEqual(channel);
json.type = 'something else';
await expect(channelType.schema.isValid(json)).resolves.toBe(false);
});
it('requires Read permissions on the topic.', async(): Promise<void> => {
await expect(channelType.extractModes(json)).resolves
.toEqual(new IdentifierSetMultiMap([[{ path: json.topic }, AccessMode.read ]]));
});
it('stores the channel and returns a valid response when subscribing.', async(): Promise<void> => {
const { response } = await channelType.subscribe(json, credentials);
expect(response.metadata.contentType).toBe('application/ld+json');
await expect(readJsonStream(response.data)).resolves.toEqual({
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
type: 'WebHookSubscription2021',
target,
// eslint-disable-next-line @typescript-eslint/naming-convention
unsubscribe_endpoint: joinUrl(unsubscribeRoute.getPath(), '123'),
});
}); });
it('errors if the credentials do not contain a WebID.', async(): Promise<void> => { it('errors if the credentials do not contain a WebID.', async(): Promise<void> => {
await expect(channelType.subscribe(json, {})).rejects await expect(channelType.initChannel(data, {})).rejects
.toThrow('A WebHookSubscription2021 subscription request needs to be authenticated with a WebID.'); .toThrow('A WebHookSubscription2021 subscription request needs to be authenticated with a WebID.');
}); });
it('calls the state handler once the response has been read.', async(): Promise<void> => { it('removes the WebID when converting back to JSON-LD.', async(): Promise<void> => {
const { response, channel } = await channelType.subscribe(json, credentials); await expect(channelType.toJsonLd(channel)).resolves.toEqual({
expect(stateHandler.handleSafe).toHaveBeenCalledTimes(0); '@context': [
CONTEXT_NOTIFICATION,
// Read out data to end stream correctly ],
await readableToString(response.data); id: channel.id,
type: NOTIFY.WebHookSubscription2021,
target,
topic,
// eslint-disable-next-line @typescript-eslint/naming-convention
unsubscribe_endpoint: channel.unsubscribe_endpoint,
});
});
it('calls the state handler once the channel is completed.', async(): Promise<void> => {
await channelType.completeChannel(channel);
expect(stateHandler.handleSafe).toHaveBeenCalledTimes(1); expect(stateHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(stateHandler.handleSafe).toHaveBeenLastCalledWith({ channel }); expect(stateHandler.handleSafe).toHaveBeenLastCalledWith({ channel });
}); });
@ -121,14 +104,7 @@ describe('A WebHookSubscription2021', (): void => {
const logger = getLoggerFor('mock'); const logger = getLoggerFor('mock');
stateHandler.handleSafe.mockRejectedValue(new Error('notification error')); stateHandler.handleSafe.mockRejectedValue(new Error('notification error'));
const { response } = await channelType.subscribe(json, credentials); await channelType.completeChannel(channel);
expect(logger.error).toHaveBeenCalledTimes(0);
// Read out data to end stream correctly
await readableToString(response.data);
await flushPromises();
expect(logger.error).toHaveBeenCalledTimes(1); expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenLastCalledWith('Error emitting state notification: notification error'); expect(logger.error).toHaveBeenLastCalledWith('Error emitting state notification: notification error');
}); });

View File

@ -11,6 +11,7 @@ import {
} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookUnsubscriber'; } from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookUnsubscriber';
import { ForbiddenHttpError } from '../../../../../src/util/errors/ForbiddenHttpError'; import { ForbiddenHttpError } from '../../../../../src/util/errors/ForbiddenHttpError';
import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError'; import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError';
import { NOTIFY } from '../../../../../src/util/Vocabularies';
describe('A WebHookUnsubscriber', (): void => { describe('A WebHookUnsubscriber', (): void => {
const request: HttpRequest = {} as any; const request: HttpRequest = {} as any;
@ -34,7 +35,7 @@ describe('A WebHookUnsubscriber', (): void => {
} as any; } as any;
storage = { storage = {
get: jest.fn().mockResolvedValue({ type: 'WebHookSubscription2021', webId }), get: jest.fn().mockResolvedValue({ type: NOTIFY.WebHookSubscription2021, webId }),
delete: jest.fn(), delete: jest.fn(),
} as any; } as any;

View File

@ -1,70 +1,58 @@
import { AccessMode } from '../../../../../src/authorization/permissions/Permissions'; import { DataFactory, Store } from 'n3';
import { import {
AbsolutePathInteractionRoute, AbsolutePathInteractionRoute,
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute'; } from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
import type { NotificationChannelJson } from '../../../../../src/server/notifications/NotificationChannel'; import type { NotificationChannel } from '../../../../../src/server/notifications/NotificationChannel';
import type { NotificationChannelStorage } from '../../../../../src/server/notifications/NotificationChannelStorage';
import { import {
generateWebSocketUrl,
} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocket2021Util';
import type {
WebSocketSubscription2021Channel,
} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021';
import {
isWebSocket2021Channel,
WebSocketSubscription2021, WebSocketSubscription2021,
} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021'; } from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021';
import { IdentifierSetMultiMap } from '../../../../../src/util/map/IdentifierMap'; import { NOTIFY, RDF } from '../../../../../src/util/Vocabularies';
import { readJsonStream } from '../../../../../src/util/StreamUtil'; import quad = DataFactory.quad;
import blankNode = DataFactory.blankNode;
import namedNode = DataFactory.namedNode;
jest.mock('uuid', (): any => ({ v4: (): string => '4c9b88c1-7502-4107-bb79-2a3a590c7aa3' }));
describe('A WebSocketSubscription2021', (): void => { describe('A WebSocketSubscription2021', (): void => {
let channel: NotificationChannelJson; let data: Store;
let storage: jest.Mocked<NotificationChannelStorage>; let channel: WebSocketSubscription2021Channel;
const subject = blankNode();
const topic = 'https://storage.example/resource';
const route = new AbsolutePathInteractionRoute('http://example.com/foo'); const route = new AbsolutePathInteractionRoute('http://example.com/foo');
let channelType: WebSocketSubscription2021; let channelType: WebSocketSubscription2021;
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
data = new Store();
data.addQuad(quad(subject, RDF.terms.type, NOTIFY.terms.WebSocketSubscription2021));
data.addQuad(quad(subject, NOTIFY.terms.topic, namedNode(topic)));
const id = '4c9b88c1-7502-4107-bb79-2a3a590c7aa3:https://storage.example/resource';
channel = { channel = {
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ], id,
type: 'WebSocketSubscription2021', type: NOTIFY.WebSocketSubscription2021,
topic: 'https://storage.example/resource', topic,
state: undefined, source: generateWebSocketUrl(route.getPath(), id),
startAt: undefined,
endAt: undefined,
accept: undefined,
rate: undefined,
}; };
storage = { channelType = new WebSocketSubscription2021(route);
create: jest.fn().mockReturnValue({
id: '123',
topic: 'http://example.com/foo',
type: 'WebSocketSubscription2021',
lastEmit: 0,
features: {},
}),
add: jest.fn(),
} as any;
channelType = new WebSocketSubscription2021(storage, route);
}); });
it('has the correct type.', async(): Promise<void> => { it('exposes a utility function to verify if a channel is a websocket channel.', async(): Promise<void> => {
expect(channelType.type).toBe('WebSocketSubscription2021'); expect(isWebSocket2021Channel(channel)).toBe(true);
(channel as NotificationChannel).type = 'something else';
expect(isWebSocket2021Channel(channel)).toBe(false);
}); });
it('correctly parses notification channel bodies.', async(): Promise<void> => { it('correctly parses notification channel bodies.', async(): Promise<void> => {
await expect(channelType.schema.isValid(channel)).resolves.toBe(true); await expect(channelType.initChannel(data, {})).resolves.toEqual(channel);
channel.type = 'something else';
await expect(channelType.schema.isValid(channel)).resolves.toBe(false);
});
it('requires Read permissions on the topic.', async(): Promise<void> => {
await expect(channelType.extractModes(channel)).resolves
.toEqual(new IdentifierSetMultiMap([[{ path: channel.topic }, AccessMode.read ]]));
});
it('stores the channel and returns a valid response when subscribing.', async(): Promise<void> => {
const { response } = await channelType.subscribe(channel);
expect(response.metadata.contentType).toBe('application/ld+json');
await expect(readJsonStream(response.data)).resolves.toEqual({
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
type: 'WebSocketSubscription2021',
source: expect.stringMatching(/^ws:\/\/example.com\/foo\?auth=.+/u),
});
}); });
}); });

View File

@ -1,7 +1,7 @@
import { import {
sanitizeUrlPart, sanitizeUrlPart,
splitCommaSeparated, splitCommaSeparated,
isValidFileName, isValidFileName, msToDuration,
} from '../../../src/util/StringUtil'; } from '../../../src/util/StringUtil';
describe('HeaderUtil', (): void => { describe('HeaderUtil', (): void => {
@ -31,4 +31,21 @@ describe('HeaderUtil', (): void => {
expect(isValidFileName('$%^*')).toBeFalsy(); expect(isValidFileName('$%^*')).toBeFalsy();
}); });
}); });
describe('#msToDuration', (): void => {
it('converts ms to a duration string.', async(): Promise<void> => {
const ms = ((2 * 24 * 60 * 60) + (10 * 60 * 60) + (5 * 60) + 50.25) * 1000;
expect(msToDuration(ms)).toBe('P2DT10H5M50.25S');
});
it('ignores 0 values.', async(): Promise<void> => {
const ms = ((2 * 24 * 60 * 60) + 50.25) * 1000;
expect(msToDuration(ms)).toBe('P2DT50.25S');
});
it('excludes the T if there is no time segment.', async(): Promise<void> => {
const ms = ((2 * 24 * 60 * 60)) * 1000;
expect(msToDuration(ms)).toBe('P2D');
});
});
}); });

View File

@ -2,9 +2,9 @@ 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. "WebSocketSubscription2021". * @param type - The type of the notification channel, e.g., "NOTIFY.WebHookSubscription2021".
* @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 where the request needs to be sent to. * @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.
* @param features - Any extra fields that need to be added to the subscription body. * @param features - Any extra fields that need to be added to the subscription body.
*/ */