├── .componentsignore ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── before.ttl ├── config ├── customise-me.json ├── dev-http-subdomain.json ├── dev-http-suffix.json ├── http │ └── handler │ │ ├── default.json │ │ └── handlers │ │ └── fedcm.json ├── identity │ └── oidc │ │ └── default.json ├── pivot-overrides.json ├── prod.json ├── storage │ ├── backend │ │ ├── file.json │ │ └── memory.json │ └── middleware │ │ ├── default.json │ │ └── stores │ │ └── patching.json └── test.json ├── jest.config.js ├── jest.coverage.config.js ├── package-lock.json ├── package.json ├── patch.ttl ├── src ├── FedcmHttpHandler.ts ├── http │ └── output │ │ └── PivotResponseWriter.ts ├── identity │ ├── PivotOidcHttpHandler.ts │ └── interaction │ │ └── password │ │ └── MigratedPasswordLoginHandler.ts ├── index.ts ├── storage │ ├── RdfPatchingStore.ts │ └── patch │ │ ├── ThrowingN3Patcher.ts │ │ └── n3-patch-parser.ts └── util │ └── debug.ts ├── templates ├── identity │ ├── oidc │ │ └── consent.html.ejs │ └── password │ │ └── login.html.ejs ├── main.html.ejs └── pod │ └── wac │ ├── .acl.hbs │ ├── .meta │ ├── README$.md.hbs │ ├── README.acl.hbs │ ├── inbox │ └── .acl.hbs │ ├── profile │ ├── .acl.hbs │ ├── card$.ttl.hbs │ └── card.acl.hbs │ ├── public │ └── .acl.hbs │ ├── robots.txt │ ├── robots.txt.acl.hbs │ └── settings │ ├── .acl.hbs │ ├── prefs.ttl.hbs │ ├── privateTypeIndex.ttl.hbs │ ├── publicTypeIndex.ttl.acl.hbs │ └── publicTypeIndex.ttl.hbs ├── test ├── integration │ ├── Cli.test.ts │ ├── Config.ts │ └── N3Patch.test.ts ├── unit │ ├── storage │ │ └── RdfPatchingStore.test.ts │ └── util │ │ └── HeaderUtil.test.ts └── util │ ├── AclHelper.ts │ ├── FetchUtil.ts │ ├── SimpleSuffixStrategy.ts │ └── Util.ts ├── tsconfig.json └── www ├── .acl └── index.html /.componentsignore: -------------------------------------------------------------------------------- 1 | [ 2 | ] 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | cert.pem 2 | key.pem 3 | ./custom-config.json 4 | /.acl 5 | /.eslintcache 6 | /componentsjs-error-state.json 7 | /coverage 8 | /data 9 | /dist 10 | /docs 11 | /node_modules 12 | /test/tmp 13 | /.idea 14 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | key.pem 2 | cert.pem 3 | node_modules 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | Copyright (c) 2019-2025 Solid, CSS, and SolidOS Contributors, Inrupt Inc. and imec. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pivot 2 | 3 | Screenshot 2023-11-17 at 09 04 27 5 | 6 | A spec-compliant Solid server for use on the [Solid Community server](https://solidcommunity.net), 7 | based on a remix of building blocks from the 8 | [Community Solid Server](https://github.com/CommunitySolidServer/CommunitySolidServer) project. 9 | 10 | That is to say, this server implements a certain community flavour of Solid, namely: 11 | * using [the Solid protocol](https://solidproject.org/TR/protocol) 12 | * using WAC and not ACP 13 | * but using an [older version of Solid OIDC](https://github.com/solid/solid-oidc/tree/a5a966c7342da01a57bfb316e5533ea7d82fd245), where storage access control is done with DPoP instead of with UMA 14 | * ([under development](https://github.com/solid-contrib/pivot/issues/64)) using the PoP token issuer as an indication for app origin 15 | 16 | Feel free to [open a feature request](https://github.com/solid-contrib/pivot/issues/new) if you think 17 | `solidcommunity.net` should implement some 18 | additional feature - because it's a missing spec feature, or because it's a new optional or experimental 19 | spec feature, or just because you want 20 | to show a novel way for your Solid project to interact with a Solid pod server. 21 | 22 | You can also join the Matrix chat [for solidcommunity.net](https://matrix.to/#/#solid_solidcommunity.net:gitter.im) 23 | or [for Pivot as piece of config+software](https://matrix.to/#/#solid_pivot:matrix.org). 24 | 25 | ## Warning 26 | With Pivot's default settings, when a pod owner authenticates to a Solid app, this app can get full access to that user's data, on their own pod and elsewhere. This is not how we envision Solid's trinity of WebId's, Pods, and Solid apps, but it's what we have implemented so far. This is a problem that is not specific to Pivot, but that is shared among all WAC-based implementations of Solid. 27 | 28 | See [this issue](https://github.com/solid-contrib/pivot/issues/78) for a discussion of how we might fix this situation. 29 | In the meantime, we [warn the user](https://github.com/solid-contrib/pivot/pull/38) (in a much sterner way than most other WAC-based servers do) that in the Solid-OIDC flow they are not just sharing their identity with a Solid app, but are actually allowing that app to read and write any data on their behalf. Still, we are aware that the current situation is insecure. 30 | 31 | ## Example usage 32 | 33 | These are the bash commands to run on for example [https://pivot.pondersource.com/](https://pivot.pondersource.com/). 34 | * create an Ubuntu server 35 | * set the DNS record for pivot.pondersource.com 36 | * ssh into the server, `apt update`, `apt upgrade` 37 | * get a wilcard cert 38 | * `apt install certbot` 39 | * `certbot certonly --manual --preferred-challenges dns --debug-challenges -v -d \*.pivot.pondersource.com -d pivot.pondersource.com` 40 | * add the `_acme-challenge.pivot` TXT record in DNS 41 | * check `dig txt _acme-challenge.pivot.pondersource.com` 42 | * continue certbot dialog 43 | * `ls /etc/letsencrypt/live/pivot.pondersource.com/` 44 | * install node 45 | * `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash` 46 | * `source ~/.bashrc` 47 | * `nvm install 20` 48 | * copy `config/customise-me.json` to `./custom-config.json` and edit it: 49 | * email server settings (will need to at least fill in the auth pass here) 50 | * quota settings (defaults to 70 MB per pod) 51 | * pod template (defaults to `node_modules/css-mashlib`) 52 | * mashlib version (both data browser and static files; defaults to `node_modules/mashlib`) 53 | 54 | ```bash 55 | root:~# git clone https://github.com/solid-contrib/pivot 56 | root:~# cd pivot 57 | root:~/pivot# npm ci --skip=dev 58 | root:~/pivot# npm run build 59 | root:~/pivot# mkdir -p data 60 | root:~/pivot# cp -r www data/ 61 | root:~/pivot# cp config/customise-me.json custom-config.json 62 | root:~/pivot# npx community-solid-server -c ./config/prod.json ./custom-config.json -f ./data --httpsKey /etc/letsencrypt/live/pivot.pondersource.com/privkey.pem --httpsCert /etc/letsencrypt/live/pivot.pondersource.com/fullchain.pem -p 443 -b https://pivot.pondersource.com -m . 63 | 2024-11-13T11:28:02.426Z [Components.js] info: Initiating component discovery from /root/pivot 64 | 2024-11-13T11:28:02.919Z [Components.js] info: Discovered 169 component packages within 1339 packages 65 | 2024-11-13T11:28:02.921Z [Components.js] info: Initiating component loading 66 | 2024-11-13T11:28:10.017Z [Components.js] info: Registered 904 components 67 | 2024-11-13T11:28:10.018Z [Components.js] info: Loaded configs 68 | 2024-11-13T11:28:12.002Z [ServerInitializer] {Primary} info: Listening to server at https://localhost/ 69 | ``` 70 | 71 | Or on `https localhost`: 72 | 73 | ```bash 74 | git clone https://github.com/solid-contrib/pivot 75 | cd pivot 76 | npm install 77 | npm run build 78 | npm test 79 | openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 3650 -nodes -subj "/C=XX/ST=StateName/L=CityName/O=CompanyName/OU=CompanySectionName/CN=CommonNameOrHostname" 80 | npm start 81 | ``` 82 | 83 | Or on `http localhost`, use `config/dev-http-suffix.json` 84 | ``` 85 | npx community-solid-server -c ./config/dev-http-suffix.json ./custom-config.json -f ./data -p 3000 -b http://localhost:3000 -m . 86 | ``` 87 | or `config/dev-http-subdomain.json` 88 | When using localhost with subdomain you must also declaree the subdomain in `/etc/hosts`. 89 | To create an account `bob.localhost:3000` you shall add the following record 90 | ``` 91 | 127.0.0.1 bob.localhost 92 | ``` 93 | ``` 94 | npx community-solid-server -c ./config/dev-http-subdomain.json ./custom-config.json -f ./data -p 3000 -b http://localhost:3000 -m . 95 | ``` 96 | 97 | 98 | ## Why 'pivot'? 99 | 100 | _Short answer:_ we needed a name. ;) 101 | 102 | _Long answer:_ it comes from the role a Solid pod can play in a data portability scenario. 103 | In traditional data portability, the user consents to organisation A transferring their data to organisation B. 104 | A Solid pod, however, can act as a "pivot" for data sharing: data is first transferred from organisation A to the pod, 105 | and then from the pod to organisation B, without the two organisations ever interacting directly. The organisations only 106 | interact through the "pivot" that is owned by the user. 107 | This greatly simplifies consent management and makes data access control user-centric. Hence the name "pivot" for this 108 | open source Solid server implementation. :) 109 | 110 | ## Copyright 111 | 112 | This repo is a very thin wrapper around 113 | [its four dependencies](https://github.com/solid-contrib/pivot/blob/70b0d5643d176ee90c70b955598973e3b97ab93d/package.json#L34-L39): 114 | * [CSS](https://github.com/CommunitySolidServer/CommunitySolidServer) 115 | * [mashlib](https://github.com/solidos/mashlib) 116 | * [rdflib.js](https://github.com/linkeddata/rdflib.js) 117 | * [css-mashlib](https://github.com/solidos/css-mashlib) 118 | 119 | Apart from that, even for the code that this repo does add, some parts were 120 | created using "copy, paste & edit" or in some cases also copied unchanged from the CSS repo, 121 | which has the following copyright notice: 122 | 123 | ``` 124 | Copyright (c) 2019-2025 Inrupt Inc. and imec 125 | ``` 126 | 127 | and from the css-mashlib repo, which has the following copyright notice: 128 | ``` 129 | Copyright (c) 2022 SolidOS 130 | ``` 131 | 132 | Whereas npm dependencies don't require you to copy the copyright notice, 133 | code copying and code remixing does. To honour the copyright involved in the 134 | code contained in this repo, we hereby publish it under an MIT license, 135 | with the following copyright notice: 136 | ``` 137 | Copyright (c) 2019-2025 Solid, CSS, and SolidOS Contributors, Inrupt Inc. and imec. 138 | ``` 139 | 140 | Photo on this page (138720473) © Leo Lintang | Dreamstime.com 141 | -------------------------------------------------------------------------------- /before.ttl: -------------------------------------------------------------------------------- 1 | @prefix solid: . 2 | ( ). 3 | 4 | -------------------------------------------------------------------------------- /config/customise-me.json: -------------------------------------------------------------------------------- 1 | { 2 | "comment": "Basic overrides parameters for a production server", 3 | "@context": [ 4 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^7.0.0/components/context.jsonld", 5 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/pivot/^1.0.0/components/context.jsonld" 6 | ], 7 | "@graph": [ 8 | { 9 | "comment": "The settings of your email server.", 10 | "@type": "Override", 11 | "overrideInstance": { 12 | "@id": "urn:solid-server:default:EmailSender" 13 | }, 14 | "overrideParameters": { 15 | "@type": "BaseEmailSender", 16 | "senderName": "no-reply@solidcommunity.net", 17 | "emailConfig_host": "smtp.sendgrid.net", 18 | "emailConfig_port": 465, 19 | "emailConfig_auth_user": "apikey", 20 | "emailConfig_auth_pass": "" 21 | } 22 | }, 23 | { 24 | "comment": "The location of the new pod templates folder.", 25 | "@type": "Override", 26 | "overrideInstance": { 27 | "@id": "urn:solid-server:default:PodResourcesGenerator" 28 | }, 29 | "overrideParameters": { 30 | "@type": "StaticFolderGenerator", 31 | "templateFolder": "templates/pod" 32 | } 33 | }, 34 | { 35 | "comment": "Sets the maximum size of a single pod to 70MB.", 36 | "@type": "Override", 37 | "overrideInstance": { 38 | "@id": "urn:solid-server:default:QuotaStrategy" 39 | }, 40 | "overrideParameters": { 41 | "@type": "PodQuotaStrategy", 42 | "limit_amount": 70000000, 43 | "limit_unit": "bytes" 44 | } 45 | }, 46 | { 47 | "comment": "Serve Databrowser as default representation", 48 | "@id": "urn:solid-server:default:DefaultUiConverter", 49 | "@type": "ConstantConverter", 50 | "contentType": "text/html", 51 | "filePath": "./node_modules/mashlib/dist/databrowser.html", 52 | "options_container": true, 53 | "options_document": true, 54 | "options_minQuality": 1, 55 | "options_disabledMediaRanges": [ 56 | "image/*", 57 | "application/pdf" 58 | ] 59 | }, 60 | { 61 | "comment": "Serve Mashlib static files.", 62 | "@id": "urn:solid-server:default:StaticAssetHandler", 63 | "@type": "StaticAssetHandler", 64 | "assets": [ 65 | { 66 | "@type": "StaticAssetEntry", 67 | "relativeUrl": "/browse.html", 68 | "filePath": "./node_modules/mashlib/dist/browse.html" 69 | }, 70 | { 71 | "@type": "StaticAssetEntry", 72 | "relativeUrl": "/mash.css", 73 | "filePath": "./node_modules/mashlib/dist/mash.css" 74 | }, 75 | { 76 | "@type": "StaticAssetEntry", 77 | "relativeUrl": "/mashlib.js", 78 | "filePath": "./node_modules/mashlib/dist/mashlib.js" 79 | }, 80 | { 81 | "@type": "StaticAssetEntry", 82 | "relativeUrl": "/mashlib.js.map", 83 | "filePath": "./node_modules/mashlib/dist/mashlib.js.map" 84 | }, 85 | { 86 | "@type": "StaticAssetEntry", 87 | "relativeUrl": "/mashlib.min.js", 88 | "filePath": "./node_modules/mashlib/dist/mashlib.min.js" 89 | }, 90 | { 91 | "@type": "StaticAssetEntry", 92 | "relativeUrl": "/mashlib.min.js.map", 93 | "filePath": "./node_modules/mashlib/dist/mashlib.min.js.map" 94 | } 95 | ] 96 | } 97 | ] 98 | } 99 | 100 | -------------------------------------------------------------------------------- /config/dev-http-subdomain.json: -------------------------------------------------------------------------------- 1 | { 2 | "comment": "Copied from https://github.com/SolidOS/css-mashlib/blob/ae21af4685f6c95c1f091cacd952831f272ea119/config/https-mashlib-subdomain-file.json, (1) pivot:config/http/handler/default.json, (2) pivot:config/storage/middleware/default.json, (3) pivot:config/pivot-overrides.json added as the last import", 3 | "@context": [ 4 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^7.0.0/components/context.jsonld", 5 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/pivot/^1.0.0/components/context.jsonld" 6 | ], 7 | "import": [ 8 | "css:config/app/init/default.json", 9 | "css:config/app/main/default.json", 10 | "css:config/app/variables/default.json", 11 | "pivot:config/http/handler/default.json", 12 | "css:config/http/middleware/default.json", 13 | "css:config/http/notifications/all.json", 14 | "css:config/http/server-factory/http.json", 15 | "css:config/http/static/default.json", 16 | "css:config/identity/access/public.json", 17 | "css:config/identity/email/default.json", 18 | "css:config/identity/handler/default.json", 19 | "pivot:config/identity/oidc/default.json", 20 | "css:config/identity/ownership/token.json", 21 | "css:config/identity/pod/static.json", 22 | "css:config/ldp/authentication/dpop-bearer.json", 23 | "css:config/ldp/authorization/webacl.json", 24 | "css:config/ldp/handler/default.json", 25 | "css:config/ldp/metadata-parser/default.json", 26 | "css:config/ldp/metadata-writer/default.json", 27 | "css:config/ldp/modes/default.json", 28 | "css:config/storage/backend/pod-quota-file.json", 29 | "css:config/storage/key-value/resource-store.json", 30 | "css:config/storage/location/pod.json", 31 | "pivot:config/storage/middleware/default.json", 32 | "css:config/util/auxiliary/acl.json", 33 | "css:config/util/identifiers/subdomain.json", 34 | 35 | "css:config/util/logging/winston.json", 36 | "css:config/util/representation-conversion/default.json", 37 | "css:config/util/resource-locker/file.json", 38 | "css:config/util/variables/default.json", 39 | "pivot:config/pivot-overrides.json" 40 | ], 41 | "@graph": [ 42 | { 43 | "comment": [ 44 | "A filesystem-based server with Databrowser as UI.", 45 | "Derived from config/file-no-setup.json" 46 | ] 47 | } 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /config/dev-http-suffix.json: -------------------------------------------------------------------------------- 1 | { 2 | "comment": "Copied from https://github.com/SolidOS/css-mashlib/blob/ae21af4685f6c95c1f091cacd952831f272ea119/config/https-mashlib-subdomain-file.json, (1) pivot:config/http/handler/default.json, (2) pivot:config/storage/middleware/default.json, (3) pivot:config/pivot-overrides.json added as the last import", 3 | "@context": [ 4 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^7.0.0/components/context.jsonld", 5 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/pivot/^1.0.0/components/context.jsonld" 6 | ], 7 | "import": [ 8 | "css:config/app/init/default.json", 9 | "css:config/app/main/default.json", 10 | "css:config/app/variables/default.json", 11 | "pivot:config/http/handler/default.json", 12 | "css:config/http/middleware/default.json", 13 | "css:config/http/notifications/all.json", 14 | "css:config/http/server-factory/http.json", 15 | "css:config/http/static/default.json", 16 | "css:config/identity/access/public.json", 17 | "css:config/identity/email/default.json", 18 | "css:config/identity/handler/default.json", 19 | "pivot:config/identity/oidc/default.json", 20 | "css:config/identity/ownership/token.json", 21 | "css:config/identity/pod/static.json", 22 | "css:config/ldp/authentication/dpop-bearer.json", 23 | "css:config/ldp/authorization/webacl.json", 24 | "css:config/ldp/handler/default.json", 25 | "css:config/ldp/metadata-parser/default.json", 26 | "css:config/ldp/metadata-writer/default.json", 27 | "css:config/ldp/modes/default.json", 28 | "css:config/storage/backend/pod-quota-file.json", 29 | "css:config/storage/key-value/resource-store.json", 30 | "css:config/storage/location/pod.json", 31 | "pivot:config/storage/middleware/default.json", 32 | "css:config/util/auxiliary/acl.json", 33 | "css:config/util/identifiers/suffix.json", 34 | 35 | "css:config/util/logging/winston.json", 36 | "css:config/util/representation-conversion/default.json", 37 | "css:config/util/resource-locker/file.json", 38 | "css:config/util/variables/default.json", 39 | "pivot:config/pivot-overrides.json" 40 | ], 41 | "@graph": [ 42 | { 43 | "comment": [ 44 | "A filesystem-based server with Databrowser as UI.", 45 | "Derived from config/file-no-setup.json" 46 | ] 47 | } 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /config/http/handler/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": [ 3 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^7.0.0/components/context.jsonld", 4 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/pivot/^1.0.0/components/context.jsonld" 5 | ], 6 | "import": [ 7 | "css:config/http/handler/handlers/storage-description.json", 8 | "pivot:config/http/handler/handlers/fedcm.json" 9 | ], 10 | "@graph": [ 11 | { 12 | "comment": "These are all the handlers a request will go through until it is handled.", 13 | "@id": "urn:solid-server:default:HttpHandler", 14 | "@type": "SequenceHandler", 15 | "handlers": [ 16 | { "@id": "urn:solid-server:default:Middleware" }, 17 | { 18 | "@id": "urn:solid-server:default:BaseHttpHandler", 19 | "@type": "WaterfallHandler", 20 | "handlers": [ 21 | { "@id": "urn:solid-server:default:StaticAssetHandler" }, 22 | { "@id": "urn:solid-server:default:OidcHandler" }, 23 | { "@id": "urn:solid-server:default:SubdomainOidcHandler" }, 24 | { "@id": "urn:pivot:default:FedcmHandler" }, 25 | { "@id": "urn:solid-server:default:NotificationHttpHandler" }, 26 | { "@id": "urn:solid-server:default:StorageDescriptionHandler" }, 27 | { "@id": "urn:solid-server:default:AuthResourceHttpHandler" }, 28 | { "@id": "urn:solid-server:default:IdentityProviderHandler" }, 29 | { "@id": "urn:solid-server:default:LdpHandler" } 30 | ] 31 | } 32 | ] 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /config/http/handler/handlers/fedcm.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": [ 3 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^7.0.0/components/context.jsonld", 4 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/pivot/^1.0.0/components/context.jsonld" 5 | ], 6 | "@graph": [ 7 | { 8 | "@id": "urn:pivot:default:FedcmHandler", 9 | "@type": "RouterHandler", 10 | "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, 11 | "targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, 12 | "allowedPathNames": [ "/\\.well-known/web-identity", 13 | "/\\.well-known/fedcm/fedcm.json", 14 | "/\\.well-known/fedcm/token", 15 | "/\\.well-known/fedcm/accounts_endpoint", 16 | "/\\.well-known/fedcm/client_metadata_endpoint" 17 | ], 18 | 19 | "handler": { 20 | "@type": "FedcmHttpHandler", 21 | "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, 22 | "webIdStore": { "@id": "urn:solid-server:default:WebIdStore" }, 23 | "cookieStore": { "@id": "urn:solid-server:default:CookieStore" } 24 | } 25 | } 26 | ] 27 | } 28 | 29 | 30 | -------------------------------------------------------------------------------- /config/identity/oidc/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": [ 3 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^7.0.0/components/context.jsonld", 4 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/pivot/^1.0.0/components/context.jsonld" 5 | ], 6 | "@graph": [ 7 | { 8 | "comment": "Routes all OIDC related requests to the OIDC library.", 9 | "@id": "urn:solid-server:default:OidcHandler", 10 | "@type": "RouterHandler", 11 | "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, 12 | "args_targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, 13 | "args_allowedPathNames": [ "^/.oidc/.*", "^/\\.well-known/openid-configuration" ], 14 | "args_handler": { 15 | "@type": "OidcHttpHandler", 16 | "providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" } 17 | } 18 | }, 19 | { 20 | "comment": "Handler for OIDC discovery on subdomains", 21 | "@id": "urn:solid-server:default:SubdomainOidcHandler", 22 | "@type": "RouterHandler", 23 | "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, 24 | "args_targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, 25 | "args_allowedPathNames": [ "/\\.well-known/openid-configuration" ], 26 | "args_handler": { 27 | "@type": "PivotOidcHttpHandler", 28 | "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, 29 | "responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" } 30 | } 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /config/pivot-overrides.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": [ 3 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^7.0.0/components/context.jsonld", 4 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/pivot/^1.0.0/components/context.jsonld" 5 | ], 6 | "@graph": [ 7 | { 8 | "@type": "Override", 9 | "overrideInstance": { "@id": "urn:solid-server:default:PasswordLoginHtml" }, 10 | "overrideParameters": { 11 | "@type" : "HtmlViewEntry", 12 | "comment": "Should we use relative path bellow ? aliases like @fedcm doesn't seems to work", 13 | "filePath": "./templates/identity/password/login.html.ejs", 14 | "route": { "@id": "urn:solid-server:default:LoginPasswordRoute" } 15 | } 16 | }, 17 | { 18 | "@type": "Override", 19 | "overrideInstance": { "@id": "urn:solid-server:default:OidcConsentHtml" }, 20 | "overrideParameters": { 21 | "@type": "HtmlViewEntry", 22 | "filePath": "./templates/identity/oidc/consent.html.ejs", 23 | "route": { "@id": "urn:solid-server:default:OidcConsentRoute" } 24 | } 25 | }, 26 | { 27 | "@type": "Override", 28 | "overrideInstance": { "@id": "urn:solid-server:default:ResponseWriter" }, 29 | "overrideParameters": { 30 | "@type": "PivotResponseWriter", 31 | "metadataWriter": { "@id": "urn:solid-server:default:MetadataWriter" }, 32 | "targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, 33 | "store": { "@id": "urn:solid-server:default:ResourceStore" } 34 | } 35 | }, 36 | { 37 | "@type": "Override", 38 | "overrideInstance": { "@id": "urn:solid-server:default:PasswordLoginHandler" }, 39 | "overrideParameters": { 40 | "@type": "MigratedPasswordLoginHandler", 41 | "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, 42 | "passwordStore": { "@id": "urn:solid-server:default:PasswordStore" }, 43 | "cookieStore": { "@id": "urn:solid-server:default:CookieStore" } 44 | } 45 | }, 46 | { 47 | "@type": "Override", 48 | "overrideInstance": { "@id": "urn:solid-server:default:MainTemplateEngine" }, 49 | "overrideParameters": { 50 | "@type": "StaticTemplateEngine", 51 | "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" }, 52 | "template": "./templates/main.html.ejs" 53 | } 54 | } 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /config/prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "comment": "Copied from https://github.com/SolidOS/css-mashlib/blob/ae21af4685f6c95c1f091cacd952831f272ea119/config/https-mashlib-subdomain-file.json, (1) pivot:config/http/handler/default.json, (2) pivot:config/storage/middleware/default.json, (3) pivot:config/pivot-overrides.json added as the last import", 3 | "@context": [ 4 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^7.0.0/components/context.jsonld", 5 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/pivot/^1.0.0/components/context.jsonld" 6 | ], 7 | "import": [ 8 | "css:config/app/init/default.json", 9 | "css:config/app/main/default.json", 10 | "css:config/app/variables/default.json", 11 | "pivot:config/http/handler/default.json", 12 | "css:config/http/middleware/default.json", 13 | "css:config/http/notifications/all.json", 14 | "css:config/http/server-factory/https.json", 15 | "css:config/http/static/default.json", 16 | "css:config/identity/access/public.json", 17 | "css:config/identity/email/example.json", 18 | "css:config/identity/handler/default.json", 19 | "pivot:config/identity/oidc/default.json", 20 | "css:config/identity/ownership/token.json", 21 | "css:config/identity/pod/static.json", 22 | "css:config/ldp/authentication/dpop-bearer.json", 23 | "css:config/ldp/authorization/webacl.json", 24 | "css:config/ldp/handler/default.json", 25 | "css:config/ldp/metadata-parser/default.json", 26 | "css:config/ldp/metadata-writer/default.json", 27 | "css:config/ldp/modes/default.json", 28 | "css:config/storage/backend/pod-quota-file.json", 29 | "css:config/storage/key-value/resource-store.json", 30 | "css:config/storage/location/pod.json", 31 | "pivot:config/storage/middleware/default.json", 32 | "css:config/util/auxiliary/acl.json", 33 | "css:config/util/identifiers/subdomain.json", 34 | "css:config/util/logging/winston.json", 35 | "css:config/util/representation-conversion/default.json", 36 | "css:config/util/resource-locker/file.json", 37 | "css:config/util/variables/default.json", 38 | "pivot:config/pivot-overrides.json" 39 | ], 40 | "@graph": [ 41 | { 42 | "comment": "Where the WebID is located in the generated pod, relative to the root.", 43 | "@type": "Override", 44 | "overrideInstance": { 45 | "@id": "urn:solid-server:default:PodCreator" 46 | }, 47 | "overrideParameters": { 48 | "@type": "BasePodCreator", 49 | "relativeWebIdPath": "profile/card#me" 50 | } 51 | }, 52 | { 53 | "comment": [ 54 | "A filesystem-based server with Databrowser as UI.", 55 | "Derived from config/file-no-setup.json" 56 | ] 57 | }, 58 | { 59 | "@id": "urn:solid-server:default:CookieStorage", 60 | "@type": "WrappedExpiringStorage", 61 | "timeout": 1 62 | }, 63 | { 64 | "@id": "urn:solid-server:default:ForgotPasswordStorage", 65 | "@type": "WrappedExpiringStorage", 66 | "timeout": 1 67 | }, 68 | { 69 | "@id": "urn:solid-server:default:ExpiringTokenStorage", 70 | "@type": "WrappedExpiringStorage", 71 | "timeout": 1 72 | } 73 | ] 74 | } 75 | -------------------------------------------------------------------------------- /config/storage/backend/file.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": [ 3 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^7.0.0/components/context.jsonld", 4 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/pivot/^1.0.0/components/context.jsonld" 5 | ], 6 | "import": [ 7 | "css:config/storage/backend/data-accessors/file.json" 8 | ], 9 | "@graph": [ 10 | { 11 | "comment": "A default store setup with a file system backend.", 12 | "@id": "urn:solid-server:default:ResourceStore_Backend", 13 | "@type": "DataAccessorBasedStore", 14 | "identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" }, 15 | "auxiliaryStrategy": { "@id": "urn:solid-server:default:AuxiliaryStrategy" }, 16 | "accessor": { "@id": "urn:solid-server:default:FileDataAccessor" }, 17 | "metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /config/storage/backend/memory.json: -------------------------------------------------------------------------------- 1 | { 2 | "comment": "Copied from https://github.com/CommunitySolidServer/CommunitySolidServer/blob/v7.0.0/config/storage/backend/file.json", 3 | "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^7.0.0/components/context.jsonld", 4 | "import": [ 5 | "css:config/storage/backend/data-accessors/memory.json" 6 | ], 7 | "@graph": [ 8 | { 9 | "comment": "A default store setup with a memory backend.", 10 | "@id": "urn:solid-server:default:ResourceStore_Backend", 11 | "@type": "DataAccessorBasedStore", 12 | "identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" }, 13 | "auxiliaryStrategy": { "@id": "urn:solid-server:default:AuxiliaryStrategy" }, 14 | "accessor": { "@id": "urn:solid-server:default:MemoryDataAccessor" }, 15 | "metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /config/storage/middleware/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": [ 3 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^7.0.0/components/context.jsonld", 4 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/pivot/^1.0.0/components/context.jsonld" 5 | ], 6 | "import": [ 7 | "css:config/storage/middleware/stores/converting.json", 8 | "css:config/storage/middleware/stores/locking.json", 9 | "pivot:config/storage/middleware/stores/patching.json" 10 | ], 11 | "@graph": [ 12 | { 13 | "comment": "A cache to prevent duplicate existence checks on resources.", 14 | "@id": "urn:solid-server:default:CachedResourceSet", 15 | "@type": "CachedResourceSet", 16 | "source": { "@id": "urn:solid-server:default:ResourceStore" } 17 | }, 18 | { 19 | "comment": "Sets up a stack of utility stores used by most instances.", 20 | "@id": "urn:solid-server:default:ResourceStore", 21 | "@type": "MonitoringStore", 22 | "source": { "@id": "urn:solid-server:default:ResourceStore_BinarySlice" } 23 | }, 24 | { 25 | "comment": "Slices part of binary streams based on the range preferences.", 26 | "@id": "urn:solid-server:default:ResourceStore_BinarySlice", 27 | "@type": "BinarySliceResourceStore", 28 | "source": { "@id": "urn:solid-server:default:ResourceStore_Index" }, 29 | "defaultSliceSize": 10000000 30 | }, 31 | { 32 | "comment": "When a container with an index.html document is accessed, serve that HTML document instead of the container.", 33 | "@id": "urn:solid-server:default:ResourceStore_Index", 34 | "@type": "IndexRepresentationStore", 35 | "source": { "@id": "urn:solid-server:default:ResourceStore_Locking" } 36 | }, 37 | { 38 | "@id": "urn:solid-server:default:ResourceStore_Locking", 39 | "@type": "LockingResourceStore", 40 | "source": { "@id": "urn:solid-server:default:ResourceStore_RdfPatching" } 41 | }, 42 | { 43 | "@id": "urn:solid-server:default:ResourceStore_RdfPatching", 44 | "@type": "RdfPatchingStore", 45 | "source": { "@id": "urn:solid-server:default:ResourceStore_Converting" } 46 | }, 47 | { 48 | "@id": "urn:solid-server:default:ResourceStore_Converting", 49 | "@type": "RepresentationConvertingStore", 50 | "source": { "@id": "urn:solid-server:default:ResourceStore_Backend" } 51 | } 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /config/storage/middleware/stores/patching.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": [ 3 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^7.0.0/components/context.jsonld", 4 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/pivot/^1.0.0/components/context.jsonld" 5 | ], 6 | "@graph": [ 7 | { 8 | "comment": "Allows for PATCH operations on stores that don't have native support.", 9 | "@id": "urn:solid-server:default:ResourceStore_RdfPatching", 10 | "@type": "RdfPatchingStore", 11 | "patchHandler": { 12 | "@id": "urn:solid-server:default:PatchHandler", 13 | "@type": "RepresentationPatchHandler", 14 | "patcher": { 15 | "@type": "WaterfallHandler", 16 | "handlers": [ 17 | { 18 | "@type": "ConvertingPatcher", 19 | "patcher": { "@id": "urn:solid-server:default:RdfPatcher" }, 20 | "converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, 21 | "intermediateType": "internal/quads", 22 | "defaultType": "text/turtle" 23 | }, 24 | { 25 | "@type": "StaticThrowHandler", 26 | "error": { "@type": "UnsupportedMediaTypeHttpError" } 27 | } 28 | ] 29 | } 30 | } 31 | }, 32 | { 33 | "comment": "Converts the input stream into an RDF/JS Dataset.", 34 | "@id": "urn:solid-server:default:RdfPatcher", 35 | "@type": "RdfPatcher", 36 | "patcher": { "@id": "urn:solid-server:default:PatchHandler_RDFStore" } 37 | }, 38 | { 39 | "@id": "urn:solid-server:default:PatchHandler_RDFStore", 40 | "@type": "WaterfallHandler", 41 | "handlers": [ 42 | { "@id": "urn:solid-server:default:PatchHandler_ImmutableMetadata" }, 43 | { "@id": "urn:solid-server:default:PatchHandler_RDF" } 44 | ] 45 | }, 46 | { 47 | "comment": "Patches metadata resources. Prevents specific triple patterns from being updated.", 48 | "@id": "urn:solid-server:default:PatchHandler_ImmutableMetadata", 49 | "@type": "ImmutableMetadataPatcher", 50 | "patcher": { "@id": "urn:solid-server:default:PatchHandler_RDF" }, 51 | "metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" }, 52 | "immutablePatterns": [ 53 | { 54 | "comment": "The root storage of a Pod is managed by the server.", 55 | "@type": "FilterPattern", 56 | "predicate": "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", 57 | "object": "http://www.w3.org/ns/pim/space#Storage" 58 | }, 59 | { 60 | "comment": "Resource containment is managed by LDP.", 61 | "@type": "FilterPattern", 62 | "predicate": "http://www.w3.org/ns/ldp#contains" 63 | }, 64 | { 65 | "comment": "The size of the resource is managed by the server.", 66 | "@type": "FilterPattern", 67 | "predicate": "http://www.w3.org/ns/posix/stat#size" 68 | }, 69 | { 70 | "comment": "The last modified timestamp of a resource is managed by the server.", 71 | "@type": "FilterPattern", 72 | "predicate": "http://www.w3.org/ns/posix/stat#mtime" 73 | }, 74 | { 75 | "comment": "The last modified datetime of a resource is managed by the server.", 76 | "@type": "FilterPattern", 77 | "predicate": "http://purl.org/dc/terms/modified" 78 | }, 79 | { 80 | "comment": "The content type of a resource is managed by the server.", 81 | "@type": "FilterPattern", 82 | "predicate": "http://www.w3.org/ns/ma-ont#format" 83 | } 84 | ] 85 | }, 86 | { 87 | "comment": "Dedicated handlers that apply specific types of patch documents", 88 | "@id": "urn:solid-server:default:PatchHandler_RDF", 89 | "@type": "WaterfallHandler", 90 | "handlers": [ 91 | { "@type": "ThrowingN3Patcher" }, 92 | { "@type": "SparqlUpdatePatcher" } 93 | ] 94 | } 95 | ] 96 | } 97 | -------------------------------------------------------------------------------- /config/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "comment": "Copied from https://github.com/CommunitySolidServer/CommunitySolidServer/blob/v7.0.0/test/integration/config/ldp-with-auth.json, with (1) pivot:config/storage/middleware/default.json and (2) pivot:config/pivot-overrides.json", 3 | "@context": [ 4 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^7.0.0/components/context.jsonld", 5 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/pivot/^1.0.0/components/context.jsonld" 6 | ], 7 | "import": [ 8 | "css:config/app/init/initialize-root.json", 9 | "css:config/app/main/default.json", 10 | "css:config/http/handler/simple.json", 11 | "css:config/http/middleware/default.json", 12 | "css:config/http/notifications/disabled.json", 13 | "css:config/http/server-factory/http.json", 14 | "css:config/http/static/default.json", 15 | "css:config/identity/access/public.json", 16 | 17 | "css:config/identity/handler/no-accounts.json", 18 | "pivot:config/identity/oidc/default.json", 19 | "css:config/identity/ownership/token.json", 20 | "css:config/identity/pod/static.json", 21 | "css:config/ldp/authentication/debug-auth-header.json", 22 | "css:config/ldp/authorization/webacl.json", 23 | "css:config/ldp/handler/default.json", 24 | "css:config/ldp/metadata-parser/default.json", 25 | "css:config/ldp/metadata-writer/default.json", 26 | "css:config/ldp/modes/default.json", 27 | 28 | "css:config/storage/key-value/memory.json", 29 | "css:config/storage/location/root.json", 30 | "pivot:config/storage/middleware/default.json", 31 | "css:config/util/auxiliary/acl.json", 32 | "css:config/util/identifiers/suffix.json", 33 | "css:config/util/index/default.json", 34 | "css:config/util/logging/winston.json", 35 | "css:config/util/representation-conversion/default.json", 36 | "css:config/util/resource-locker/memory.json", 37 | "css:config/util/variables/default.json", 38 | "pivot:config/pivot-overrides.json" 39 | ], 40 | "@graph": [ 41 | { 42 | "comment": "An HTTP server with only the LDP handler as HttpHandler and an unsecure authenticator.", 43 | "@id": "urn:solid-server:test:Instances", 44 | "@type": "RecordObject", 45 | "record": [ 46 | { 47 | "RecordObject:_record_key": "app", 48 | "RecordObject:_record_value": { "@id": "urn:solid-server:default:App" } 49 | }, 50 | { 51 | "RecordObject:_record_key": "store", 52 | "RecordObject:_record_value": { "@id": "urn:solid-server:default:ResourceStore" } 53 | } 54 | ] 55 | } 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.ts$': [ 'ts-jest', { 4 | tsconfig: 'tsconfig.json', 5 | }], 6 | }, 7 | // Only run tests in the unit and integration folders. 8 | // All test files need to have the suffix `.test.ts`. 9 | testRegex: '/test/(unit|integration)/.*\\.test\\.ts$', 10 | moduleFileExtensions: [ 11 | 'ts', 12 | 'js', 13 | ], 14 | testEnvironment: 'node', 15 | collectCoverage: true, 16 | coverageReporters: [ 'text', 'lcov' ], 17 | coveragePathIgnorePatterns: [ 18 | '/dist/', 19 | '/node_modules/', 20 | '/test/', 21 | ], 22 | // Make sure our tests have enough time to start a server 23 | testTimeout: 60000, 24 | }; 25 | -------------------------------------------------------------------------------- /jest.coverage.config.js: -------------------------------------------------------------------------------- 1 | const jestConfig = require('./jest.config'); 2 | 3 | module.exports = { 4 | ...jestConfig, 5 | coverageThreshold: { 6 | './src': { 7 | branches: 100, 8 | functions: 100, 9 | lines: 100, 10 | statements: 100, 11 | }, 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@solid/pivot", 3 | "version": "1.6.4", 4 | "description": "A module for the Community Solid Server that allows to create containers that do SHACL shape validation.", 5 | "repository": "git@github.com:solid-contrib/pivot.git", 6 | "bugs": { 7 | "url": "https://github.com/solid-contrib/pivot/issues" 8 | }, 9 | "main": "./dist/index.js", 10 | "types": "./dist/index.d.ts", 11 | "lsd:module": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/pivot", 12 | "lsd:components": "dist/components/components.jsonld", 13 | "lsd:contexts": { 14 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/pivot/^1.0.0/components/context.jsonld": "dist/components/context.jsonld" 15 | }, 16 | "lsd:importPaths": { 17 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/pivot/^1.0.0/components/": "dist/components/", 18 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/pivot/^1.0.0/config/": "config/", 19 | "https://linkedsoftwaredependencies.org/bundles/npm/@solid/pivot/^1.0.0/dist/": "dist/" 20 | }, 21 | "scripts": { 22 | "start": "community-solid-server --loggingLevel debug -c config/prod.json ./custom-config.json -f ./data --httpsKey ./key.pem --httpsCert ./cert.pem -p 443 -b https://lolcathost.de -m .", 23 | "staging": "npx community-solid-server -c ./config/prod.json ./custom-config.json -f ./data --httpsKey /etc/letsencrypt/live/pivot.pondersource.com-0001/privkey.pem --httpsCert /etc/letsencrypt/live/pivot.pondersource.com-0001/fullchain.pem -p 443 -b https://pivot.pondersource.com -m .", 24 | "build": "npm run build:ts && npm run build:components", 25 | "build:components": "componentsjs-generator -s src -c dist/components -i .componentsignore -r pivot", 26 | "build:ts": "tsc", 27 | "prepare": "npm run build", 28 | "test": "jest" 29 | }, 30 | "files": [ 31 | "dist", 32 | "config", 33 | "templates", 34 | "www" 35 | ], 36 | "dependencies": { 37 | "@solid/community-server": "^7.1.7", 38 | "mashlib": "^1.10.4", 39 | "rdflib": "^2.2.37" 40 | }, 41 | "devDependencies": { 42 | "@tsconfig/node14": "^14.1.3", 43 | "@types/jest": "^29.5.14", 44 | "@types/node-fetch": "^2.6.12", 45 | "componentsjs-generator": "^3.1.0", 46 | "jest": "^29.7.0", 47 | "jest-rdf": "^2.0.0", 48 | "node-fetch": "^3.3.2", 49 | "ts-jest": "^29.3.1", 50 | "typescript": "^5.8.2" 51 | }, 52 | "license": "MIT" 53 | } 54 | -------------------------------------------------------------------------------- /patch.ttl: -------------------------------------------------------------------------------- 1 | @prefix solid: . 2 | <> a solid:InsertDeletePatch; solid:inserts { . }. 3 | 4 | -------------------------------------------------------------------------------- /src/FedcmHttpHandler.ts: -------------------------------------------------------------------------------- 1 | import { CookieStore } from '@solid/community-server'; 2 | import { HttpHandler } from '@solid/community-server'; 3 | import type { HttpHandlerInput } from '@solid/community-server'; 4 | import { WebIdStore } from '@solid/community-server'; 5 | import { generateDpopKeyPair } from '@inrupt/solid-client-authn-core'; 6 | import { getLoggerFor } from '@solid/community-server'; 7 | import { parse } from 'cookie' 8 | import { readableToString } from '@solid/community-server'; 9 | 10 | 11 | /** 12 | * HTTP handler that handle all FedCM requests. 13 | */ 14 | export class FedcmHttpHandler extends HttpHandler { 15 | protected readonly logger = getLoggerFor(this); 16 | 17 | private readonly baseUrl: string; 18 | private readonly cookieStore: CookieStore; 19 | private readonly webIdStore: WebIdStore; 20 | 21 | public constructor( 22 | baseUrl: string, 23 | cookieStore: CookieStore, 24 | webIdStore: WebIdStore, 25 | ) { 26 | super(); 27 | this.baseUrl = baseUrl.slice(-1) === '/' 28 | ? baseUrl 29 | : `${baseUrl}/`; // TODO check if CSS does it automatically 30 | this.cookieStore = cookieStore 31 | this.webIdStore = webIdStore 32 | } 33 | 34 | private async get_token(id: string, secret: string, dpopHeader: string) { 35 | // A key pair is needed for encryption. 36 | // This function from `solid-client-authn` generates such a pair for you. 37 | const dpopKey = await generateDpopKeyPair(); 38 | 39 | // These are the ID and secret generated in the previous step. 40 | // Both the ID and the secret need to be form-encoded. 41 | const authString = `${encodeURIComponent(id)}:${encodeURIComponent(secret)}`; 42 | // This URL can be found by looking at the "token_endpoint" field at 43 | // http://localhost:3000/.well-known/openid-configuration 44 | // if your server is hosted at http://localhost:3000/. 45 | const tokenUrl = `${this.baseUrl}.oidc/token`; 46 | try { 47 | 48 | const response = await fetch(tokenUrl, { 49 | method: 'POST', 50 | headers: { 51 | // The header needs to be in base64 encoding. 52 | authorization: `Basic ${Buffer.from(authString).toString('base64')}`, 53 | 'content-type': 'application/x-www-form-urlencoded', 54 | dpop: dpopHeader, 55 | }, 56 | body: 'grant_type=client_credentials&scope=webid', 57 | }); 58 | const resp = await response.json(); 59 | return resp 60 | } catch (error) { 61 | this.logger.info(`Error in get_token: ${error}`) 62 | return 63 | } 64 | 65 | // This is the Access token that will be used to do an authenticated request to the server. 66 | // The JSON also contains an "expires_in" field in seconds, 67 | // which you can use to know when you need request a new Access token. 68 | 69 | } 70 | 71 | 72 | private async get_client_id_secret(authorization: string, webId: string) { 73 | 74 | // Now that we are logged in, we need to request the updated controls from the server. 75 | // These will now have more values than in the previous example. 76 | const indexResponse = await fetch(`${this.baseUrl}.account/`, { 77 | headers: { authorization: `CSS-Account-Token ${authorization}` } 78 | }); 79 | let { controls }: any = await indexResponse.json(); 80 | try { 81 | 82 | 83 | // Here we request the server to generate a token on our account 84 | const response = await fetch(controls.account.clientCredentials, { 85 | method: 'POST', 86 | headers: { authorization: `CSS-Account-Token ${authorization}`, 'content-type': 'application/json' }, 87 | // The name field will be used when generating the ID of your token. 88 | // The WebID field determines which WebID you will identify as when using the token. 89 | // Only WebIDs linked to your account can be used. 90 | body: JSON.stringify({ name: 'my-token', webId: webId }), 91 | }); 92 | 93 | 94 | // These are the identifier and secret of your token. 95 | // Store the secret somewhere safe as there is no way to request it again from the server! 96 | // The `resource` value can be used to delete the token at a later point in time. 97 | const { id, secret } : any = await response.json(); 98 | return { tokenId: id, secret: secret } 99 | 100 | } catch (error) { 101 | this.logger.info(`Error in get_token: ${error}`) 102 | return 103 | } 104 | 105 | } 106 | private async deleteToken(tokenId: string, authorization: string) { 107 | const indexResponse = await fetch(`${this.baseUrl}.account/`, { 108 | headers: { authorization: `CSS-Account-Token ${authorization}` } 109 | }); 110 | let { controls }: any = await indexResponse.json(); 111 | const listOfTokensResp = await fetch(controls.account.clientCredentials, { 112 | headers: { authorization: `CSS-Account-Token ${authorization}` } 113 | }) 114 | const listOfTokensJson: any = await listOfTokensResp.json() 115 | const tokenUrl = listOfTokensJson.clientCredentials[tokenId] 116 | const delteTokenResp = await fetch(tokenUrl, { 117 | method: 'DELETE', 118 | headers: { authorization: `CSS-Account-Token ${authorization}`} 119 | }); 120 | } 121 | 122 | public async handle({ request, response }: HttpHandlerInput): Promise { 123 | 124 | if (request.headers['sec-fetch-dest'] !== 'webidentity') { 125 | response.writeHead(400, { 'Content-Type': 'application/json' }); 126 | response.end(JSON.stringify({ error: 'Bad Request: Missing or incorrect Sec-Fetch-Dest header' })); 127 | return; 128 | } 129 | 130 | if (request.url?.startsWith('/.well-known/web-identity')) { 131 | await this.handleWebIdentity({ request, response }); 132 | } else if (request.url?.startsWith('/.well-known/fedcm/fedcm.json')) { 133 | await this.handleFedcmJSON({ request, response }); 134 | } else if (request.url?.startsWith('/.well-known/fedcm/accounts_endpoint')) { 135 | await this.handleAccountsEnpoint({ request, response }); 136 | } else if (request.url?.startsWith('/.well-known/fedcm/client_metadata_endpoint')) { 137 | await this.handleClientMetadataEndpoint({ request, response }); 138 | } else if (request.url?.startsWith('/.well-known/fedcm/token')) { 139 | await this.handleToken({ request, response }); 140 | } else if (request.url?.startsWith('/.well-known/fedcm/disconnect')) { 141 | await this.handleDisconnect({ request, response }); 142 | } else { 143 | response.writeHead(500, { 'Content-Type': 'application/json' }); 144 | response.end(JSON.stringify({ 'error': { 'message': `Fail in FedcmHttpHandler to handle the following request url: ${request.url}` } })); 145 | } 146 | 147 | 148 | } 149 | 150 | 151 | private async handleWebIdentity({ request, response }: HttpHandlerInput): Promise { 152 | // 3.1 153 | // https://fedidcg.github.io/FedCM/#idp-api-well-known 154 | 155 | const providers = [`${this.baseUrl}.well-known/fedcm/fedcm.json`] 156 | response.writeHead(200, { 'Content-Type': 'application/json' }) 157 | response.end(JSON.stringify({ 'provider_urls': providers })) 158 | } 159 | 160 | private async handleFedcmJSON({ request, response }: HttpHandlerInput): Promise { 161 | // 3.2 162 | // 163 | 164 | const config = { 165 | "accounts_endpoint": "/.well-known/fedcm/accounts_endpoint", 166 | "client_metadata_endpoint": "/.well-known/fedcm/client_metadata_endpoint", 167 | "id_assertion_endpoint": "/.well-known/fedcm/token", 168 | "disconnect_endpoint": "/.well-known/fedcm/disconnect", 169 | "revocation_endpoint": ".oidc/token/revocation", 170 | "login_url": "/.account/login/password/", 171 | "branding": { 172 | "background_color": "rgb(255, 055, 255)", 173 | "color": "0xffffff", 174 | "context": `Sign in to CSS`, 175 | "icons": [ 176 | { 177 | "url": `${this.baseUrl}.well-known/css/images/solid.png`, 178 | "size": 32 179 | } 180 | ] 181 | } 182 | } 183 | 184 | response.writeHead(200, { 'Content-Type': 'application/json' }) 185 | response.end(JSON.stringify(config)) 186 | } 187 | 188 | private async handleAccountsEnpoint({ request, response }: HttpHandlerInput): Promise { 189 | // 3.3 190 | // https://fedidcg.github.io/FedCM/#idp-api-accounts-endpoint 191 | 192 | // Upon receiving the request, the server should: 193 | // 1. Verify that the request contains a Sec-Fetch-Dest: webidentity HTTP header. 194 | // 2. Match the session cookies with the IDs of the already signed-in accounts. 195 | // 3. Respond with the list of accounts. 196 | 197 | const cookies = parse(request.headers.cookie || '') 198 | 199 | if (!('css-account' in cookies)) { 200 | response.writeHead(401, { 'Content-Type': 'text/plain' }); 201 | response.end(JSON.stringify({ error: "Missing 'css-account' in request's cookies" })); 202 | return; 203 | } 204 | const cssAccountCookie = cookies['css-account'] 205 | const accountId = await this.cookieStore.get(cssAccountCookie) 206 | // TODO If the user is not signed in, respond with HTTP 401 (Unauthorized). 207 | // find a way to check if the user is signed in 208 | 209 | if (!accountId) { 210 | // TODO Does this necessary mean the user is not signed in ? 211 | response.writeHead(400, { 'Content-Type': 'text/plain' }); 212 | response.end(JSON.stringify({ error: `Could not find an account matching the given cookie (${cssAccountCookie}).` })); 213 | return; 214 | } 215 | 216 | 217 | const accountLinks = await this.webIdStore.findLinks(accountId) 218 | const webId = accountLinks[0].webId || '' // TODO multi webId account 219 | 220 | console.log(`processing FedCM request for ${accountId} / ${webId}`); 221 | 222 | const accounts = { 223 | accounts: [ 224 | { 225 | id: accountId, 226 | name: 'John', // TODO fetch webId's vcard 227 | given_name: 'Doe', // TODO fetch webId's vcard 228 | // email: 'a@a.a', // TODO get user's email ? 229 | email: webId, // giving the webId instead of an email 230 | picture: 'https://doodleipsum.com/150x150/avatar-2?i=f7de8aff0b8c3f4bc758e106d80d071e', // TODO 231 | approved_clients: [] 232 | } 233 | ] 234 | } 235 | response.writeHead(200, { 'Content-Type': 'application/json' }) 236 | 237 | response.end(JSON.stringify(accounts)) 238 | 239 | } 240 | 241 | 242 | private async handleClientMetadataEndpoint({ request, response }: HttpHandlerInput): Promise { 243 | // 3.4 244 | // https://fedidcg.github.io/FedCM/#idp-api-client-id-metadata-endpoint 245 | 246 | const metadata = { 247 | privacy_policy_url: '...', 248 | terms_of_service_url: '...' 249 | }; 250 | response.writeHead(200, { 'Content-Type': 'application/json' }); 251 | response.end(JSON.stringify(metadata)); 252 | } 253 | 254 | private async handleToken({ request, response }: HttpHandlerInput): Promise { 255 | // 3.5 256 | // https://fedidcg.github.io/FedCM/#idp-api-id-assertion-endpoint 257 | 258 | // Upon receiving the request, the server should: 259 | // 1. Verify that the request contains a Sec-Fetch-Dest: webidentity HTTP header. 260 | // 2. Match the Origin header against the RP origin determine by the client_id. Reject if they don't match. 261 | // 3. Match account_id against the ID of the already signed-in account. Reject if they don't match. 262 | // 4. Respond with a token. If the request is rejected, respond with an error response. 263 | // How the token is issued is up to the IdP, but in general, it's signed with information 264 | //such as the account ID, client ID, issuer origin, nonce, so that the RP can verify the token is genuine. 265 | 266 | const r = await readableToString(request) 267 | const client_id = new URLSearchParams(r).get('client_id') || '' 268 | const nonce = new URLSearchParams(r).get('nonce') || undefined 269 | 270 | if (!client_id) { 271 | const error_msg = 'client_id missing from the request\'s body.' 272 | this.logger.info(error_msg) 273 | response.writeHead(400, { 'Content-Type': 'application/json' }) 274 | response.end(JSON.stringify({ 'error': error_msg })) 275 | return 276 | } 277 | 278 | if (!nonce) { 279 | const error_msg = 'Nonce missing. To make FedCM work with Solid-OIDC, you need to pass the DPoP Header through the nonce value in the request.' 280 | this.logger.info(error_msg) 281 | response.writeHead(400, { 'Content-Type': 'application/json' }) 282 | response.end(JSON.stringify({ 'error': error_msg })) 283 | return 284 | } 285 | 286 | const dpopHeader = nonce // This is a hack since FedCM doesn't support DPoP Header, 287 | // we pass it through the nonce, since its an optional feature of FedCM 288 | 289 | 290 | 291 | const cookies = parse(request.headers.cookie || '') 292 | 293 | if (!('css-account' in cookies)) { 294 | const error_msg = 'No CSS cookie found in the request header.' 295 | this.logger.info(error_msg) 296 | response.writeHead(500, { 'Content-Type': 'application/json' }) 297 | response.end(JSON.stringify({ 'error': error_msg })) 298 | return 299 | } 300 | 301 | const cssAccountCookie = cookies['css-account'] 302 | 303 | const accountId = await this.cookieStore.get(cssAccountCookie) 304 | const reqAccountId = new URLSearchParams(r).get('account_id') 305 | 306 | if (!accountId) { 307 | const error_msg = 'no account id find with the given cookie' 308 | this.logger.info(error_msg) 309 | response.writeHead(400, { 'Content-Type': 'application/json' }) 310 | response.end(JSON.stringify({ 'error': error_msg })) 311 | return 312 | } 313 | 314 | if (!reqAccountId) { 315 | const error_msg = 'account_id missing from the request\'s body.' 316 | this.logger.info(error_msg) 317 | response.writeHead(400, { 'Content-Type': 'application/json' }) 318 | response.end(JSON.stringify({ 'error': error_msg })) 319 | return 320 | } 321 | 322 | if (accountId !== reqAccountId) { 323 | const error_msg = 'The account_id from the request\'s body doesn\'t match the account_id binded to the session cookie.' 324 | this.logger.info(error_msg) 325 | response.writeHead(400, { 'Content-Type': 'application/json' }) 326 | response.end(JSON.stringify({ 'error': error_msg })) 327 | return 328 | } 329 | 330 | 331 | 332 | 333 | const accountLinks = await this.webIdStore.findLinks(accountId) 334 | const webId = accountLinks[0].webId // TODO: handle mutiple webId 335 | 336 | 337 | // TODO re-use previous token instead of creating a new 338 | const { tokenId, secret }: any = await this.get_client_id_secret(cssAccountCookie, webId) 339 | const { access_token: accessToken } : any = await this.get_token(tokenId, secret, dpopHeader) 340 | // seems that we can safely delete the tokenId once we have the access token 341 | // then we don't poluate the account with an incremental number of access tokens 342 | await this.deleteToken(tokenId, cssAccountCookie) 343 | 344 | response.writeHead(200, { 'Content-Type': 'application/json' }) 345 | response.end(JSON.stringify({ 'token': accessToken })) 346 | } 347 | 348 | 349 | private async handleDisconnect({ request, response }: HttpHandlerInput): Promise { 350 | // 3.6 351 | // https://fedidcg.github.io/FedCM/#idp-api-disconnect-endpoint 352 | 353 | 354 | // TODO: 355 | // check POST 356 | // has IDP cookies 357 | // has RP origin in header 358 | // in cors mode 359 | // has in body: 360 | // - client id 361 | // - account_hint 362 | 363 | 364 | // get account_id from cookie 365 | // fetch controls with account_id 366 | // call controls.account.logout 367 | const metadata = { 368 | privacy_policy_url: '...', 369 | terms_of_service_url: '...' 370 | }; 371 | response.writeHead(501, { 'Content-Type': 'application/json' }); 372 | response.end(JSON.stringify({ error: "TODO" })); 373 | } 374 | 375 | 376 | } 377 | -------------------------------------------------------------------------------- /src/http/output/PivotResponseWriter.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage } from 'http'; 2 | import { 3 | HttpResponse, 4 | ResponseDescription, 5 | BasicResponseWriter, 6 | MetadataWriter, 7 | TargetExtractor, 8 | HttpRequest, 9 | DataAccessorBasedStore 10 | } from '@solid/community-server'; 11 | 12 | function hasTrailingSlash(input: string): boolean { 13 | return (input.slice(-1) === '/'); 14 | } 15 | 16 | function addTrailingSlash(input: string): string { 17 | return `${input}/`; 18 | } 19 | 20 | export class PivotResponseWriter extends BasicResponseWriter { 21 | private readonly store; 22 | private readonly targetExtractor; 23 | constructor(metadataWriter: MetadataWriter, store: DataAccessorBasedStore, targetExtractor: TargetExtractor) { 24 | super(metadataWriter); 25 | this.store = store; 26 | this.targetExtractor = targetExtractor; 27 | } 28 | public async handle(input: { response: HttpResponse; result: ResponseDescription }): Promise { 29 | try { 30 | if ( 31 | (input.response.req.method === 'GET') && 32 | (typeof input.response.req.url === 'string') && 33 | ([401, 403, 404].indexOf(input.result.statusCode) !== -1) && 34 | (hasTrailingSlash(input.response.req.url) === false)) { 35 | const target = await this.targetExtractor.handleSafe({ request: input.response.req as HttpRequest }); 36 | const withSlash = addTrailingSlash(target.path); 37 | let exists = false; 38 | try { 39 | exists = await this.store.hasResource({ path: withSlash }); 40 | } catch (e) { 41 | // leave as false 42 | } 43 | // console.log('exists', withSlash, exists); 44 | if (exists) { 45 | // console.log('rewriting', input.response.req.method, input.response.req.url, input.result.statusCode); 46 | input.response.statusCode = 301; 47 | input.response.setHeader('Location', withSlash); 48 | input.response.end('Try adding a slash at the end of the URL.\n'); 49 | return; 50 | } 51 | } 52 | } catch (e) { 53 | /* path withSlash do not exist */ 54 | } 55 | return super.handle(input); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/identity/PivotOidcHttpHandler.ts: -------------------------------------------------------------------------------- 1 | import type { HttpHandlerInput, ResponseWriter,RedirectHttpError } from '@solid/community-server'; 2 | import { ResponseDescription, SOLID_HTTP, getLoggerFor, HttpHandler, MovedPermanentlyHttpError } from '@solid/community-server'; 3 | import { DataFactory } from 'n3'; 4 | 5 | class RedirectResponseDescription extends ResponseDescription { 6 | public constructor(error: RedirectHttpError) { 7 | error.metadata.set(SOLID_HTTP.terms.location, DataFactory.namedNode(error.location)); 8 | super(error.statusCode, error.metadata); 9 | } 10 | } 11 | 12 | /** 13 | * HTTP handler that redirects all requests to the OIDC library. 14 | */ 15 | export class PivotOidcHttpHandler extends HttpHandler { 16 | protected readonly logger = getLoggerFor(this); 17 | 18 | public constructor(private readonly baseUrl: string, private readonly responseWriter: ResponseWriter) { 19 | super(); 20 | } 21 | 22 | public async handle({ request, response }: HttpHandlerInput): Promise { 23 | const redirect = `${this.baseUrl}.well-known/openid-configuration` 24 | this.logger.info(`Redirecting ${request.url} to ${redirect}`); 25 | const redirectError = new MovedPermanentlyHttpError(redirect); 26 | const result = new RedirectResponseDescription(redirectError); 27 | await this.responseWriter.handleSafe({ response, result }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/identity/interaction/password/MigratedPasswordLoginHandler.ts: -------------------------------------------------------------------------------- 1 | import { PasswordLoginHandler, JsonView, JsonInteractionHandlerInput, JsonRepresentation, LoginOutputType } from '@solid/community-server'; 2 | 3 | export class MigratedPasswordLoginHandler extends PasswordLoginHandler implements JsonView { 4 | public async login(input: JsonInteractionHandlerInput): Promise> { 5 | const inspect = input as { json: { email: string }}; 6 | if (inspect.json.email.indexOf('@') === -1) { 7 | // user is logging in with an account that was migrated from an NSS account using 8 | // https://github.com/RubenVerborgh/NSS2CSS?tab=readme-ov-file#running-the-script 9 | // this can happen for instance on https://solidcommunity.net, where such a migration 10 | // happened in December 2024. 11 | inspect.json.email += '@users.css.pod'; 12 | } 13 | return super.login(input as JsonInteractionHandlerInput); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./storage/RdfPatchingStore"; 2 | export * from "./storage/patch/ThrowingN3Patcher"; 3 | export * from './FedcmHttpHandler'; 4 | export * from './http/output/PivotResponseWriter'; 5 | export * from './identity/interaction/password/MigratedPasswordLoginHandler'; 6 | export * from './identity/PivotOidcHttpHandler'; 7 | -------------------------------------------------------------------------------- /src/storage/RdfPatchingStore.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Patch, 3 | ResourceIdentifier, 4 | TEXT_TURTLE, 5 | Conditions, 6 | PassthroughStore, 7 | PatchHandler, 8 | ChangeMap, 9 | ResourceStore, 10 | readableToString, 11 | BasicRepresentation, 12 | NotImplementedHttpError, 13 | NotFoundHttpError, 14 | ConflictHttpError, 15 | RepresentationMetadata, 16 | } from '@solid/community-server'; 17 | import { graph, parse, serialize } from 'rdflib'; 18 | import { parsePatchDocument } from './patch/n3-patch-parser'; 19 | import { debug } from '../util/debug'; 20 | 21 | export class PatchRequiresTurtlePreservation {}; 22 | 23 | // Patch parsers by request body content type 24 | const PATCH_PARSERS = { 25 | 'text/n3': parsePatchDocument 26 | }; 27 | 28 | /** 29 | * {@link ResourceStore} using decorator pattern for the `modifyResource` function. 30 | * If the original store supports the {@link Patch}, behaviour will be identical, 31 | * otherwise the {@link PatchHandler} will be called instead. 32 | */ 33 | export class RdfPatchingStore extends PassthroughStore { 34 | private readonly patchHandler: PatchHandler; 35 | protected source: T; 36 | 37 | public constructor(source: T, patchHandler: PatchHandler) { 38 | super(source); 39 | this.source = source; 40 | this.patchHandler = patchHandler; 41 | } 42 | 43 | public async modifyResource( 44 | identifier: ResourceIdentifier, 45 | patch: Patch, 46 | conditions?: Conditions, 47 | ): Promise { 48 | try { 49 | return await this.source.modifyResource(identifier, patch, conditions); 50 | } catch (error: unknown) { 51 | if (NotImplementedHttpError.isInstance(error)) { 52 | try { 53 | const result = await this.patchHandler.handleSafe({ source: this.source, identifier, patch }); 54 | return result; 55 | } catch (nestedError: unknown) { 56 | // console.log('inner error', nestedError); 57 | if (nestedError instanceof PatchRequiresTurtlePreservation) { 58 | return this.modifyResourceUsingRdflib(identifier, patch, conditions); 59 | } 60 | } 61 | } 62 | throw error; 63 | } 64 | } 65 | private async modifyResourceUsingRdflib( 66 | identifier: ResourceIdentifier, 67 | patch: Patch, 68 | conditions?: Conditions, 69 | ): Promise { 70 | const store = graph(); 71 | const patchStr = await readableToString(patch.data); 72 | const resourceUrl = identifier.path; 73 | const resourceSym = store.sym(resourceUrl); 74 | const resourceContentType = TEXT_TURTLE; 75 | let turtle: string; 76 | let metadataOut; 77 | try { 78 | 79 | const representationIn = await this.source.getRepresentation(identifier, { type: { [TEXT_TURTLE]: 1 }}); 80 | turtle = await readableToString(representationIn.data); 81 | metadataOut = representationIn.metadata; 82 | parse(turtle, store, resourceUrl, resourceContentType); 83 | } catch (e) { 84 | if (NotFoundHttpError.isInstance(e)) { 85 | turtle = ''; 86 | metadataOut = new RepresentationMetadata(identifier, TEXT_TURTLE); 87 | } else { 88 | throw e; 89 | } 90 | } 91 | const parsePatch = PATCH_PARSERS['text/n3']; 92 | const patchObject = await parsePatch(resourceUrl, resourceUrl, patchStr); 93 | try { 94 | await new Promise((resolve, reject) => { 95 | (store as any).applyPatch(patchObject, resourceSym, (err: Error | null): void => { 96 | if (err) { 97 | reject(err); 98 | } 99 | resolve(undefined); 100 | }); 101 | }); 102 | } catch (e) { 103 | if (JSON.stringify(e as any).startsWith('\"No match found to be patched')) { 104 | throw new ConflictHttpError('The document does not contain any matches for the N3 Patch solid:where condition.'); 105 | } 106 | if (JSON.stringify(e as any).startsWith('\"Could not find to delete')) { 107 | throw new ConflictHttpError('The document does not contain all triples the N3 Patch requests to delete'); 108 | } 109 | if (JSON.stringify(e as any).startsWith('\"Patch ambiguous. No patch done.')) { 110 | throw new ConflictHttpError('The document contains multiple matches for the N3 Patch solid:where condition'); 111 | } 112 | 113 | throw e; 114 | } 115 | 116 | let serialized: string | undefined = await new Promise((resolve, reject) => { 117 | serialize(resourceSym, store as any, resourceUrl, resourceContentType, (err: Error | null | undefined, result: string | undefined): void => { 118 | if (err) { 119 | reject(err); 120 | } 121 | resolve(result); 122 | }); 123 | }); 124 | if (typeof serialized !== 'string') { 125 | await debug('something went wrong'); 126 | serialized = turtle; 127 | } 128 | const representationOut = new BasicRepresentation(serialized, metadataOut, TEXT_TURTLE); 129 | 130 | const ret = await this.source.setRepresentation(identifier, representationOut); 131 | return ret; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/storage/patch/ThrowingN3Patcher.ts: -------------------------------------------------------------------------------- 1 | 2 | import { N3Patcher } from '@solid/community-server'; 3 | import { PatchRequiresTurtlePreservation } from '../RdfPatchingStore'; 4 | 5 | /** 6 | * To minimize code disruption, this patcher can be 7 | * put at the end of the CSS patcher chain, so that 8 | * the RdfPatchingStore can know that all checks passed 9 | * and it can use for instance RdfLib to apply the patch. 10 | */ 11 | export class ThrowingN3Patcher extends N3Patcher { 12 | public async handle(): Promise { 13 | throw new PatchRequiresTurtlePreservation(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/storage/patch/n3-patch-parser.ts: -------------------------------------------------------------------------------- 1 | import { graph, parse, SPARQLToQuery } from 'rdflib'; 2 | 3 | const PATCH_NS = 'http://www.w3.org/ns/solid/terms#'; 4 | const PREFIXES = `PREFIX solid: <${PATCH_NS}>\n`; 5 | 6 | // Parses the given N3 patch document 7 | export async function parsePatchDocument (targetURI: string, patchURI: string, patchText: string): Promise<{ insert: any, delete: any, where: any }> { 8 | // Parse the N3 document into triples 9 | const patchGraph = graph(); 10 | try { 11 | parse(patchText, patchGraph, patchURI, 'text/n3'); 12 | } catch (err) { 13 | throw new Error(`Patch document syntax error: ${err}`); 14 | } 15 | 16 | // Query the N3 document for insertions and deletions 17 | let firstResult: any; 18 | try { // solid/protocol v0.9.0 19 | firstResult = await queryForFirstResult(patchGraph, `${PREFIXES} 20 | SELECT ?insert ?delete ?where WHERE { 21 | ?patch a solid:InsertDeletePatch. 22 | OPTIONAL { ?patch solid:inserts ?insert. } 23 | OPTIONAL { ?patch solid:deletes ?delete. } 24 | OPTIONAL { ?patch solid:where ?where. } 25 | }`); 26 | } catch (err) { 27 | try { // deprecated, kept for compatibility 28 | firstResult = await queryForFirstResult(patchGraph, `${PREFIXES} 29 | SELECT ?insert ?delete ?where WHERE { 30 | ?patch solid:patches <${targetURI}>. 31 | OPTIONAL { ?patch solid:inserts ?insert. } 32 | OPTIONAL { ?patch solid:deletes ?delete. } 33 | OPTIONAL { ?patch solid:where ?where. } 34 | }`); 35 | } catch (err) { 36 | throw new Error('No n3-patch found.'); 37 | } 38 | } 39 | 40 | // Return the insertions and deletions as an rdflib patch document 41 | const { '?insert': insert, '?delete': deleted, '?where': where } = firstResult; 42 | if (!insert && !deleted) { 43 | throw new Error('Patch should at least contain inserts or deletes.'); 44 | } 45 | return { insert, delete: deleted, where }; 46 | } 47 | 48 | // Queries the store with the given SPARQL query and returns the first result 49 | function queryForFirstResult (store: unknown, sparql: unknown) { 50 | return new Promise((resolve, reject) => { 51 | const query: unknown = SPARQLToQuery(sparql, false, store); 52 | (store as any).query(query, resolve, null, () => reject(new Error('No results.'))); 53 | }); 54 | } -------------------------------------------------------------------------------- /src/util/debug.ts: -------------------------------------------------------------------------------- 1 | import { promises } from 'node:fs'; 2 | 3 | export async function debug(str: string): Promise { 4 | // console.log(`DEBUG: ${str}`); 5 | // await promises.appendFile('debug.txt', `${str}\n`); 6 | } 7 | -------------------------------------------------------------------------------- /templates/identity/oidc/consent.html.ejs: -------------------------------------------------------------------------------- 1 |

An application is requesting full access

2 |

3 |

4 | Do you trust this application 5 | to read and write all your data on your behalf? 6 |

7 |
8 |
9 |
10 | Choose your WebID to authorize 11 |
    12 |
13 |
14 | 15 |
16 |
    17 |
  1. 18 | 19 |
  2. 20 |
21 |
22 | 23 |

24 | 25 | 26 | 27 | 28 |

29 |
30 | 31 | 112 | -------------------------------------------------------------------------------- /templates/identity/password/login.html.ejs: -------------------------------------------------------------------------------- 1 |

Log in

2 |
33 | 34 | 35 | 76 | 77 | -------------------------------------------------------------------------------- /templates/main.html.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | <%= extractTitle(htmlBody) %> 7 | 8 | 9 | 10 | 11 |
12 | [Solid logo] 13 |

Pivot

14 |
15 |
16 | <%- htmlBody %> 17 |
18 | 25 | 26 | 27 | 28 | <% 29 | function extractTitle(body) { 30 | const match = /^]*>([^<]*)<\/h1>/u.exec(body); 31 | return match ? match[1] : 'Solid'; 32 | } 33 | %> 34 | -------------------------------------------------------------------------------- /templates/pod/wac/.acl.hbs: -------------------------------------------------------------------------------- 1 | # Root ACL resource for the agent account 2 | @prefix acl: . 3 | @prefix foaf: . 4 | 5 | # The homepage is readable by the public 6 | <#public> 7 | a acl:Authorization; 8 | acl:agentClass foaf:Agent; 9 | acl:accessTo <./>; 10 | acl:mode acl:Read. 11 | 12 | # The owner has full access to every resource in their pod. 13 | # Other agents have no access rights, 14 | # unless specifically authorized in other .acl resources. 15 | <#owner> 16 | a acl:Authorization; 17 | acl:agent <{{webId}}>; 18 | # Set the access to the root storage folder itself 19 | acl:accessTo <./>; 20 | # All resources will inherit this authorization, by default 21 | acl:default <./>; 22 | # The owner has all of the access modes allowed 23 | acl:mode 24 | acl:Read, acl:Write, acl:Control. 25 | -------------------------------------------------------------------------------- /templates/pod/wac/.meta: -------------------------------------------------------------------------------- 1 | @prefix pim: . 2 | 3 | <> a pim:Storage. 4 | -------------------------------------------------------------------------------- /templates/pod/wac/README$.md.hbs: -------------------------------------------------------------------------------- 1 | # Welcome to your pod 2 | 3 | ## A place to store your data 4 | Your pod is a **secure storage space** for your documents and data. 5 |
6 | You can choose to share those with other people and apps. 7 | 8 | As the owner of this pod, 9 | identified by {{webId}}, 10 | you have access to all of your documents. 11 | 12 | ## Working with your pod 13 | The easiest way to interact with pods 14 | is through Solid apps. 15 |
16 | For example, 17 | you can open your pod in [Databrowser](https://solidos.github.io/mashlib/dist/browse.html?uri={{podBaseUrl}}). 18 | 19 | ## Learn more 20 | The [Solid website](https://solidproject.org/) 21 | and the people on its [forum](https://forum.solidproject.org/) 22 | will be glad to help you on your journey. 23 | -------------------------------------------------------------------------------- /templates/pod/wac/README.acl.hbs: -------------------------------------------------------------------------------- 1 | @prefix acl: . 2 | @prefix foaf: . 3 | 4 | <#public> 5 | a acl:Authorization; 6 | acl:accessTo <./README>; 7 | acl:agentClass foaf:Agent; 8 | acl:mode acl:Read. 9 | 10 | <#owner> 11 | a acl:Authorization; 12 | acl:accessTo <./README>; 13 | acl:agent <{{webId}}>; 14 | acl:mode acl:Read, acl:Write, acl:Control. 15 | -------------------------------------------------------------------------------- /templates/pod/wac/inbox/.acl.hbs: -------------------------------------------------------------------------------- 1 | # ACL resource for the profile Inbox 2 | 3 | @prefix acl: . 4 | @prefix foaf: . 5 | 6 | <#owner> 7 | a acl:Authorization; 8 | acl:agent <{{webId}}>; 9 | acl:accessTo <./>; 10 | acl:default <./>; 11 | acl:mode 12 | acl:Read, acl:Write, acl:Control. 13 | 14 | # Appendable by authenticated, but NOT public-readable 15 | <#authenticated> 16 | a acl:Authorization; 17 | acl:agentClass acl:AuthenticatedAgent; 18 | acl:accessTo <./>; 19 | acl:mode acl:Append. 20 | -------------------------------------------------------------------------------- /templates/pod/wac/profile/.acl.hbs: -------------------------------------------------------------------------------- 1 | @prefix : <#>. 2 | @prefix acl: . 3 | @prefix foaf: . 4 | 5 | :ControlReadWrite 6 | a acl:Authorization; 7 | acl:accessTo <./>; 8 | acl:agent <{{webId}}>; 9 | acl:default <./>; 10 | acl:mode acl:Control, acl:Read, acl:Write. 11 | :Read 12 | a acl:Authorization; 13 | acl:accessTo <./>; 14 | acl:agentClass foaf:Agent; 15 | acl:default <./>; 16 | acl:mode acl:Read. 17 | -------------------------------------------------------------------------------- /templates/pod/wac/profile/card$.ttl.hbs: -------------------------------------------------------------------------------- 1 | @prefix foaf: . 2 | @prefix solid: . 3 | @prefix space: . 4 | @prefix ldp: . 5 | <> 6 | a foaf:PersonalProfileDocument; 7 | foaf:maker <{{webId}}>; 8 | foaf:primaryTopic <{{webId}}>. 9 | 10 | <{{webId}}> 11 | {{#if name}}foaf:name "{{name}}";{{/if}} 12 | space:storage <../>; 13 | ldp:inbox <../inbox/>; 14 | space:preferencesFile <../settings/prefs.ttl>; 15 | solid:privateTypeIndex <../settings/privateTypeIndex.ttl>; 16 | solid:publicTypeIndex <../settings/publicTypeIndex.ttl>; 17 | {{#if oidcIssuer}}solid:oidcIssuer <{{oidcIssuer}}>;{{/if}} 18 | a foaf:Person. 19 | -------------------------------------------------------------------------------- /templates/pod/wac/profile/card.acl.hbs: -------------------------------------------------------------------------------- 1 | # ACL resource for the WebID profile document 2 | @prefix acl: . 3 | @prefix foaf: . 4 | 5 | # The WebID profile is readable by the public. 6 | # This is required for discovery and verification, 7 | # e.g. when checking identity providers. 8 | <#public> 9 | a acl:Authorization; 10 | acl:agentClass foaf:Agent; 11 | acl:accessTo <./card>; 12 | acl:mode acl:Read. 13 | 14 | # The owner has full access to the profile 15 | <#owner> 16 | a acl:Authorization; 17 | acl:agent <{{webId}}>; 18 | acl:accessTo <./card>; 19 | acl:mode acl:Read, acl:Write, acl:Control. 20 | -------------------------------------------------------------------------------- /templates/pod/wac/public/.acl.hbs: -------------------------------------------------------------------------------- 1 | # Root ACL resource for the agent account 2 | @prefix acl: . 3 | @prefix foaf: . 4 | 5 | <#public> 6 | a acl:Authorization; 7 | acl:agentClass foaf:Agent; 8 | acl:accessTo <./>; 9 | acl:default <./>; 10 | acl:mode acl:Read. 11 | 12 | <#owner> 13 | a acl:Authorization; 14 | acl:agent <{{webId}}>; 15 | acl:accessTo <./>; 16 | acl:default <./>; 17 | acl:mode 18 | acl:Read, acl:Write, acl:Control. 19 | -------------------------------------------------------------------------------- /templates/pod/wac/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | # Allow all crawling (subject to ACLs as usual, of course) 3 | Disallow: 4 | -------------------------------------------------------------------------------- /templates/pod/wac/robots.txt.acl.hbs: -------------------------------------------------------------------------------- 1 | # ACL for the default robots.txt resource 2 | # Individual users will be able to override it as they wish 3 | # Public-readable 4 | 5 | @prefix acl: . 6 | @prefix foaf: . 7 | 8 | <#owner> 9 | a acl:Authorization; 10 | acl:agent <{{webId}}>; 11 | acl:accessTo ; 12 | acl:mode acl:Read, acl:Write, acl:Control. 13 | 14 | <#public> 15 | a acl:Authorization; 16 | acl:agentClass foaf:Agent; # everyone 17 | acl:accessTo ; 18 | acl:mode acl:Read. 19 | -------------------------------------------------------------------------------- /templates/pod/wac/settings/.acl.hbs: -------------------------------------------------------------------------------- 1 | @prefix acl: . 2 | @prefix foaf: . 3 | 4 | <#owner> 5 | a acl:Authorization; 6 | acl:agent <{{webId}}>; 7 | acl:accessTo <./>; 8 | acl:default <./>; 9 | acl:mode 10 | acl:Read, acl:Write, acl:Control. 11 | 12 | # Private, no public access modes 13 | -------------------------------------------------------------------------------- /templates/pod/wac/settings/prefs.ttl.hbs: -------------------------------------------------------------------------------- 1 | @prefix dct: . 2 | @prefix pim: . 3 | @prefix foaf: . 4 | @prefix solid: . 5 | 6 | <> 7 | a pim:ConfigurationFile; 8 | 9 | dct:title "Preferences file" . 10 | 11 | {{#if email}}<{{webId}}> foaf:mbox <{{email}}> .{{/if}} 12 | 13 | <{{webId}}> 14 | solid:publicTypeIndex ; 15 | solid:privateTypeIndex . 16 | -------------------------------------------------------------------------------- /templates/pod/wac/settings/privateTypeIndex.ttl.hbs: -------------------------------------------------------------------------------- 1 | @prefix solid: . 2 | <> 3 | a solid:TypeIndex ; 4 | a solid:UnlistedDocument. 5 | -------------------------------------------------------------------------------- /templates/pod/wac/settings/publicTypeIndex.ttl.acl.hbs: -------------------------------------------------------------------------------- 1 | @prefix acl: . 2 | @prefix foaf: . 3 | 4 | # The Public Type Index is readable by the public. 5 | <#public> 6 | a acl:Authorization; 7 | acl:agentClass foaf:Agent; 8 | acl:accessTo <./publicTypeIndex.ttl>; 9 | acl:mode acl:Read. 10 | 11 | # The owner has full access to the profile 12 | <#owner> 13 | a acl:Authorization; 14 | acl:agent <{{webId}}>; 15 | acl:accessTo <./publicTypeIndex.ttl>; 16 | acl:mode acl:Read, acl:Write, acl:Control. 17 | -------------------------------------------------------------------------------- /templates/pod/wac/settings/publicTypeIndex.ttl.hbs: -------------------------------------------------------------------------------- 1 | @prefix solid: . 2 | <> 3 | a solid:TypeIndex ; 4 | a solid:ListedDocument. 5 | -------------------------------------------------------------------------------- /test/integration/Cli.test.ts: -------------------------------------------------------------------------------- 1 | import type { CliResolver } from '@solid/community-server'; 2 | import { resolveModulePath } from '@solid/community-server'; 3 | import { instantiateFromConfig } from './Config'; 4 | 5 | // Needed to prevent yargs from stopping the process on error 6 | const error = jest.spyOn(console, 'error').mockImplementation(jest.fn()); 7 | const exit = jest.spyOn(process, 'exit').mockImplementation(jest.fn() as any); 8 | 9 | describe('An instantiated CliResolver', (): void => { 10 | let cliResolver: CliResolver; 11 | 12 | beforeAll(async(): Promise => { 13 | // Create the CliExtractor 14 | cliResolver = await instantiateFromConfig( 15 | 'urn:solid-server-app-setup:default:CliResolver', 16 | resolveModulePath('config/default.json'), // this loads node_module/@solid/community-server/config/default.json 17 | ); 18 | }); 19 | 20 | it('converts known abbreviations to the full parameter.', async(): Promise => { 21 | /* eslint-disable antfu/consistent-list-newline */ 22 | const shorthand = await cliResolver.cliExtractor.handleSafe([ 'node', 'server.js', 23 | '-c', 'c', 24 | '-m', 'm', 25 | '-l', 'l', 26 | '-b', 'b', 27 | '-p', '3000', 28 | '-f', 'f', 29 | '-t', 30 | '-s', 's', 31 | '-w', '2', 32 | ]); 33 | /* eslint-enable antfu/consistent-list-newline */ 34 | expect(shorthand.config).toEqual([ 'c' ]); 35 | expect(shorthand.mainModulePath).toBe('m'); 36 | expect(shorthand.loggingLevel).toBe('l'); 37 | expect(shorthand.baseUrl).toBe('b'); 38 | expect(shorthand.port).toBe(3000); 39 | expect(shorthand.rootFilePath).toBe('f'); 40 | expect(shorthand.showStackTrace).toBe(true); 41 | expect(shorthand.sparqlEndpoint).toBe('s'); 42 | expect(shorthand.workers).toBe(2); 43 | }); 44 | 45 | it('errors on unknown parameters.', async(): Promise => { 46 | await cliResolver.cliExtractor.handleSafe([ 'node', 'server.js', '-a', 'abc' ]); 47 | 48 | expect(exit).toHaveBeenCalledTimes(1); 49 | expect(error).toHaveBeenCalledWith('Unknown argument: a'); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /test/integration/Config.ts: -------------------------------------------------------------------------------- 1 | import type { IModuleState } from 'componentsjs'; 2 | import { ComponentsManager } from 'componentsjs'; 3 | import { remove } from 'fs-extra'; 4 | import { joinFilePath } from '@solid/community-server'; 5 | 6 | let cachedModuleState: IModuleState; 7 | 8 | /** 9 | * Returns a component instantiated from a Components.js configuration. 10 | */ 11 | export async function instantiateFromConfig( 12 | componentUrl: string, 13 | configPaths: string | string[], 14 | variables?: Record, 15 | ): Promise { 16 | // Initialize the Components.js loader 17 | const mainModulePath = joinFilePath(__dirname, '../../'); 18 | const manager = await ComponentsManager.build({ 19 | mainModulePath, 20 | logLevel: 'error', 21 | moduleState: cachedModuleState, 22 | typeChecking: false, 23 | }); 24 | cachedModuleState = manager.moduleState; 25 | 26 | if (!Array.isArray(configPaths)) { 27 | configPaths = [ configPaths ]; 28 | } 29 | 30 | // Instantiate the component from the config(s) 31 | for (const configPath of configPaths) { 32 | await manager.configRegistry.register(configPath); 33 | } 34 | return manager.instantiate(componentUrl, { variables }); 35 | } 36 | 37 | export function getTestConfigPath(configFile: string): string { 38 | return joinFilePath(__dirname, 'config', configFile); 39 | } 40 | 41 | export function getPresetConfigPath(configFile: string): string { 42 | return joinFilePath(__dirname, '../../config', configFile); 43 | } 44 | 45 | export function getTestFolder(name: string): string { 46 | return joinFilePath(__dirname, '../tmp', name); 47 | } 48 | 49 | export async function removeFolder(folder: string): Promise { 50 | await remove(folder); 51 | } 52 | 53 | export function getDefaultVariables(port: number, baseUrl?: string): Record { 54 | return { 55 | 'urn:solid-server:default:variable:baseUrl': baseUrl ?? `http://localhost:${port}/`, 56 | 'urn:solid-server:default:variable:port': port, 57 | 'urn:solid-server:default:variable:socket': null, 58 | 'urn:solid-server:default:variable:loggingLevel': 'off', 59 | 'urn:solid-server:default:variable:showStackTrace': true, 60 | 'urn:solid-server:default:variable:seedConfig': null, 61 | 'urn:solid-server:default:variable:workers': 1, 62 | 'urn:solid-server:default:variable:confirmMigration': false, 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /test/integration/N3Patch.test.ts: -------------------------------------------------------------------------------- 1 | import 'jest-rdf'; 2 | import { fetch } from 'cross-fetch'; 3 | import { Parser } from 'n3'; 4 | import { 5 | AclPermissionSet, 6 | BasicRepresentation, 7 | App, 8 | ResourceStore, 9 | joinUrl, 10 | } from '@solid/community-server'; 11 | import { AclHelper } from '../util/AclHelper'; 12 | import { getPort } from '../util/Util'; 13 | import { 14 | getDefaultVariables, 15 | getPresetConfigPath, 16 | instantiateFromConfig, 17 | } from './Config'; 18 | 19 | const port = getPort('N3Patch'); 20 | const baseUrl = `http://localhost:${port}/`; 21 | 22 | let store: ResourceStore; 23 | let aclHelper: AclHelper; 24 | 25 | async function expectPatch( 26 | input: { path: string; contentType?: string; body: string }, 27 | expected: { status: number; message?: string; turtle?: string }, 28 | respectTurtle?: boolean 29 | ): Promise { 30 | const message = expected.message ?? ''; 31 | const contentType = input.contentType ?? 'text/n3'; 32 | 33 | const body = `@prefix solid: . 34 | ${input.body}`; 35 | 36 | const url = joinUrl(baseUrl, input.path); 37 | const res = await fetch(url, { 38 | method: 'PATCH', 39 | headers: { 'content-type': contentType }, 40 | body, 41 | }); 42 | await expect(res.text()).resolves.toContain(message); 43 | expect(res.status).toBe(expected.status); 44 | 45 | // Verify if the resource has the expected RDF data 46 | if (expected.turtle) { 47 | // Might not have read permissions so need to update 48 | await aclHelper.setSimpleAcl(url, { permissions: { read: true }, agentClass: 'agent', accessTo: true }); 49 | const get = await fetch(url, { 50 | method: 'GET', 51 | headers: { accept: 'text/turtle' }, 52 | }); 53 | const expectedTurtle = `@prefix solid: . 54 | ${expected.turtle}`; 55 | 56 | expect(get.status).toBe(200); 57 | const parser = new Parser({ format: 'text/turtle', baseIRI: url }); 58 | const actualText = await get.text(); 59 | const actualTriples = parser.parse(actualText); 60 | expect(actualTriples).toBeRdfIsomorphic(parser.parse(expectedTurtle)); 61 | if (respectTurtle === true) { 62 | expect(actualText).toEqual(expected.turtle); 63 | } 64 | } 65 | } 66 | 67 | // Creates/updates a resource with the given data and permissions 68 | async function setResource(path: string, turtle: string, permissions: AclPermissionSet): Promise { 69 | const url = joinUrl(baseUrl, path); 70 | await store.setRepresentation({ path: url }, new BasicRepresentation(turtle, 'text/turtle')); 71 | await aclHelper.setSimpleAcl(url, { permissions, agentClass: 'agent', accessTo: true }); 72 | } 73 | 74 | describe('A Server supporting N3 Patch', (): void => { 75 | let app: App; 76 | 77 | beforeAll(async(): Promise => { 78 | // Create and start the server 79 | const instances = await instantiateFromConfig( 80 | 'urn:solid-server:test:Instances', 81 | [ 82 | getPresetConfigPath('storage/backend/memory.json'), 83 | getPresetConfigPath('test.json'), 84 | ], 85 | getDefaultVariables(port, baseUrl), 86 | ) as Record; 87 | ({ app, store } = instances); 88 | 89 | await app.start(); 90 | 91 | // Create test helper for manipulating acl 92 | aclHelper = new AclHelper(store); 93 | }); 94 | 95 | afterAll(async(): Promise => { 96 | await app.stop(); 97 | }); 98 | 99 | describe('with an invalid patch document', (): void => { 100 | it('requires text/n3 content-type.', async(): Promise => { 101 | await expectPatch( 102 | { path: '/invalid', contentType: 'text/other', body: '' }, 103 | { status: 415 }, 104 | ); 105 | }); 106 | 107 | it('requires valid syntax.', async(): Promise => { 108 | await expectPatch( 109 | { path: '/invalid', body: 'invalid syntax' }, 110 | { status: 400, message: 'Invalid N3' }, 111 | ); 112 | }); 113 | 114 | it('requires a solid:InsertDeletePatch.', async(): Promise => { 115 | await expectPatch( 116 | { path: '/invalid', body: '<> a solid:Patch.' }, 117 | { 118 | status: 422, 119 | message: 'This patcher only supports N3 Patch documents with exactly 1 solid:InsertDeletePatch entry', 120 | }, 121 | ); 122 | }); 123 | }); 124 | 125 | describe('inserting data', (): void => { 126 | it('succeeds if there is no resource.', async(): Promise => { 127 | await expectPatch( 128 | { path: '/new-insert', body: `<> a solid:InsertDeletePatch; solid:inserts { . }.` }, 129 | { status: 201, turtle: ' .' }, 130 | ); 131 | }); 132 | 133 | it('fails if there is only read access.', async(): Promise => { 134 | await setResource('/read-only', ' .', { read: true }); 135 | await expectPatch( 136 | { path: '/read-only', body: `<> a solid:InsertDeletePatch; solid:inserts { . }.` }, 137 | { status: 401 }, 138 | ); 139 | }); 140 | 141 | it('succeeds if there is only append access.', async(): Promise => { 142 | await setResource('/append-only', ' .', { append: true }); 143 | await expectPatch( 144 | { path: '/append-only', body: `<> a solid:InsertDeletePatch; solid:inserts { . }.` }, 145 | { status: 205, turtle: ' . .' }, 146 | ); 147 | }); 148 | 149 | it('succeeds if there is only write access.', async(): Promise => { 150 | await setResource('/write-only', ' .', { write: true }); 151 | await expectPatch( 152 | { path: '/write-only', body: `<> a solid:InsertDeletePatch; solid:inserts { . }.` }, 153 | { status: 205, turtle: ' . .' }, 154 | ); 155 | }); 156 | 157 | it('Respects existing Turtle lists.', async(): Promise => { 158 | await setResource('/write-only', ' ( ).', { write: true }); 159 | await expectPatch( 160 | { path: '/write-only', body: `<> a solid:InsertDeletePatch; solid:inserts { . }.` }, 161 | { status: 205, turtle: `@prefix : .\n@prefix loc: .\n\nloc:a loc:b ( loc:c loc:d ).\n\nloc:x loc:y loc:z.\n\n` }, 162 | true, 163 | ); 164 | }); 165 | }); 166 | 167 | describe('inserting conditional data', (): void => { 168 | it('fails if there is no resource.', async(): Promise => { 169 | await expectPatch( 170 | { path: '/new-insert-where', body: `<> a solid:InsertDeletePatch; 171 | solid:inserts { ?a . }; 172 | solid:where { ?a . }.` }, 173 | { status: 409, message: 'The document does not contain any matches for the N3 Patch solid:where condition.' }, 174 | ); 175 | }); 176 | 177 | it('fails if there is only read access.', async(): Promise => { 178 | await setResource('/read-only', ' .', { read: true }); 179 | await expectPatch( 180 | { path: '/read-only', body: `<> a solid:InsertDeletePatch; 181 | solid:inserts { ?a . }; 182 | solid:where { ?a . }.` }, 183 | { status: 401 }, 184 | ); 185 | }); 186 | 187 | it('fails if there is only append access.', async(): Promise => { 188 | await setResource('/append-only', ' .', { append: true }); 189 | await expectPatch( 190 | { path: '/append-only', body: `<> a solid:InsertDeletePatch; 191 | solid:inserts { ?a . }; 192 | solid:where { ?a . }.` }, 193 | { status: 401 }, 194 | ); 195 | }); 196 | 197 | it('fails if there is only write access.', async(): Promise => { 198 | await setResource('/write-only', ' .', { write: true }); 199 | await expectPatch( 200 | { path: '/write-only', body: `<> a solid:InsertDeletePatch; 201 | solid:inserts { ?a . }; 202 | solid:where { ?a . }.` }, 203 | { status: 401 }, 204 | ); 205 | }); 206 | 207 | describe('with read/append access', (): void => { 208 | it('succeeds if the conditions match.', async(): Promise => { 209 | await setResource('/read-append', ' .', { read: true, append: true }); 210 | await expectPatch( 211 | { path: '/read-append', body: `<> a solid:InsertDeletePatch; 212 | solid:inserts { ?a . }; 213 | solid:where { ?a . }.` }, 214 | { status: 205, turtle: ' . .' }, 215 | ); 216 | }); 217 | 218 | it('rejects if there is no match.', async(): Promise => { 219 | await setResource('/read-append', ' .', { read: true, append: true }); 220 | await expectPatch( 221 | { path: '/read-append', body: `<> a solid:InsertDeletePatch; 222 | solid:inserts { ?a . }; 223 | solid:where { ?a . }.` }, 224 | { status: 409, message: 'The document does not contain any matches for the N3 Patch solid:where condition.' }, 225 | ); 226 | }); 227 | 228 | it('rejects if there are multiple matches.', async(): Promise => { 229 | await setResource('/read-append', ' . .', { read: true, append: true }); 230 | await expectPatch( 231 | { path: '/read-append', body: `<> a solid:InsertDeletePatch; 232 | solid:inserts { ?a . }; 233 | solid:where { ?a . }.` }, 234 | { status: 409, message: 'The document contains multiple matches for the N3 Patch solid:where condition' }, 235 | ); 236 | }); 237 | }); 238 | 239 | describe('with read/write access', (): void => { 240 | it('succeeds if the conditions match.', async(): Promise => { 241 | await setResource('/read-write', ' .', { read: true, write: true }); 242 | await expectPatch( 243 | { path: '/read-write', body: `<> a solid:InsertDeletePatch; 244 | solid:inserts { ?a . }; 245 | solid:where { ?a . }.` }, 246 | { status: 205, turtle: ' . .' }, 247 | ); 248 | }); 249 | }); 250 | }); 251 | 252 | describe('deleting data', (): void => { 253 | it('fails if there is no resource.', async(): Promise => { 254 | await expectPatch( 255 | { path: '/new-delete', body: `<> a solid:InsertDeletePatch; 256 | solid:deletes { . }.` }, 257 | { status: 409, message: 'The document does not contain all triples the N3 Patch requests to delete' }, 258 | ); 259 | }); 260 | 261 | it('fails if there is only append access.', async(): Promise => { 262 | await setResource('/append-only', ' .', { append: true }); 263 | await expectPatch( 264 | { path: '/append-only', body: `<> a solid:InsertDeletePatch; 265 | solid:deletes { . }.` }, 266 | { status: 401 }, 267 | ); 268 | }); 269 | 270 | it('fails if there is only write access.', async(): Promise => { 271 | await setResource('/write-only', ' .', { write: true }); 272 | await expectPatch( 273 | { path: '/write-only', body: `<> a solid:InsertDeletePatch; 274 | solid:deletes { . }.` }, 275 | { status: 401 }, 276 | ); 277 | }); 278 | 279 | it('fails if there is only read/append access.', async(): Promise => { 280 | await setResource('/read-append', ' .', { read: true, append: true }); 281 | await expectPatch( 282 | { path: '/read-append', body: `<> a solid:InsertDeletePatch; 283 | solid:deletes { . }.` }, 284 | { status: 401 }, 285 | ); 286 | }); 287 | 288 | describe('with read/write access', (): void => { 289 | it('succeeds if the delete triples exist.', async(): Promise => { 290 | await setResource('/read-write', ' . .', { read: true, write: true }); 291 | await expectPatch( 292 | { path: '/read-write', body: `<> a solid:InsertDeletePatch; 293 | solid:deletes { . }.` }, 294 | { status: 205, turtle: ' .' }, 295 | ); 296 | }); 297 | 298 | it('fails if the delete triples do not exist.', async(): Promise => { 299 | await setResource('/read-write', ' . .', { read: true, write: true }); 300 | await expectPatch( 301 | { path: '/read-write', body: `<> a solid:InsertDeletePatch; 302 | solid:deletes { . }.` }, 303 | { status: 409, message: 'The document does not contain all triples the N3 Patch requests to delete' }, 304 | ); 305 | }); 306 | 307 | it('succeeds if the conditions match.', async(): Promise => { 308 | await setResource('/read-write', ' . .', { read: true, write: true }); 309 | await expectPatch( 310 | { path: '/read-write', body: `<> a solid:InsertDeletePatch; 311 | solid:where { ?a . }; 312 | solid:deletes { ?a . }.` }, 313 | { status: 205, turtle: ' .' }, 314 | ); 315 | }); 316 | 317 | it('fails if the conditions do not match.', async(): Promise => { 318 | await setResource('/read-write', ' .', { read: true, write: true }); 319 | await expectPatch( 320 | { path: '/read-write', body: `<> a solid:InsertDeletePatch; 321 | solid:where { ?a . }; 322 | solid:deletes { ?a . }.` }, 323 | { status: 409, message: 'The document does not contain any matches for the N3 Patch solid:where condition.' }, 324 | ); 325 | }); 326 | }); 327 | }); 328 | 329 | describe('deleting and inserting data', (): void => { 330 | it('fails if there is no resource.', async(): Promise => { 331 | await expectPatch( 332 | { path: '/new-delete-insert', body: `<> a solid:InsertDeletePatch; 333 | solid:inserts { . }; 334 | solid:deletes { . }.` }, 335 | { status: 409, message: 'The document does not contain all triples the N3 Patch requests to delete' }, 336 | ); 337 | }); 338 | 339 | it('fails if there is only append access.', async(): Promise => { 340 | await setResource('/append-only', ' .', { append: true }); 341 | await expectPatch( 342 | { path: '/append-only', body: `<> a solid:InsertDeletePatch; 343 | solid:inserts { . }; 344 | solid:deletes { . }.` }, 345 | { status: 401 }, 346 | ); 347 | }); 348 | 349 | it('fails if there is only write access.', async(): Promise => { 350 | await setResource('/write-only', ' .', { write: true }); 351 | await expectPatch( 352 | { path: '/write-only', body: `<> a solid:InsertDeletePatch; 353 | solid:inserts { . }; 354 | solid:deletes { . }.` }, 355 | { status: 401 }, 356 | ); 357 | }); 358 | 359 | it('fails if there is only read/append access.', async(): Promise => { 360 | await setResource('/read-append', ' .', { read: true, append: true }); 361 | await expectPatch( 362 | { path: '/read-append', body: `<> a solid:InsertDeletePatch; 363 | solid:inserts { . }; 364 | solid:deletes { . }.` }, 365 | { status: 401 }, 366 | ); 367 | }); 368 | 369 | describe('with read/write access', (): void => { 370 | it('executes deletes before inserts.', async(): Promise => { 371 | await setResource('/read-write', ' .', { read: true, write: true }); 372 | await expectPatch( 373 | { path: '/read-write', body: `<> a solid:InsertDeletePatch; 374 | solid:inserts { . }; 375 | solid:deletes { . }.` }, 376 | { status: 409, message: 'The document does not contain all triples the N3 Patch requests to delete' }, 377 | ); 378 | }); 379 | 380 | it('succeeds if the delete triples exist.', async(): Promise => { 381 | await setResource('/read-write', ' .', { read: true, write: true }); 382 | await expectPatch( 383 | { path: '/read-write', body: `<> a solid:InsertDeletePatch; 384 | solid:inserts { . }; 385 | solid:deletes { . }.` }, 386 | { status: 205, turtle: ' .' }, 387 | ); 388 | }); 389 | 390 | it('succeeds if the conditions match.', async(): Promise => { 391 | await setResource('/read-write', ' .', { read: true, write: true }); 392 | await expectPatch( 393 | { path: '/read-write', body: `<> a solid:InsertDeletePatch; 394 | solid:where { ?a . }; 395 | solid:inserts { ?a . }; 396 | solid:deletes { ?a . }.` }, 397 | { status: 205, turtle: ' .' }, 398 | ); 399 | }); 400 | 401 | it('fails if the conditions do not match.', async(): Promise => { 402 | await setResource('/read-write', ' .', { read: true, write: true }); 403 | await expectPatch( 404 | { path: '/read-write', body: `<> a solid:InsertDeletePatch; 405 | solid:where { ?a . }; 406 | solid:inserts { ?a . }; 407 | solid:deletes { ?a . }.` }, 408 | { status: 409, message: 'The document does not contain any matches for the N3 Patch solid:where condition.' }, 409 | ); 410 | }); 411 | }); 412 | }); 413 | }); 414 | -------------------------------------------------------------------------------- /test/unit/storage/RdfPatchingStore.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Patch, 3 | PatchHandler, 4 | ResourceStore, 5 | NotImplementedHttpError, 6 | } from '@solid/community-server'; 7 | import { RdfPatchingStore } from '../../../src/storage/RdfPatchingStore'; 8 | 9 | describe('An RdfPatchingStore', (): void => { 10 | let store: RdfPatchingStore; 11 | let source: jest.Mocked; 12 | let patcher: PatchHandler; 13 | let handleSafeFn: jest.Mock, []>; 14 | 15 | beforeEach(async(): Promise => { 16 | source = { 17 | modifyResource: jest.fn(async(): Promise => 'modify'), 18 | } satisfies Partial as any; 19 | 20 | handleSafeFn = jest.fn(async(): Promise => 'patcher'); 21 | patcher = { handleSafe: handleSafeFn } as unknown as PatchHandler; 22 | 23 | store = new RdfPatchingStore(source, patcher); 24 | }); 25 | 26 | it('calls modifyResource directly from the source if available.', async(): Promise => { 27 | await expect(store.modifyResource({ path: 'modifyPath' }, {} as Patch)).resolves.toBe('modify'); 28 | expect(source.modifyResource).toHaveBeenCalledTimes(1); 29 | expect(source.modifyResource).toHaveBeenLastCalledWith({ path: 'modifyPath' }, {}, undefined); 30 | }); 31 | 32 | it('calls its patcher if modifyResource is not implemented.', async(): Promise => { 33 | jest.spyOn(source, 'modifyResource').mockImplementation(async(): Promise => { 34 | throw new NotImplementedHttpError(); 35 | }); 36 | await expect(store.modifyResource({ path: 'modifyPath' }, {} as Patch)).resolves.toBe('patcher'); 37 | expect(source.modifyResource).toHaveBeenCalledTimes(1); 38 | expect(source.modifyResource).toHaveBeenLastCalledWith({ path: 'modifyPath' }, {}, undefined); 39 | await expect(source.modifyResource.mock.results[0].value).rejects.toThrow(NotImplementedHttpError); 40 | expect(handleSafeFn).toHaveBeenCalledTimes(1); 41 | expect(handleSafeFn).toHaveBeenLastCalledWith({ source, identifier: { path: 'modifyPath' }, patch: {}}); 42 | }); 43 | 44 | it('rethrows source modifyResource errors.', async(): Promise => { 45 | jest.spyOn(source, 'modifyResource').mockImplementation(async(): Promise => { 46 | throw new Error('dummy'); 47 | }); 48 | await expect(store.modifyResource({ path: 'modifyPath' }, {} as Patch)).rejects.toThrow('dummy'); 49 | expect(source.modifyResource).toHaveBeenCalledTimes(1); 50 | expect(source.modifyResource).toHaveBeenLastCalledWith({ path: 'modifyPath' }, {}, undefined); 51 | expect(handleSafeFn).toHaveBeenCalledTimes(0); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /test/unit/util/HeaderUtil.test.ts: -------------------------------------------------------------------------------- 1 | import { HttpResponse, BadRequestHttpError } from '@solid/community-server'; 2 | import { ContentType } from '../../../src/util/Header'; 3 | import { 4 | addHeader, 5 | hasScheme, 6 | matchesAuthorizationScheme, 7 | parseAccept, 8 | parseAcceptCharset, 9 | parseAcceptDateTime, 10 | parseAcceptEncoding, 11 | parseAcceptLanguage, 12 | parseContentType, 13 | parseForwarded, 14 | parseLinkHeader, 15 | } from '../../../src/util/HeaderUtil'; 16 | 17 | describe('HeaderUtil', (): void => { 18 | describe('#parseAccept', (): void => { 19 | it('parses empty Accept headers.', async(): Promise => { 20 | expect(parseAccept('')).toEqual([]); 21 | }); 22 | 23 | it('parses Accept headers with a single entry.', async(): Promise => { 24 | expect(parseAccept('audio/basic')).toEqual([ 25 | { range: 'audio/basic', weight: 1, parameters: { mediaType: {}, extension: {}}}, 26 | ]); 27 | }); 28 | 29 | it('parses Accept headers with multiple entries.', async(): Promise => { 30 | expect(parseAccept('audio/*; q=0.2, audio/basic')).toEqual([ 31 | { range: 'audio/basic', weight: 1, parameters: { mediaType: {}, extension: {}}}, 32 | { range: 'audio/*', weight: 0.2, parameters: { mediaType: {}, extension: {}}}, 33 | ]); 34 | }); 35 | 36 | it('parses complex Accept headers.', async(): Promise => { 37 | expect(parseAccept( 38 | 'text/html;q=0.7, text/html;level=1, text/html;level=2;q=0.4,text/x-dvi; q=0.8; mxb=100000; mxt', 39 | )).toEqual([ 40 | { range: 'text/html', weight: 1, parameters: { mediaType: { level: '1' }, extension: {}}}, 41 | { range: 'text/x-dvi', weight: 0.8, parameters: { mediaType: {}, extension: { mxb: '100000', mxt: '' }}}, 42 | { range: 'text/html', weight: 0.7, parameters: { mediaType: {}, extension: {}}}, 43 | { range: 'text/html', weight: 0.4, parameters: { mediaType: { level: '2' }, extension: {}}}, 44 | ]); 45 | }); 46 | 47 | it('parses Accept headers with double quoted values.', async(): Promise => { 48 | expect(parseAccept('audio/basic; param1="val" ; q=0.5 ;param2="\\\\\\"valid"')).toEqual([ 49 | { 50 | range: 'audio/basic', 51 | weight: 0.5, 52 | parameters: { mediaType: { param1: 'val' }, extension: { param2: '\\\\\\"valid' }}, 53 | }, 54 | ]); 55 | }); 56 | 57 | it('ignores Accept Headers with invalid types.', async(): Promise => { 58 | expect(parseAccept('*')).toEqual([]); 59 | expect(parseAccept('"bad"/text')).toEqual([]); 60 | expect(parseAccept('*/\\bad')).toEqual([]); 61 | expect(parseAccept('*/*')).toEqual([ 62 | { parameters: { extension: {}, mediaType: {}}, range: '*/*', weight: 1 }, 63 | ]); 64 | }); 65 | 66 | it('ignores the weight of Accept Headers with q values it can not parse.', async(): Promise => { 67 | expect(parseAccept('a/b; q=text')).toEqual([ 68 | { range: 'a/b', weight: 1, parameters: { extension: {}, mediaType: {}}}, 69 | ]); 70 | // Invalid Q value but can be parsed 71 | expect(parseAccept('a/b; q=0.1234')).toEqual([ 72 | { range: 'a/b', weight: 0.1234, parameters: { extension: {}, mediaType: {}}}, 73 | ]); 74 | expect(parseAccept('a/b; q=1.1')).toEqual([ 75 | { range: 'a/b', weight: 1, parameters: { extension: {}, mediaType: {}}}, 76 | ]); 77 | expect(parseAccept('a/b; q=1.000')).toEqual([ 78 | { range: 'a/b', weight: 1, parameters: { extension: {}, mediaType: {}}}, 79 | ]); 80 | expect(parseAccept('a/b; q=-5')).toEqual([ 81 | { range: 'a/b', weight: 0, parameters: { extension: {}, mediaType: {}}}, 82 | ]); 83 | expect(parseAccept('a/b; q=0.123')).toEqual([ 84 | { range: 'a/b', weight: 0.123, parameters: { extension: {}, mediaType: {}}}, 85 | ]); 86 | }); 87 | 88 | it('ignores Accept Headers with invalid parameters.', async(): Promise => { 89 | expect(parseAccept('a/b; a')).toEqual([ 90 | { range: 'a/b', weight: 1, parameters: { extension: {}, mediaType: {}}}, 91 | ]); 92 | expect(parseAccept('a/b; a=\\')).toEqual([ 93 | { range: 'a/b', weight: 1, parameters: { extension: {}, mediaType: {}}}, 94 | ]); 95 | expect(parseAccept('a/b; q=1 ; a=\\')).toEqual([ 96 | { range: 'a/b', weight: 1, parameters: { extension: {}, mediaType: {}}}, 97 | ]); 98 | expect(parseAccept('a/b; q=1 ; a')).toEqual([ 99 | { range: 'a/b', weight: 1, parameters: { extension: { a: '' }, mediaType: {}}}, 100 | ]); 101 | }); 102 | 103 | it('rejects Accept Headers with quoted parameters.', async(): Promise => { 104 | expect((): any => parseAccept('a/b; a="\\""')).not.toThrow(); 105 | expect((): any => parseAccept('a/b; a="\\\u007F"')).toThrow('Invalid quoted string in header:'); 106 | }); 107 | 108 | it('rejects invalid values when strict mode is enabled.', async(): Promise => { 109 | expect((): any => parseAccept('"bad"/text', true)).toThrow(BadRequestHttpError); 110 | expect((): any => parseAccept('a/b; q=text', true)).toThrow(BadRequestHttpError); 111 | expect((): any => parseAccept('a/b; a', true)).toThrow(BadRequestHttpError); 112 | }); 113 | }); 114 | 115 | describe('#parseCharset', (): void => { 116 | it('parses Accept-Charset headers.', async(): Promise => { 117 | expect(parseAcceptCharset('iso-8859-5, unicode-1-1;q=0.8')).toEqual([ 118 | { range: 'iso-8859-5', weight: 1 }, 119 | { range: 'unicode-1-1', weight: 0.8 }, 120 | ]); 121 | }); 122 | 123 | it('ignores invalid Accept-Charset Headers.', async(): Promise => { 124 | expect(parseAcceptCharset('a/b')).toEqual([]); 125 | expect(parseAcceptCharset('a; q=text')).toEqual([{ range: 'a', weight: 1 }]); 126 | expect(parseAcceptCharset('a; c=d')).toEqual([{ range: 'a', weight: 1 }]); 127 | }); 128 | 129 | it('rejects invalid values when strict mode is enabled.', async(): Promise => { 130 | expect((): any => parseAcceptCharset('a/b', true)).toThrow(BadRequestHttpError); 131 | }); 132 | }); 133 | 134 | describe('#parseEncoding', (): void => { 135 | it('parses empty Accept-Encoding headers.', async(): Promise => { 136 | expect(parseAcceptCharset('')).toEqual([]); 137 | }); 138 | 139 | it('parses Accept-Encoding headers.', async(): Promise => { 140 | expect(parseAcceptEncoding('gzip;q=1.000, identity; q=0.5, *;q=0')).toEqual([ 141 | { range: 'gzip', weight: 1 }, 142 | { range: 'identity', weight: 0.5 }, 143 | { range: '*', weight: 0 }, 144 | ]); 145 | }); 146 | 147 | it('ignores invalid Accept-Encoding Headers.', async(): Promise => { 148 | expect(parseAcceptEncoding('a/b')).toEqual([]); 149 | expect(parseAcceptEncoding('a; q=text')).toEqual([{ range: 'a', weight: 1 }]); 150 | expect(parseAcceptCharset('a; c=d')).toEqual([{ range: 'a', weight: 1 }]); 151 | }); 152 | 153 | it('rejects invalid values when strict mode is enabled.', async(): Promise => { 154 | expect((): any => parseAcceptEncoding('a/b', true)).toThrow(BadRequestHttpError); 155 | }); 156 | }); 157 | 158 | describe('#parseLanguage', (): void => { 159 | it('parses Accept-Language headers.', async(): Promise => { 160 | expect(parseAcceptLanguage('da, en-gb;q=0.8, en;q=0.7')).toEqual([ 161 | { range: 'da', weight: 1 }, 162 | { range: 'en-gb', weight: 0.8 }, 163 | { range: 'en', weight: 0.7 }, 164 | ]); 165 | }); 166 | 167 | it('ignores invalid Accept-Language Headers.', async(): Promise => { 168 | expect(parseAcceptLanguage('a/b')).toEqual([]); 169 | expect(parseAcceptLanguage('05-a')).toEqual([]); 170 | expect(parseAcceptLanguage('a--05')).toEqual([]); 171 | expect(parseAcceptLanguage('a-"a"')).toEqual([]); 172 | expect(parseAcceptLanguage('a-05')).toEqual([{ range: 'a-05', weight: 1 }]); 173 | expect(parseAcceptLanguage('a-b-c-d')).toEqual([{ range: 'a-b-c-d', weight: 1 }]); 174 | 175 | expect(parseAcceptLanguage('a; q=text')).toEqual([{ range: 'a', weight: 1 }]); 176 | expect(parseAcceptCharset('a; c=d')).toEqual([{ range: 'a', weight: 1 }]); 177 | }); 178 | 179 | it('rejects invalid values when strict mode is enabled.', async(): Promise => { 180 | expect((): any => parseAcceptLanguage('a/b', true)).toThrow(BadRequestHttpError); 181 | }); 182 | }); 183 | 184 | describe('#parseAcceptDateTime', (): void => { 185 | it('parses valid Accept-DateTime Headers.', async(): Promise => { 186 | expect(parseAcceptDateTime('Wed, 30 May 2007 18:47:52 GMT')).toEqual([ 187 | { range: 'Wed, 30 May 2007 18:47:52 GMT', weight: 1 }, 188 | ]); 189 | }); 190 | 191 | it('parses empty Accept-DateTime headers.', async(): Promise => { 192 | expect(parseAcceptDateTime('')).toEqual([]); 193 | expect(parseAcceptDateTime(' ')).toEqual([]); 194 | }); 195 | 196 | it('ignores invalid Accept-DateTime Headers.', async(): Promise => { 197 | expect(parseAcceptDateTime('a/b')).toEqual([]); 198 | expect(parseAcceptDateTime('30 May 2007')).toEqual([]); 199 | }); 200 | 201 | it('rejects invalid values when strict mode is enabled.', async(): Promise => { 202 | expect((): any => parseAcceptLanguage('a/b', true)).toThrow(BadRequestHttpError); 203 | }); 204 | }); 205 | 206 | describe('#addHeader', (): void => { 207 | let response: HttpResponse; 208 | 209 | beforeEach(async(): Promise => { 210 | const headers: Record = {}; 211 | response = { 212 | hasHeader: (name: string): boolean => Boolean(headers[name]), 213 | getHeader: (name: string): number | string | string[] | undefined => headers[name], 214 | setHeader(name: string, value: number | string | string[]): void { 215 | headers[name] = value; 216 | }, 217 | } as any; 218 | }); 219 | 220 | it('adds values if there are none already.', async(): Promise => { 221 | expect(addHeader(response, 'name', 'value')).toBeUndefined(); 222 | expect(response.getHeader('name')).toBe('value'); 223 | 224 | expect(addHeader(response, 'names', [ 'value1', 'values2' ])).toBeUndefined(); 225 | expect(response.getHeader('names')).toEqual([ 'value1', 'values2' ]); 226 | }); 227 | 228 | it('appends values to already existing values.', async(): Promise => { 229 | response.setHeader('name', 'oldValue'); 230 | expect(addHeader(response, 'name', 'value')).toBeUndefined(); 231 | expect(response.getHeader('name')).toEqual([ 'oldValue', 'value' ]); 232 | 233 | response.setHeader('number', 5); 234 | expect(addHeader(response, 'number', 'value')).toBeUndefined(); 235 | expect(response.getHeader('number')).toEqual([ '5', 'value' ]); 236 | 237 | response.setHeader('names', [ 'oldValue1', 'oldValue2' ]); 238 | expect(addHeader(response, 'names', [ 'value1', 'values2' ])).toBeUndefined(); 239 | expect(response.getHeader('names')).toEqual([ 'oldValue1', 'oldValue2', 'value1', 'values2' ]); 240 | }); 241 | }); 242 | 243 | describe('#parseContentType', (): void => { 244 | const contentTypeTurtle = 'text/turtle'; 245 | const contentTypePlain: any = { 246 | value: 'text/plain', 247 | parameters: { 248 | charset: 'utf-8', 249 | }, 250 | }; 251 | it('handles single content-type parameter (with leading and trailing whitespaces).', (): void => { 252 | expect(parseContentType('text/turtle').value).toEqual(contentTypeTurtle); 253 | expect(parseContentType('text/turtle ').value).toEqual(contentTypeTurtle); 254 | expect(parseContentType(' text/turtle').value).toEqual(contentTypeTurtle); 255 | expect(parseContentType('text/plain; charset=utf-8')).toEqual(contentTypePlain); 256 | expect(parseContentType(' text/plain; charset=utf-8')).toEqual(contentTypePlain); 257 | expect(parseContentType('text/plain ; charset=utf-8')).toEqual(contentTypePlain); 258 | expect(parseContentType(' text/plain ; charset=utf-8')).toEqual(contentTypePlain); 259 | expect(parseContentType(' text/plain ; charset="utf-8"')).toEqual(contentTypePlain); 260 | expect(parseContentType(' text/plain ; charset = "utf-8"')).toEqual(contentTypePlain); 261 | }); 262 | 263 | it('handles multiple content-type parameters.', (): void => { 264 | expect(parseContentType('text/turtle; charset=UTF-8').value).toEqual(contentTypeTurtle); 265 | contentTypePlain.parameters.test = 'value1'; 266 | expect(parseContentType('text/plain; charset=utf-8;test="value1"')).toEqual(contentTypePlain); 267 | }); 268 | 269 | it('errors on invalid content-types.', (): void => { 270 | expect((): any => parseContentType('invalid type')).toThrow(BadRequestHttpError); 271 | }); 272 | }); 273 | 274 | describe('#parseForwarded', (): void => { 275 | it('handles an empty set of headers.', (): void => { 276 | expect(parseForwarded({})).toEqual({}); 277 | }); 278 | 279 | it('handles empty string values.', (): void => { 280 | const headers = { forwarded: '', 'x-forwarded-host': '', 'x-forwarded-proto': '' }; 281 | expect(parseForwarded(headers)).toEqual({}); 282 | }); 283 | 284 | it('parses a Forwarded header value.', (): void => { 285 | const headers = { forwarded: 'for=192.0.2.60;proto=http;by=203.0.113.43;host=example.org' }; 286 | expect(parseForwarded(headers)).toEqual({ 287 | by: '203.0.113.43', 288 | for: '192.0.2.60', 289 | host: 'example.org', 290 | proto: 'http', 291 | }); 292 | }); 293 | 294 | it('skips empty fields.', (): void => { 295 | const headers = { forwarded: 'for=192.0.2.60;proto=;by=;host=' }; 296 | expect(parseForwarded(headers)).toEqual({ 297 | for: '192.0.2.60', 298 | }); 299 | }); 300 | 301 | it('takes only the first value into account.', (): void => { 302 | const headers = { forwarded: 'host=pod.example, for=192.0.2.43, host=other' }; 303 | expect(parseForwarded(headers)).toEqual({ 304 | host: 'pod.example', 305 | }); 306 | }); 307 | 308 | it('should fall back to X-Forwarded-Host and X-Forwarded-Proto without Forward header.', (): void => { 309 | const headers = { 'x-forwarded-host': 'pod.example', 'x-forwarded-proto': 'https' }; 310 | expect(parseForwarded(headers)).toEqual({ 311 | host: 'pod.example', 312 | proto: 'https', 313 | }); 314 | }); 315 | 316 | it('should prefer Forwarded to X-Forwarded-Host and X-Forwarded-Proto with Forward header.', (): void => { 317 | const headers = { 318 | forwarded: 'proto=http;host=pod.example', 319 | 'x-forwarded-host': 'anotherpod.example', 320 | 'x-forwarded-proto': 'https', 321 | }; 322 | expect(parseForwarded(headers)).toEqual({ 323 | host: 'pod.example', 324 | proto: 'http', 325 | }); 326 | }); 327 | 328 | it('should properly handle multiple values with varying spaces for X-Forwarded-*.', (): void => { 329 | const headers = { 330 | 'x-forwarded-host': ' pod.example ,192.0.2.60, 192.0.2.43', 331 | 'x-forwarded-proto': ' https ,http', 332 | }; 333 | expect(parseForwarded(headers)).toEqual({ 334 | host: 'pod.example', 335 | proto: 'https', 336 | }); 337 | }); 338 | }); 339 | 340 | describe('#parseLinkHeader', (): void => { 341 | it('handles an empty set of headers.', (): void => { 342 | expect(parseLinkHeader([])).toEqual([]); 343 | }); 344 | 345 | it('handles empty string values.', (): void => { 346 | expect(parseLinkHeader([ '' ])).toEqual([]); 347 | }); 348 | 349 | it('parses a Link header value as array.', (): void => { 350 | const link = [ '; rel="myRel"; test="value1"' ]; 351 | expect(parseLinkHeader(link)).toEqual([ 352 | { 353 | target: 'http://test.com', 354 | parameters: { 355 | rel: 'myRel', 356 | test: 'value1', 357 | }, 358 | }, 359 | ]); 360 | }); 361 | 362 | it('parses a Link header value as string.', (): void => { 363 | const link = '; rel="myRel"; test="value1"'; 364 | expect(parseLinkHeader(link)).toEqual([ 365 | { 366 | target: 'http://test.com', 367 | parameters: { 368 | rel: 'myRel', 369 | test: 'value1', 370 | }, 371 | }, 372 | ]); 373 | }); 374 | 375 | it('parses multiple Link header values delimited by a comma.', (): void => { 376 | const link = [ `; rel="myRel"; test="value1", 377 | ; rel="myRel2"; test="value2"` ]; 378 | expect(parseLinkHeader(link)).toEqual([ 379 | { 380 | target: 'http://test.com', 381 | parameters: { 382 | rel: 'myRel', 383 | test: 'value1', 384 | }, 385 | }, 386 | { 387 | target: 'http://test2.com', 388 | parameters: { 389 | rel: 'myRel2', 390 | test: 'value2', 391 | }, 392 | }, 393 | ]); 394 | }); 395 | 396 | it('parses multiple Link header values as array elements.', (): void => { 397 | const link = [ 398 | '; rel="myRel"; test="value1"', 399 | '; rel="myRel2"; test="value2"', 400 | ]; 401 | expect(parseLinkHeader(link)).toEqual([ 402 | { 403 | target: 'http://test.com', 404 | parameters: { 405 | rel: 'myRel', 406 | test: 'value1', 407 | }, 408 | }, 409 | { 410 | target: 'http://test2.com', 411 | parameters: { 412 | rel: 'myRel2', 413 | test: 'value2', 414 | }, 415 | }, 416 | ]); 417 | }); 418 | 419 | it('ignores invalid syntax links.', (): void => { 420 | const link = [ 421 | 'http://test.com; rel="myRel"; test="value1"', 422 | '; rel="myRel2"; test="value2"', 423 | ]; 424 | expect(parseLinkHeader(link)).toEqual([ 425 | { 426 | target: 'http://test2.com', 427 | parameters: { 428 | rel: 'myRel2', 429 | test: 'value2', 430 | }, 431 | }, 432 | ]); 433 | }); 434 | 435 | it('ignores invalid links (no rel parameter).', (): void => { 436 | const link = [ 437 | '; att="myAtt"; test="value1"', 438 | '; rel="myRel2"; test="value2"', 439 | ]; 440 | expect(parseLinkHeader(link)).toEqual([ 441 | { 442 | target: 'http://test2.com', 443 | parameters: { 444 | rel: 'myRel2', 445 | test: 'value2', 446 | }, 447 | }, 448 | ]); 449 | }); 450 | 451 | it('ignores extra rel parameters.', (): void => { 452 | const link = [ 453 | '; rel="myRel1"; rel="myRel2"; test="value1"', 454 | ]; 455 | expect(parseLinkHeader(link)).toEqual([ 456 | { 457 | target: 'http://test.com', 458 | parameters: { 459 | rel: 'myRel1', 460 | test: 'value1', 461 | }, 462 | }, 463 | ]); 464 | }); 465 | 466 | it('works with an empty argument.', (): void => { 467 | expect(parseLinkHeader()).toEqual([]); 468 | }); 469 | }); 470 | 471 | describe('#matchesAuthorizationScheme', (): void => { 472 | it('returns true if the provided authorization header value matches the provided scheme.', (): void => { 473 | const authorization = `Bearer Q0xXTzl1dTM4RF8xLXllSGx5am51WFUzbzZ2LTZ1WU1GWXpfMTBEajBjaw==`; 474 | expect(matchesAuthorizationScheme('Bearer', authorization)).toBeTruthy(); 475 | }); 476 | 477 | it('returns false if the provided authorization header value does not match the provided scheme.', (): void => { 478 | const authorization = `Basic YWxpY2U6YWxpY2U=`; 479 | expect(matchesAuthorizationScheme('Bearer', authorization)).toBeFalsy(); 480 | }); 481 | 482 | it('correctly detects scheme matches when a different casing is used.', (): void => { 483 | const authorization = `bAsIc YWxpY2U6YWxpY2U=`; 484 | expect(matchesAuthorizationScheme('Basic', authorization)).toBeTruthy(); 485 | }); 486 | 487 | it('escapes special regex characters in the scheme argument, resulting in a correct match.', (): void => { 488 | const authorization = `bA.*sIc$ YWxpY2U6YWxpY2U=`; 489 | expect(matchesAuthorizationScheme('bA.*sIc$', authorization)).toBeTruthy(); 490 | }); 491 | 492 | it('returns false if the authorization argument is undefined.', (): void => { 493 | expect(matchesAuthorizationScheme('Bearer')).toBeFalsy(); 494 | }); 495 | }); 496 | 497 | describe('#hasScheme', (): void => { 498 | it('returns true if the provided url matches the provided scheme.', (): void => { 499 | expect(hasScheme('http://example.com', 'http')).toBeTruthy(); 500 | }); 501 | 502 | it('returns true if the provided url matches one of the provided schemes.', (): void => { 503 | expect(hasScheme('ws://example.com', 'http', 'https', 'ws')).toBeTruthy(); 504 | }); 505 | 506 | it('returns false if the provided url does not match the provided scheme.', (): void => { 507 | expect(hasScheme('http://example.com', 'https')).toBeFalsy(); 508 | }); 509 | 510 | it('returns false if the provided value is not a valid url.', (): void => { 511 | expect(hasScheme('not-a-URL:test', 'http')).toBeFalsy(); 512 | }); 513 | 514 | it('is case insensitive: schemes with different case, result in a correct match.', (): void => { 515 | expect(hasScheme('wss://example.com', 'http', 'WSS')).toBeTruthy(); 516 | }); 517 | }); 518 | describe('A ContentType instance', (): void => { 519 | it('can serialize to a correct header value string with parameters.', (): void => { 520 | const contentType: ContentType = new ContentType( 521 | 'text/plain', 522 | { 523 | charset: 'utf-8', 524 | extra: 'test', 525 | }, 526 | ); 527 | expect(contentType.toHeaderValueString()).toBe('text/plain; charset=utf-8; extra=test'); 528 | }); 529 | 530 | it('can serialize to a correct header value string without parameters.', (): void => { 531 | const contentType: ContentType = new ContentType('text/plain'); 532 | expect(contentType.toHeaderValueString()).toBe('text/plain'); 533 | }); 534 | }); 535 | }); 536 | -------------------------------------------------------------------------------- /test/util/AclHelper.ts: -------------------------------------------------------------------------------- 1 | import { ResourceStore, BasicRepresentation, AclPermissionSet } from '@solid/community-server'; 2 | 3 | export type AclHelperInput = { 4 | permissions: AclPermissionSet; 5 | agentClass?: 'agent' | 'authenticated'; 6 | agent?: string; 7 | accessTo?: boolean; 8 | default?: boolean; 9 | }; 10 | 11 | export class AclHelper { 12 | public readonly store: ResourceStore; 13 | 14 | public constructor(store: ResourceStore) { 15 | this.store = store; 16 | } 17 | 18 | public async setSimpleAcl( 19 | resource: string, 20 | options: AclHelperInput | AclHelperInput[], 21 | ): Promise { 22 | options = Array.isArray(options) ? options : [ options ]; 23 | 24 | const acl: string[] = [ 25 | '@prefix acl: .\n', 26 | '@prefix foaf: .\n', 27 | ]; 28 | 29 | for (const [ i, option ] of options.entries()) { 30 | acl.push(`\n a acl:Authorization`); 31 | 32 | if (!option.agentClass && !option.agent) { 33 | throw new Error('At least one of agentClass or agent have to be provided.'); 34 | } 35 | if (!option.accessTo && !option.default) { 36 | throw new Error('At least one of accessTo or default have to be true.'); 37 | } 38 | 39 | for (const perm of [ 'Read', 'Append', 'Write', 'Control' ]) { 40 | if (option.permissions[perm.toLowerCase() as keyof AclPermissionSet]) { 41 | acl.push(`;\n acl:mode acl:${perm}`); 42 | } 43 | } 44 | if (option.accessTo) { 45 | acl.push(`;\n acl:accessTo <${resource}>`); 46 | } 47 | if (option.default) { 48 | acl.push(`;\n acl:default <${resource}>`); 49 | } 50 | if (option.agentClass) { 51 | acl.push( 52 | `;\n acl:agentClass ${ 53 | option.agentClass === 'agent' ? 'foaf:Agent' : 'foaf:AuthenticatedAgent' 54 | }`, 55 | ); 56 | } 57 | if (option.agent) { 58 | acl.push(`;\n acl:agent ${option.agent}`); 59 | } 60 | 61 | acl.push('.'); 62 | } 63 | 64 | await this.store.setRepresentation({ path: `${resource}.acl` }, new BasicRepresentation(acl, 'text/turtle')); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /test/util/FetchUtil.ts: -------------------------------------------------------------------------------- 1 | import 'jest-rdf'; 2 | import type { Response } from 'cross-fetch'; 3 | import fetch from 'cross-fetch'; 4 | import type { Quad } from 'n3'; 5 | import { Parser } from 'n3'; 6 | import { isContainerPath } from '@solid/community-server'; 7 | 8 | /** 9 | * This is specifically for GET requests which are expected to succeed. 10 | */ 11 | export async function getResource(url: string, 12 | options?: { accept?: string }, 13 | expected?: { contentType?: string }): Promise { 14 | const isContainer = isContainerPath(url); 15 | const response = await fetch(url, { headers: options }); 16 | expect(response.status).toBe(200); 17 | expect(response.headers.get('link')).toContain(`; rel="type"`); 18 | expect(response.headers.get('link')).toContain(`<${url}.acl>; rel="acl"`); 19 | expect(response.headers.get('accept-patch')).toBe('text/n3, application/sparql-update'); 20 | 21 | if (isContainer) { 22 | expect(response.headers.get('link')).toContain(`; rel="type"`); 23 | expect(response.headers.get('link')).toContain(`; rel="type"`); 24 | } 25 | if (expected?.contentType) { 26 | expect(response.headers.get('content-type')).toBe(expected.contentType); 27 | } else if (isContainer) { 28 | expect(response.headers.get('content-type')).toBe('text/turtle'); 29 | } 30 | 31 | return response; 32 | } 33 | 34 | /** 35 | * This is specifically for PUT requests which are expected to succeed. 36 | */ 37 | export async function putResource(url: string, options: { contentType: string; body?: string; exists?: boolean }): 38 | Promise { 39 | const init: RequestInit = { 40 | method: 'PUT', 41 | headers: { 'content-type': options.contentType }, 42 | body: options.body, 43 | }; 44 | if (isContainerPath(url)) { 45 | (init.headers as Record).link = '; rel="type"'; 46 | } 47 | const response = await fetch(url, init); 48 | expect(response.status).toBe(options.exists ? 205 : 201); 49 | if (!options.exists) { 50 | expect(response.headers.get('location')).toBe(url); 51 | } 52 | await expect(response.text()).resolves.toHaveLength(0); 53 | return response; 54 | } 55 | 56 | export type CreateOptions = { 57 | contentType: string; 58 | isContainer?: boolean; 59 | slug?: string; 60 | body?: string; 61 | }; 62 | /** 63 | * This is specifically for POST requests which are expected to succeed. 64 | */ 65 | export async function postResource(container: string, options: CreateOptions): Promise { 66 | const init: RequestInit = { 67 | method: 'POST', 68 | headers: { 'content-type': options.contentType }, 69 | body: options.body, 70 | }; 71 | if (options.isContainer) { 72 | (init.headers as Record).link = '; rel="type"'; 73 | } 74 | if (options.slug) { 75 | (init.headers as Record).slug = options.slug; 76 | } 77 | const response = await fetch(container, init); 78 | await expect(response.text()).resolves.toHaveLength(0); 79 | expect(response.status).toBe(201); 80 | const regex = new RegExp(`^${container}[^/]+${options.isContainer ? '/' : ''}`, 'u'); 81 | expect(response.headers.get('location')).toMatch(regex); 82 | return response; 83 | } 84 | 85 | /** 86 | * This is specifically for PATCH requests which are expected to succeed. 87 | */ 88 | export async function patchResource(url: string, query: string, type: 'sparql' | 'n3', exists?: boolean): 89 | Promise { 90 | const contentTypes = { sparql: 'application/sparql-update', n3: 'text/n3' }; 91 | const response = await fetch(url, { 92 | method: 'PATCH', 93 | headers: { 94 | 'content-type': contentTypes[type], 95 | }, 96 | body: query, 97 | }); 98 | await expect(response.text()).resolves.toHaveLength(0); 99 | expect(response.status).toBe(exists ? 205 : 201); 100 | if (!exists) { 101 | expect(response.headers.get('location')).toBe(url); 102 | } 103 | 104 | return response; 105 | } 106 | 107 | export async function deleteResource(url: string): Promise { 108 | let response = await fetch(url, { method: 'DELETE' }); 109 | expect(response.status).toBe(205); 110 | response = await fetch(url); 111 | expect(response.status).toBe(404); 112 | } 113 | 114 | /** 115 | * Verifies if the body of the given Response contains the expected Quads. 116 | * If `exact` is true, a 1-to-1 match is expected, if not, the expected quads should be a subset of the body. 117 | */ 118 | export async function expectQuads(response: Response, expected: Quad[], exact?: boolean): Promise { 119 | const parser = new Parser({ baseIRI: response.url }); 120 | const quads = parser.parse(await response.text()); 121 | if (exact) { 122 | expect(quads).toBeRdfIsomorphic(expected); 123 | } else { 124 | for (const expectedQuad of expected) { 125 | expect(quads.some((entry): boolean => entry.equals(expectedQuad))).toBe(true); 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /test/util/SimpleSuffixStrategy.ts: -------------------------------------------------------------------------------- 1 | import { DataFactory } from 'n3'; 2 | import type { AuxiliaryStrategy, RepresentationMetadata, ResourceIdentifier } from '@solid/community-server'; 3 | const namedNode = DataFactory.namedNode; 4 | 5 | export class SimpleSuffixStrategy implements AuxiliaryStrategy { 6 | private readonly suffix: string; 7 | 8 | public constructor(suffix: string) { 9 | this.suffix = suffix; 10 | } 11 | 12 | public usesOwnAuthorization(): boolean { 13 | return true; 14 | } 15 | 16 | public getAuxiliaryIdentifier(identifier: ResourceIdentifier): ResourceIdentifier { 17 | return { path: `${identifier.path}${this.suffix}` }; 18 | } 19 | 20 | public getAuxiliaryIdentifiers(identifier: ResourceIdentifier): ResourceIdentifier[] { 21 | return [ this.getAuxiliaryIdentifier(identifier) ]; 22 | } 23 | 24 | public isAuxiliaryIdentifier(identifier: ResourceIdentifier): boolean { 25 | return identifier.path.endsWith(this.suffix); 26 | } 27 | 28 | public getSubjectIdentifier(identifier: ResourceIdentifier): ResourceIdentifier { 29 | return { path: identifier.path.slice(0, -this.suffix.length) }; 30 | } 31 | 32 | public isRequiredInRoot(): boolean { 33 | return false; 34 | } 35 | 36 | public async addMetadata(metadata: RepresentationMetadata): Promise { 37 | const identifier = { path: metadata.identifier.value }; 38 | // Random triple to test on 39 | metadata.add(namedNode('AUXILIARY'), this.getAuxiliaryIdentifier(identifier).path); 40 | } 41 | 42 | public async validate(): Promise { 43 | // Always validates 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /test/util/Util.ts: -------------------------------------------------------------------------------- 1 | import type { Dirent, Stats } from 'node:fs'; 2 | import { PassThrough, Readable } from 'node:stream'; 3 | import type { SystemError } from '@solid/community-server'; 4 | import Describe = jest.Describe; 5 | 6 | const portNames = [ 7 | // Integration 8 | 'Accounts', 9 | 'AcpServer', 10 | 'Conditions', 11 | 'ContentNegotiation', 12 | 'DynamicPods', 13 | 'ExpiringDataCleanup', 14 | 'FileBackend', 15 | 'GlobalQuota', 16 | 'Identity', 17 | 'LegacyWebSocketsProtocol', 18 | 'LpdHandlerWithAuth', 19 | 'LpdHandlerWithoutAuth', 20 | 'Middleware', 21 | 'N3Patch', 22 | 'PermissionTable', 23 | 'PodCreation', 24 | 'PodQuota', 25 | 'RedisLocker', 26 | 'ResourceLockCleanup', 27 | 'RestrictedIdentity', 28 | 'SeedingPods', 29 | 'ServerFetch', 30 | 'SetupMemory', 31 | 'SparqlStorage', 32 | 'StreamingHTTPChannel2023', 33 | 'Subdomains', 34 | 'WebhookChannel2023', 35 | 'WebhookChannel2023-client', 36 | 'WebSocketChannel2023', 37 | 38 | // Unit 39 | 'BaseServerFactory', 40 | ] as const; 41 | 42 | // These are ports that are not allowed to change for various reasons 43 | const fixedPorts = { 44 | V6Migration: 6999, 45 | } as const; 46 | 47 | const socketNames = [ 48 | // Unit 49 | 'BaseHttpServerFactory', 50 | ]; 51 | 52 | function isFixedPortName(name: string): name is keyof typeof fixedPorts { 53 | return Boolean(fixedPorts[name as keyof typeof fixedPorts]); 54 | } 55 | 56 | export function getPort(name: typeof portNames[number] | keyof typeof fixedPorts): number { 57 | if (isFixedPortName(name)) { 58 | return fixedPorts[name]; 59 | } 60 | const idx = portNames.indexOf(name); 61 | // Just in case something doesn't listen to the typings 62 | if (idx < 0) { 63 | throw new Error(`Unknown port name ${name}`); 64 | } 65 | // 6000 is a bad port, causing node v18+ to block fetch requests targeting such a URL 66 | // https://fetch.spec.whatwg.org/#port-blocking 67 | return 6000 + idx + 1; 68 | } 69 | 70 | export function getSocket(name: typeof socketNames[number]): string { 71 | const idx = socketNames.indexOf(name); 72 | // Just in case something doesn't listen to the typings 73 | if (idx < 0) { 74 | throw new Error(`Unknown socket name ${name}`); 75 | } 76 | return `css${idx}.sock`; 77 | } 78 | 79 | export function describeIf(envFlag: string): Describe { 80 | const flag = `TEST_${envFlag.toUpperCase()}`; 81 | const enabled = !/^(?:0|false)?$/iu.test(process.env[flag] ?? ''); 82 | return enabled ? describe : describe.skip; 83 | } 84 | 85 | /** 86 | * This is needed when you want to wait for all promises to resolve. 87 | * Also works when using jest.useFakeTimers(). 88 | * For more details see the links below 89 | * - https://github.com/facebook/jest/issues/2157 90 | * - https://stackoverflow.com/questions/52177631/jest-timer-and-promise-dont-work-well-settimeout-and-async-function 91 | */ 92 | export async function flushPromises(): Promise { 93 | return new Promise(jest.requireActual('timers').setImmediate); 94 | } 95 | 96 | /** 97 | * Compares the contents of the given two maps. 98 | */ 99 | export function compareMaps(map1: Map, map2: Map): void { 100 | expect(new Set(map1.keys())).toEqual(new Set(map2.keys())); 101 | // Looping like this also allows us to compare SetMultiMaps 102 | for (const key of map1.keys()) { 103 | // Adding key for better error output 104 | expect({ key, value: map1.get(key) }).toEqual({ key, value: map2.get(key) }); 105 | } 106 | } 107 | 108 | /** 109 | * Mocks (some) functions of the fs system library. 110 | * It is important that you call `jest.mock('node:fs');` in your test file before calling this!!! 111 | * 112 | * This function will return an object of which the `data` field corresponds to the contents of the root folder. 113 | * The file system can be "reset" by assigning an empty object (`{}`) to the data field. 114 | * 115 | * Only files and directories are supported. 116 | * Files are stored as strings, directories as objects with the keys corresponding to its contents. 117 | * File path `/folder/folder2/file` will correspond to `data['folder']['folder2']['file']`. 118 | * This can both be used to check if a file/directory was created, 119 | * or to specify in advance certain files on the "file system". 120 | * 121 | * Data streams will be converted to strings for files by concatenating the contents. 122 | * 123 | * @param rootFilepath - The name of the root folder in which fs will start. 124 | * @param time - The date object to use for time functions (currently only mtime from lstats) 125 | */ 126 | export function mockFileSystem(rootFilepath?: string, time?: Date): { data: any } { 127 | const cache: { data: any } = { data: {}}; 128 | 129 | rootFilepath = rootFilepath ?? 'folder'; 130 | time = time ?? new Date(); 131 | 132 | // eslint-disable-next-line unicorn/consistent-function-scoping 133 | function throwSystemError(code: string): void { 134 | const error = new Error('error') as SystemError; 135 | error.code = code; 136 | error.syscall = 'this exists for isSystemError'; 137 | throw error; 138 | } 139 | 140 | function getFolder(path: string): { folder: any; name: string } { 141 | let parts = path.slice(rootFilepath!.length).split('/').filter((part): boolean => part.length > 0); 142 | 143 | if (parts.length === 0) { 144 | return { folder: cache, name: 'data' }; 145 | } 146 | 147 | const name = parts.at(-1) as string; 148 | parts = parts.slice(0, -1); 149 | let folder = cache.data; 150 | for (const part of parts) { 151 | if (typeof folder === 'string') { 152 | throwSystemError('ENOTDIR'); 153 | } 154 | folder = folder[part]; 155 | if (!folder) { 156 | throwSystemError('ENOENT'); 157 | } 158 | } 159 | 160 | return { folder, name }; 161 | } 162 | 163 | const mockFs = { 164 | createReadStream(path: string): any { 165 | const { folder, name } = getFolder(path); 166 | return Readable.from([ folder[name] ]); 167 | }, 168 | createWriteStream(path: string): any { 169 | const { folder, name } = getFolder(path); 170 | folder[name] = ''; 171 | const stream = new PassThrough(); 172 | stream.on('data', (data): any => { 173 | folder[name] += data; 174 | }); 175 | stream.on('end', (): any => stream.emit('finish')); 176 | return stream; 177 | }, 178 | promises: { 179 | async stat(path: string): Promise { 180 | return this.lstat(await this.realpath(path)); 181 | }, 182 | async lstat(path: string): Promise { 183 | const { folder, name } = getFolder(path); 184 | if (!folder[name]) { 185 | throwSystemError('ENOENT'); 186 | } 187 | return { 188 | isFile: (): boolean => typeof folder[name] === 'string', 189 | isDirectory: (): boolean => typeof folder[name] === 'object', 190 | isSymbolicLink: (): boolean => typeof folder[name] === 'symbol', 191 | size: typeof folder[name] === 'string' ? folder[name].length : 4, 192 | mtime: time, 193 | } as Stats; 194 | }, 195 | async unlink(path: string): Promise { 196 | const { folder, name } = getFolder(path); 197 | if (!folder[name]) { 198 | throwSystemError('ENOENT'); 199 | } 200 | if (!(await this.lstat(path)).isFile()) { 201 | throwSystemError('EISDIR'); 202 | } 203 | delete folder[name]; 204 | }, 205 | async symlink(target: string, path: string): Promise { 206 | const { folder, name } = getFolder(path); 207 | folder[name] = Symbol(target); 208 | }, 209 | async realpath(path: string): Promise { 210 | const { folder, name } = getFolder(path); 211 | const entry = folder[name]; 212 | return typeof entry === 'symbol' ? entry.description ?? 'invalid' : path; 213 | }, 214 | async rm(path: string): Promise { 215 | const { folder, name } = getFolder(path); 216 | if (!folder[name]) { 217 | throwSystemError('ENOENT'); 218 | } 219 | if (Object.keys(folder[name]).length > 0) { 220 | throwSystemError('ENOTEMPTY'); 221 | } 222 | if (!(await this.lstat(path)).isDirectory()) { 223 | throwSystemError('ENOTDIR'); 224 | } 225 | delete folder[name]; 226 | }, 227 | async readdir(path: string): Promise { 228 | const { folder, name } = getFolder(path); 229 | if (!folder[name]) { 230 | throwSystemError('ENOENT'); 231 | } 232 | return Object.keys(folder[name]); 233 | }, 234 | async* opendir(path: string): AsyncIterableIterator { 235 | const { folder, name } = getFolder(path); 236 | if (!folder[name]) { 237 | throwSystemError('ENOENT'); 238 | } 239 | for (const [ child, entry ] of Object.entries(folder[name])) { 240 | yield { 241 | name: child, 242 | isFile: (): boolean => typeof entry === 'string', 243 | isDirectory: (): boolean => typeof entry === 'object', 244 | isSymbolicLink: (): boolean => typeof entry === 'symbol', 245 | } as Dirent; 246 | } 247 | }, 248 | async mkdir(path: string): Promise { 249 | const { folder, name } = getFolder(path); 250 | if (folder[name]) { 251 | throwSystemError('EEXIST'); 252 | } 253 | folder[name] = {}; 254 | }, 255 | async readFile(path: string): Promise { 256 | const { folder, name } = getFolder(path); 257 | if (!folder[name]) { 258 | throwSystemError('ENOENT'); 259 | } 260 | return folder[name]; 261 | }, 262 | async writeFile(path: string, data: string): Promise { 263 | const { folder, name } = getFolder(path); 264 | folder[name] = data; 265 | }, 266 | async rename(path: string, destination: string): Promise { 267 | const { folder, name } = getFolder(path); 268 | if (!folder[name]) { 269 | throwSystemError('ENOENT'); 270 | } 271 | if (!(await this.lstat(path)).isFile()) { 272 | throwSystemError('EISDIR'); 273 | } 274 | 275 | const { folder: folderDest, name: nameDest } = getFolder(destination); 276 | folderDest[nameDest] = folder[name]; 277 | 278 | delete folder[name]; 279 | }, 280 | }, 281 | }; 282 | 283 | const mockFsExtra = { 284 | async readJson(path: string): Promise> { 285 | const { folder, name } = getFolder(path); 286 | if (!folder[name]) { 287 | throwSystemError('ENOENT'); 288 | } 289 | return JSON.parse(folder[name]); 290 | }, 291 | async writeJson(path: string, json: NodeJS.Dict): Promise { 292 | const { folder, name } = getFolder(path); 293 | const data = JSON.stringify(json, null, 2); 294 | folder[name] = data; 295 | }, 296 | async ensureDir(path: string): Promise { 297 | const { folder, name } = getFolder(path); 298 | folder[name] = {}; 299 | }, 300 | async remove(path: string): Promise { 301 | const { folder, name } = getFolder(path); 302 | delete folder[name]; 303 | }, 304 | async pathExists(path: string): Promise { 305 | try { 306 | const { folder, name } = getFolder(path); 307 | return Boolean(folder[name]); 308 | } catch { 309 | return false; 310 | } 311 | }, 312 | createReadStream(path: string): any { 313 | return mockFs.createReadStream(path); 314 | }, 315 | createWriteStream(path: string): any { 316 | return mockFs.createWriteStream(path); 317 | }, 318 | async realpath(path: string): Promise { 319 | return mockFs.promises.realpath(path); 320 | }, 321 | async stat(path: string): Promise { 322 | return mockFs.promises.lstat(await mockFs.promises.realpath(path)); 323 | }, 324 | async lstat(path: string): Promise { 325 | return mockFs.promises.lstat(path); 326 | }, 327 | async unlink(path: string): Promise { 328 | await mockFs.promises.unlink(path); 329 | }, 330 | async symlink(target: string, path: string): Promise { 331 | await mockFs.promises.symlink(target, path); 332 | }, 333 | async rm(path: string): Promise { 334 | await mockFs.promises.rm(path); 335 | }, 336 | async readdir(path: string): Promise { 337 | return mockFs.promises.readdir(path); 338 | }, 339 | async* opendir(path: string): AsyncIterableIterator { 340 | for await (const entry of mockFs.promises.opendir(path)) { 341 | yield entry; 342 | } 343 | }, 344 | async mkdir(path: string): Promise { 345 | await mockFs.promises.mkdir(path); 346 | }, 347 | async readFile(path: string): Promise { 348 | return mockFs.promises.readFile(path); 349 | }, 350 | async writeFile(path: string, data: string): Promise { 351 | await mockFs.promises.writeFile(path, data); 352 | }, 353 | async rename(path: string, destination: string): Promise { 354 | await mockFs.promises.rename(path, destination); 355 | }, 356 | }; 357 | 358 | const fs = jest.requireMock('node:fs'); 359 | Object.assign(fs, mockFs); 360 | 361 | const fsExtra = jest.requireMock('fs-extra'); 362 | Object.assign(fsExtra, mockFsExtra); 363 | 364 | return cache; 365 | } 366 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node14/tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "incremental": true, 6 | "inlineSources": true, 7 | "lib": [ "es2022" ], 8 | "newLine": "lf", 9 | "outDir": "dist", 10 | "preserveConstEnums": true, 11 | "sourceMap": true, 12 | "stripInternal": true, 13 | "typeRoots": [ 14 | "types", 15 | "node_modules/@types" 16 | ], 17 | "resolveJsonModule": true, 18 | }, 19 | "include": [ 20 | "src" 21 | ], 22 | "exclude": [ 23 | "node_modules" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /www/.acl: -------------------------------------------------------------------------------- 1 | # Root ACL resource for the domain root 2 | @prefix acl: . 3 | @prefix foaf: . 4 | 5 | # The homepage is readable by the public 6 | <#public> 7 | a acl:Authorization; 8 | acl:agentClass foaf:Agent; 9 | acl:accessTo <./>; 10 | acl:mode acl:Read. 11 | -------------------------------------------------------------------------------- /www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | An application is requesting access 7 | 8 | 9 | 10 | 11 | 15 |
16 |

Welcome to this Solid Server

17 | 18 |

19 | 20 | 21 |

22 | 23 |
24 | 31 | 32 | 33 | 34 | 35 | --------------------------------------------------------------------------------
12 | [Solid logo] 13 |

Pivot

14 |
3 |

4 | 5 |
6 | Accounts created after December 2024 log in with email. Older ones log in with username. 7 |
    8 |
  1. 9 | 10 | 11 |
  2. 12 |
  3. 13 | 14 | 15 |
  4. 16 |
  5. 17 | 18 |
  6. 19 |
20 |
21 | 22 |

23 | 24 | 25 | 26 |

27 | 28 |
32 |