├── .nvmrc ├── src ├── authSession │ └── authSession.ts ├── util │ ├── ns.ts │ ├── debug.ts │ ├── utils.ts │ ├── containerLogic.ts │ └── utilityLogic.ts ├── logic │ ├── CustomError.ts │ ├── solidLogicSingleton.ts │ └── solidLogic.ts ├── index.ts ├── issuer │ └── issuerLogic.ts ├── inbox │ └── inboxLogic.ts ├── authn │ ├── authUtil.ts │ └── SolidAuthnLogic.ts ├── types.ts ├── acl │ └── aclLogic.ts ├── profile │ └── profileLogic.ts ├── chat │ └── chatLogic.ts └── typeIndex │ └── typeIndexLogic.ts ├── tsconfig.test.json ├── .gitignore ├── test ├── helpers │ ├── setup.ts │ ├── debugger.ts │ └── dataSetup.ts ├── authUtil.test.ts ├── logic.test.ts ├── aclLogic.test.ts ├── testBundles │ └── test-umd.html ├── solidAuthLogic.test.ts ├── utils.test.ts ├── container.test.ts ├── inboxLogic.test.ts ├── utilityLogic.test.ts ├── profileLogic.test.ts ├── chatLogic.test.ts └── typeIndexLogic.test.ts ├── babel.config.mjs ├── timestamp.sh ├── jest.config.mjs ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── LICENSE ├── eslint.config.mjs ├── package.json ├── webpack.config.mjs ├── README.md └── tsconfig.json /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.19.2 2 | -------------------------------------------------------------------------------- /src/authSession/authSession.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Session, 3 | } from '@inrupt/solid-client-authn-browser' 4 | 5 | export const authSession = new Session() 6 | 7 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["test/**/*"], 4 | "compilerOptions": { 5 | "rootDir": ".", 6 | "noEmit": true 7 | } 8 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | 5 | ### VisualStudioCode Patch ### 6 | # Ignore all local history of files 7 | .history 8 | .idea 9 | .vscode 10 | 11 | src/versionInfo.ts 12 | -------------------------------------------------------------------------------- /test/helpers/setup.ts: -------------------------------------------------------------------------------- 1 | import fetchMock from 'jest-fetch-mock' 2 | import { TextEncoder, TextDecoder } from 'util' 3 | 4 | global.TextEncoder = TextEncoder as any 5 | global.TextDecoder = TextDecoder as any 6 | 7 | fetchMock.enableMocks() -------------------------------------------------------------------------------- /babel.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | presets: [ 3 | ['@babel/preset-env', { 4 | targets: { 5 | browsers: ['> 1%', 'last 3 versions', 'not dead'] 6 | } 7 | }], 8 | '@babel/preset-typescript' 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /src/util/ns.ts: -------------------------------------------------------------------------------- 1 | // Namespaces we commonly use and have common prefixes for around Solid 2 | import solidNamespace from 'solid-namespace' // Delegate to this which takes RDFlib as param. 3 | import * as $rdf from 'rdflib' 4 | 5 | export const ns = solidNamespace($rdf) -------------------------------------------------------------------------------- /src/util/debug.ts: -------------------------------------------------------------------------------- 1 | 2 | export function log(...args: any[]): void { 3 | console.log(...args) 4 | } 5 | 6 | export function warn(...args: any[]): void { 7 | console.warn(...args) 8 | } 9 | 10 | export function error(...args: any[]): void { 11 | console.error(...args) 12 | } 13 | 14 | export function trace(...args: any[]): void { 15 | console.trace(...args) 16 | } 17 | -------------------------------------------------------------------------------- /test/helpers/debugger.ts: -------------------------------------------------------------------------------- 1 | // We don't want to output debug messages to console as part of the tests 2 | jest.mock('../../src/util/debug') 3 | import * as debug from '../../src/util/debug' 4 | 5 | export function silenceDebugMessages () { 6 | jest.spyOn(debug, 'log').mockImplementation(() => {}) 7 | jest.spyOn(debug, 'warn').mockImplementation(() => {}) 8 | jest.spyOn(debug, 'error').mockImplementation(() => {}) 9 | jest.spyOn(debug, 'trace').mockImplementation(() => {}) 10 | } -------------------------------------------------------------------------------- /timestamp.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "export default {" 4 | date -u '+ buildTime: "%Y-%m-%dT%H:%M:%SZ",' 5 | if [ -d .git ]; then 6 | commit=$(git log --pretty=format:'%H' -n 1) 7 | else 8 | commit="unknown" 9 | fi 10 | echo " commit: \"$commit\"," 11 | echo " npmInfo: {" 12 | npm version | grep -v '^{' | while IFS=: read key value; do 13 | key=$(echo "$key" | xargs) 14 | value=$(echo $value | xargs) 15 | # Remove any trailing comma from value 16 | value=$(echo "$value" | sed 's/,$//') 17 | if [ "$key" != "}" ]; then 18 | echo " '$key': '$value'," 19 | fi 20 | done 21 | echo " }" 22 | echo "}" -------------------------------------------------------------------------------- /test/authUtil.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | * 4 | */ 5 | import * as authUtil from '../src/authn/authUtil' 6 | 7 | describe('offlineTestID', () => { 8 | it('exists', () => { 9 | expect(authUtil.offlineTestID).toBeInstanceOf(Function) 10 | }) 11 | it('runs', () => { 12 | expect(authUtil.offlineTestID()).toEqual(null) 13 | }) 14 | }) 15 | 16 | describe('appContext', () => { 17 | it('exists', () => { 18 | expect(authUtil.appContext).toBeInstanceOf(Function) 19 | }) 20 | it('runs', () => { 21 | expect(authUtil.appContext()).toEqual({'viewingNoAuthPage': false,}) 22 | }) 23 | }) -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('jest').Config} */ 2 | export default { 3 | // verbose: true, // Uncomment for detailed test output 4 | collectCoverage: true, 5 | coverageDirectory: 'coverage', 6 | testEnvironment: 'jsdom', 7 | testEnvironmentOptions: { 8 | customExportConditions: ['node'], 9 | }, 10 | testPathIgnorePatterns: ['/node_modules/', '/dist/'], 11 | transform: { 12 | '^.+\\.[tj]sx?$': ['babel-jest', { configFile: './babel.config.mjs' }], 13 | }, 14 | setupFilesAfterEnv: ['./test/helpers/setup.ts'], 15 | testMatch: ['**/__tests__/**/*.ts?(x)', '**/?(*.)+(spec|test).ts?(x)'], 16 | roots: ['/src', '/test'], 17 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | # Check for updates to GitHub Actions every week 16 | interval: "weekly" 17 | -------------------------------------------------------------------------------- /test/logic.test.ts: -------------------------------------------------------------------------------- 1 | import { solidLogicSingleton } from '../src/logic/solidLogicSingleton' 2 | import { silenceDebugMessages } from './helpers/debugger' 3 | 4 | silenceDebugMessages() 5 | 6 | describe('store', () => { 7 | it('exists', () => { 8 | expect(solidLogicSingleton.store).toBeInstanceOf(Object) 9 | }) 10 | }) 11 | 12 | describe('store.fetcher', () => { 13 | it('exists', () => { 14 | expect(solidLogicSingleton.store.fetcher).toBeInstanceOf(Object) 15 | }) 16 | }) 17 | 18 | describe('store.updater', () => { 19 | it('exists', () => { 20 | expect(solidLogicSingleton.store.updater).toBeInstanceOf(Object) 21 | }) 22 | }) 23 | 24 | describe('authn', () => { 25 | it('exists', () => { 26 | expect(solidLogicSingleton.authn).toBeInstanceOf(Object) 27 | }) 28 | }) 29 | 30 | -------------------------------------------------------------------------------- /test/aclLogic.test.ts: -------------------------------------------------------------------------------- 1 | import { Fetcher, Store, sym, UpdateManager } from 'rdflib' 2 | import { createAclLogic } from '../src/acl/aclLogic' 3 | 4 | describe('setACLUserPublic', () => { 5 | let aclLogic 6 | let store 7 | beforeAll(() => { 8 | const options = { fetch: fetch } 9 | store = new Store() 10 | store.fetcher = new Fetcher(store, options) 11 | store.updater = new UpdateManager(store) 12 | aclLogic = createAclLogic(store) 13 | }) 14 | it('exists', () => { 15 | expect(aclLogic.setACLUserPublic).toBeInstanceOf(Function) 16 | }) 17 | it.skip('runs', async () => { 18 | expect(await aclLogic.setACLUserPublic( 19 | 'https://test.test#', 20 | sym('https://test.test#'), 21 | {} 22 | )).toEqual({}) 23 | }) 24 | }) -------------------------------------------------------------------------------- /src/logic/CustomError.ts: -------------------------------------------------------------------------------- 1 | class CustomError extends Error { 2 | constructor(message?: string) { 3 | super(message) 4 | // see: typescriptlang.org/docs/handbook/release-notes/typescript-2-2.html 5 | Object.setPrototypeOf(this, new.target.prototype) // restore prototype chain 6 | this.name = new.target.name // stack traces display correctly now 7 | } 8 | } 9 | 10 | export class UnauthorizedError extends CustomError {} 11 | 12 | export class CrossOriginForbiddenError extends CustomError {} 13 | 14 | export class SameOriginForbiddenError extends CustomError {} 15 | 16 | export class NotFoundError extends CustomError {} 17 | 18 | export class NotEditableError extends CustomError { } 19 | 20 | export class WebOperationError extends CustomError {} 21 | 22 | export class FetchError extends CustomError { 23 | status: number 24 | 25 | constructor(status: number, message?: string) { 26 | super(message) 27 | this.status = status 28 | } 29 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Make these variables directly accessible as it is what you need most of the time 2 | // This also makes these variable globaly accesible in mashlib 3 | import { solidLogicSingleton } from './logic/solidLogicSingleton' 4 | 5 | const authn = solidLogicSingleton.authn 6 | const authSession = solidLogicSingleton.authn.authSession 7 | const store = solidLogicSingleton.store 8 | 9 | export { ACL_LINK } from './acl/aclLogic' 10 | export { offlineTestID, appContext } from './authn/authUtil' 11 | export { getSuggestedIssuers } from './issuer/issuerLogic' 12 | export { createTypeIndexLogic } from './typeIndex/typeIndexLogic' 13 | export type { AppDetails, SolidNamespace, AuthenticationContext, SolidLogic, ChatLogic } from './types' 14 | export { UnauthorizedError, CrossOriginForbiddenError, SameOriginForbiddenError, NotFoundError, FetchError, NotEditableError, WebOperationError } from './logic/CustomError' 15 | 16 | export { 17 | solidLogicSingleton, // solidLogicSingleton is exported entirely because it is used in solid-panes 18 | store, 19 | authn, 20 | authSession 21 | } 22 | 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Solid 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/testBundles/test-umd.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Solid Logic UMD Bundle Test 6 | 7 | 8 |

Solid Logic UMD Bundle Browser Test

9 |

10 |   
18 |   
31 | 
32 | 
33 | 


--------------------------------------------------------------------------------
/src/issuer/issuerLogic.ts:
--------------------------------------------------------------------------------
 1 | const DEFAULT_ISSUERS = [
 2 |   {
 3 |     name: 'Solid Community',
 4 |     uri: 'https://solidcommunity.net'
 5 |   },
 6 |   {
 7 |     name: 'Solid Web',
 8 |     uri: 'https://solidweb.org'
 9 |   },
10 |   {
11 |     name: 'Solid Web ME',
12 |     uri: 'https://solidweb.me'
13 |   },
14 |   {
15 |     name: 'Inrupt.com',
16 |     uri: 'https://login.inrupt.com'
17 |   }
18 | ]
19 | 
20 | /**
21 |  * @returns - A list of suggested OIDC issuers
22 |  */
23 | export function getSuggestedIssuers (): { name: string, uri: string }[] {
24 |     // Suggest a default list of OIDC issuers
25 |     const issuers = [...DEFAULT_ISSUERS]
26 |   
27 |     // Suggest the current host if not already included
28 |     const { host, origin } = new URL(location.href)
29 |     const hosts = issuers.map(({ uri }) => new URL(uri).host)
30 |     if (!hosts.includes(host) && !hosts.some(existing => isSubdomainOf(host, existing))) {
31 |       issuers.unshift({ name: host, uri: origin })
32 |     }
33 |   
34 |     return issuers
35 |   }
36 |   
37 | function isSubdomainOf (subdomain: string, domain: string): boolean {
38 |     const dot = subdomain.length - domain.length - 1
39 |     return dot > 0 && subdomain[dot] === '.' && subdomain.endsWith(domain)
40 | }


--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
 1 | import tsParser from '@typescript-eslint/parser'
 2 | import importPlugin from 'eslint-plugin-import'
 3 | 
 4 | export default [
 5 |   {
 6 |     ignores: [
 7 |       'dist/**',
 8 |       'lib/**',
 9 |       'node_modules/**',
10 |       'coverage/**'
11 |     ],
12 |   },
13 |   {
14 |     files: ['src/**/*.ts'],
15 |     languageOptions: {
16 |       parser: tsParser,
17 |       parserOptions: {
18 |         project: ['./tsconfig.json'],
19 |         sourceType: 'module',
20 |       },
21 |     },
22 |     plugins: {
23 |       import: importPlugin,
24 |     },
25 |     rules: {
26 |       // Style rules (not handled by TypeScript)
27 |       semi: ['error', 'never'],
28 |       quotes: ['error', 'single'],
29 |       
30 |       // Disable ESLint rules that TypeScript handles better
31 |       'no-unused-vars': 'off', // TypeScript handles this via noUnusedLocals
32 |       'no-undef': 'off', // TypeScript handles undefined variables
33 |     },
34 |   },
35 |   {
36 |     files: ['test/**/*.ts'],
37 |     languageOptions: {
38 |       parser: tsParser,
39 |       parserOptions: {
40 |         project: ['./tsconfig.test.json'],
41 |       },
42 |     },
43 |     rules: {
44 |       semi: ['error', 'never'],
45 |       quotes: ['error', 'single'],
46 |       'no-console': 'off', // Allow console in tests
47 |       'no-undef': 'off', // Tests may define globals
48 |     }
49 |   }
50 | ]


--------------------------------------------------------------------------------
/test/solidAuthLogic.test.ts:
--------------------------------------------------------------------------------
 1 | /**
 2 | * @jest-environment jsdom
 3 | * 
 4 | */
 5 | import { SolidAuthnLogic } from '../src/authn/SolidAuthnLogic'
 6 | import { silenceDebugMessages } from './helpers/debugger'
 7 | import { authSession } from '../src/authSession/authSession'
 8 | import { AuthenticationContext } from '../src/types'
 9 | 
10 | silenceDebugMessages()
11 | let solidAuthnLogic
12 | 
13 | describe('SolidAuthnLogic', () => {
14 |   
15 |   beforeEach(() => {
16 |     solidAuthnLogic = new SolidAuthnLogic(authSession)
17 |   })
18 | 
19 |   describe('checkUser', () => {
20 |     it('exists', () => {
21 |       expect(solidAuthnLogic.checkUser).toBeInstanceOf(Function)
22 |     })
23 |     it('runs', async () => {
24 |       expect(await solidAuthnLogic.checkUser()).toEqual(null)
25 |     })
26 |   })
27 | 
28 |   describe('currentUser', () => {
29 |     it('exists', () => {
30 |       expect(solidAuthnLogic.currentUser).toBeInstanceOf(Function)
31 |     })
32 |     it('runs', async () => {
33 |       expect(await solidAuthnLogic.currentUser()).toEqual(null)
34 |     })
35 |   })
36 | 
37 |   describe('saveUser', () => {
38 |     it('exists', () => {
39 |       expect(solidAuthnLogic.saveUser).toBeInstanceOf(Function)
40 |     })
41 |     it('runs', () => {
42 |       expect(solidAuthnLogic.saveUser(
43 |         '',
44 |         {} as AuthenticationContext
45 |       )).toEqual(null)
46 |     })
47 |   })
48 | 
49 | })


--------------------------------------------------------------------------------
/test/utils.test.ts:
--------------------------------------------------------------------------------
 1 | import * as rdf from 'rdflib'
 2 | import { getArchiveUrl, uniqueNodes } from '../src/util/utils'
 3 | 
 4 | describe('utils', () => {
 5 |     describe('uniqueNodes', () => {
 6 |         it('exists', () => {
 7 |             expect(uniqueNodes).toBeInstanceOf(Function)
 8 |         })
 9 |         it('removed duplicates', async () => {
10 |             const input = [ rdf.sym('https://a.com/'), rdf.sym('https://b.com/'), rdf.sym('https://a.com/'), rdf.sym('https://a.com/'), rdf.sym('https://c.com/'),  ]
11 |             const expected = [ rdf.sym('https://a.com/'), rdf.sym('https://b.com/'), rdf.sym('https://c.com/'),  ]
12 |             const result =  uniqueNodes(input)
13 |             expect(result).toEqual(expected)
14 | 
15 |         })
16 |         it('handles an empty array', async () => {
17 |             const result = await uniqueNodes([])
18 |             expect(result).toEqual([])
19 |         })
20 |     })
21 | 
22 |     describe('getArchiveUrl', () => {
23 |         it('produces the right URL in February', () => {
24 |             const url = getArchiveUrl('https://example.com/inbox/asdf-qwer-asdf-qwer', new Date('7 Feb 2062 UTC'))
25 |             expect(url).toEqual('https://example.com/inbox/archive/2062/02/07/asdf-qwer-asdf-qwer')
26 |         })
27 |         it('produces the right URL in November', () => {
28 |             const url = getArchiveUrl('https://example.com/inbox/asdf-qwer-asdf-qwer', new Date('12 Nov 2012 UTC'))
29 |             expect(url).toEqual('https://example.com/inbox/archive/2012/11/12/asdf-qwer-asdf-qwer')
30 |         })
31 |     })
32 | })


--------------------------------------------------------------------------------
/src/logic/solidLogicSingleton.ts:
--------------------------------------------------------------------------------
 1 | import * as debug from '../util/debug'
 2 | import { authSession } from '../authSession/authSession'
 3 | import { createSolidLogic } from './solidLogic'
 4 | import { SolidLogic } from '../types'
 5 | 
 6 | const _fetch = async (url, requestInit) => {
 7 |     const omitCreds = requestInit && requestInit.credentials && requestInit.credentials == 'omit'
 8 |     if (authSession.info.webId && !omitCreds) { // see https://github.com/solidos/solidos/issues/114
 9 |         // In fact fetch should respect credentials omit itself
10 |         return authSession.fetch(url, requestInit)
11 |     } else {
12 |         return window.fetch(url, requestInit)
13 |     }
14 | }
15 | 
16 | // Global singleton pattern to ensure unique store across library versions
17 | const SINGLETON_SYMBOL = Symbol.for('solid-logic-singleton')
18 | 
19 | // Type the global object properly with the singleton
20 | interface GlobalWithSingleton {
21 |     [SINGLETON_SYMBOL]?: SolidLogic
22 | }
23 | 
24 | const globalTarget = (typeof window !== 'undefined' ? window : global) as GlobalWithSingleton
25 | 
26 | function getOrCreateSingleton(): SolidLogic {
27 |     if (!globalTarget[SINGLETON_SYMBOL]) {
28 |         debug.log('SolidLogic: Creating new global singleton instance.')
29 |         globalTarget[SINGLETON_SYMBOL] = createSolidLogic({ fetch: _fetch }, authSession)
30 |         debug.log('Unique quadstore initialized.')
31 |     } else {
32 |         debug.log('SolidLogic: Using existing global singleton instance.')
33 |     }
34 |     return globalTarget[SINGLETON_SYMBOL]!
35 | }
36 | //this const makes solidLogicSingleton global accessible in mashlib
37 | const solidLogicSingleton = getOrCreateSingleton()
38 | 
39 | export { solidLogicSingleton }


--------------------------------------------------------------------------------
/src/util/utils.ts:
--------------------------------------------------------------------------------
 1 | import { NamedNode, sym } from 'rdflib'
 2 | 
 3 | export function newThing(doc: NamedNode): NamedNode {
 4 |     return sym(doc.uri + '#' + 'id' + ('' + Date.now()))
 5 | }
 6 | 
 7 | export function uniqueNodes (arr: NamedNode[]): NamedNode[] {
 8 |     const uris = arr.map(x => x.uri)
 9 |     const set = new Set(uris)
10 |     const uris2 = Array.from(set)
11 |     const arr2 = uris2.map(u => new NamedNode(u))
12 |     return arr2 // Array.from(new Set(arr.map(x => x.uri))).map(u => sym(u))
13 | }
14 | 
15 | export function getArchiveUrl(baseUrl: string, date: Date) {
16 |     const year = date.getUTCFullYear()
17 |     const month = ('0' + (date.getUTCMonth()+1)).slice(-2)
18 |     const day = ('0' + (date.getUTCDate())).slice(-2)
19 |     const parts = baseUrl.split('/')
20 |     const filename = parts[parts.length -1 ]
21 |     return new URL(`./archive/${year}/${month}/${day}/${filename}`, baseUrl).toString()
22 | }
23 | 
24 | export function differentOrigin(doc): boolean {
25 |     if (!doc) {
26 |         return true
27 |     }
28 |     return (
29 |         `${window.location.origin}/` !== new URL(doc.value).origin
30 |     )
31 | }
32 | 
33 | export function suggestPreferencesFile (me:NamedNode) {
34 |     const stripped = me.uri.replace('/profile/', '/').replace('/public/', '/')
35 |     // const stripped = me.uri.replace(\/[p|P]rofile/\g, '/').replace(\/[p|P]ublic/\g, '/')
36 |     const folderURI = stripped.split('/').slice(0,-1).join('/') + '/Settings/'
37 |     const fileURI = folderURI + 'Preferences.ttl'
38 |     return sym(fileURI)
39 | }
40 | 
41 | export function determineChatContainer(
42 |     invitee: NamedNode,
43 |     podRoot: NamedNode
44 | ): NamedNode {
45 |     // Create chat
46 |     // See https://gitter.im/solid/chat-app?at=5f3c800f855be416a23ae74a
47 |     const chatContainerStr = new URL(
48 |         `IndividualChats/${new URL(invitee.value).host}/`,
49 |         podRoot.value
50 |     ).toString()
51 |     return new NamedNode(chatContainerStr)
52 | }
53 | 


--------------------------------------------------------------------------------
/src/util/containerLogic.ts:
--------------------------------------------------------------------------------
 1 | import { NamedNode, Statement, sym } from 'rdflib'
 2 | 
 3 | /**
 4 |  * Container-related class
 5 |  */
 6 | export function createContainerLogic(store) {
 7 | 
 8 |     function getContainerElements(containerNode: NamedNode): NamedNode[] {
 9 |         return store
10 |             .statementsMatching(
11 |                 containerNode,
12 |                 sym('http://www.w3.org/ns/ldp#contains'),
13 |                 undefined
14 |             )
15 |             .map((st: Statement) => st.object as NamedNode)
16 |     }
17 | 
18 |     function isContainer(url: NamedNode) {
19 |         const nodeToString = url.value
20 |         return nodeToString.charAt(nodeToString.length - 1) === '/'
21 |     }
22 | 
23 |     async function createContainer(url: string) {
24 |         const stringToNode = sym(url)
25 |         if (!isContainer(stringToNode)) {
26 |             throw new Error(`Not a container URL ${url}`)
27 |         }
28 |         // Copied from https://github.com/solidos/solid-crud-tests/blob/v3.1.0/test/surface/create-container.test.ts#L56-L64
29 |         const result = await store.fetcher._fetch(url, {
30 |             method: 'PUT',
31 |             headers: {
32 |                 'Content-Type': 'text/turtle',
33 |                 'If-None-Match': '*',
34 |                 Link: '; rel="type"', // See https://github.com/solidos/node-solid-server/issues/1465
35 |             },
36 |             body: ' ', // work around https://github.com/michielbdejong/community-server/issues/4#issuecomment-776222863
37 |         })
38 |         if (result.status.toString()[0] !== '2') {
39 |             throw new Error(`Not OK: got ${result.status} response while creating container at ${url}`)
40 |         }
41 |     }
42 | 
43 |     async function getContainerMembers(containerUrl: NamedNode): Promise {
44 |         await store.fetcher.load(containerUrl)
45 |         return getContainerElements(containerUrl)
46 |     }
47 |     return {
48 |         isContainer,
49 |         createContainer,
50 |         getContainerElements,
51 |         getContainerMembers
52 |     }
53 | }


--------------------------------------------------------------------------------
/src/inbox/inboxLogic.ts:
--------------------------------------------------------------------------------
 1 | import { NamedNode } from 'rdflib'
 2 | import { InboxLogic } from '../types'
 3 | import { getArchiveUrl } from '../util/utils'
 4 | 
 5 | export function createInboxLogic(store, profileLogic, utilityLogic, containerLogic, aclLogic): InboxLogic {
 6 | 
 7 |     async function createInboxFor(peerWebId: string, nick: string) {
 8 |       const myWebId: NamedNode = await profileLogic.loadMe()
 9 |       const podRoot: NamedNode = await profileLogic.getPodRoot(myWebId)
10 |       const ourInbox = `${podRoot.value}p2p-inboxes/${encodeURIComponent(nick)}/`
11 |       await containerLogic.createContainer(ourInbox)
12 |       // const aclDocUrl = await aclLogic.findAclDocUrl(ourInbox);
13 |       await utilityLogic.setSinglePeerAccess({
14 |         ownerWebId: myWebId.value,
15 |         peerWebId,
16 |         accessToModes: 'acl:Append',
17 |         target: ourInbox
18 |       })
19 |       return ourInbox
20 |   }
21 | 
22 |   async function getNewMessages(
23 |       user?: NamedNode
24 |     ): Promise {
25 |       if (!user) {
26 |         user = await profileLogic.loadMe()
27 |       }
28 |       const inbox = await profileLogic.getMainInbox(user)
29 |       const urls = await containerLogic.getContainerMembers(inbox)
30 |       return urls.filter(url => !containerLogic.isContainer(url))
31 |   }
32 | 
33 |   async function markAsRead(url: string, date: Date) {
34 |     const downloaded = await store.fetcher._fetch(url)
35 |     if (downloaded.status !== 200) {
36 |       throw new Error(`Not OK! ${url}`)
37 |     }
38 |     const archiveUrl = getArchiveUrl(url, date)
39 |     const options = {
40 |       method: 'PUT',
41 |       body: await downloaded.text(),
42 |       headers: [
43 |         ['Content-Type', downloaded.headers.get('Content-Type') || 'application/octet-stream']
44 |       ]
45 |     }
46 |     const uploaded = await store.fetcher._fetch(archiveUrl, options)
47 |     if (uploaded.status.toString()[0] === '2') {
48 |       await store.fetcher._fetch(url, {
49 |         method: 'DELETE'
50 |       })
51 |     }
52 |   }
53 |   return {
54 |     createInboxFor,
55 |     getNewMessages,
56 |     markAsRead
57 |   }
58 | }
59 | 


--------------------------------------------------------------------------------
/test/container.test.ts:
--------------------------------------------------------------------------------
 1 | /**
 2 | * @jest-environment jsdom
 3 | * 
 4 | */
 5 | import { UpdateManager, Store, Fetcher, sym } from 'rdflib'
 6 | import { createContainerLogic } from '../src/util/containerLogic'
 7 | import { alice } from './helpers/dataSetup'
 8 | 
 9 | window.$SolidTestEnvironment = { username: alice.uri }
10 | 
11 | describe('Container', () => {
12 |     let store
13 |     let containerLogic
14 |     beforeEach(() => {
15 |         fetchMock.resetMocks()
16 |         store = new Store()
17 |         store.fetcher = new Fetcher(store, { fetch: fetch })
18 |         store.updater = new UpdateManager(store)
19 |         containerLogic = createContainerLogic(store)
20 |     })
21 | 
22 |     it('getContainerMembers - When container has some containment triples', async () => {
23 |             containerHasSomeContainmentTriples()
24 |             const containerMembers = await containerLogic.getContainerMembers(sym('https://container.com/'))
25 |             const result = containerMembers.map(oneResult => oneResult.value)
26 |             expect(result.sort()).toEqual([
27 |                 'https://container.com/foo.txt',
28 |                 'https://container.com/bar/'
29 |             ].sort())
30 |     })
31 |     it.skip('getContainerMembers- When container is empty - Resolves to an empty array', async () => {
32 |         jest.setTimeout(2000)
33 |         containerIsEmpty()
34 |         const result = await containerLogic.getContainerMembers(sym('https://container.com/'))
35 |         expect(result).toEqual([])
36 |     })
37 | 
38 |     function containerIsEmpty() {
39 |         fetchMock.mockOnceIf(
40 |             'https://com/',
41 |             '', // FIXME: https://github.com/jefflau/jest-fetch-mock/issues/189
42 |             {
43 |                 headers: { 'Content-Type': 'text/turtle' },
44 |             }
45 |         )
46 |     }
47 |     
48 |     function containerHasSomeContainmentTriples() {
49 |         fetchMock.mockOnceIf(
50 |             'https://container.com/',
51 |             '<.>  <./foo.txt>, <./bar/> .',
52 |             {
53 |                 headers: { 'Content-Type': 'text/turtle' },
54 |             }
55 |         )
56 |         
57 |     }
58 | })


--------------------------------------------------------------------------------
/src/authn/authUtil.ts:
--------------------------------------------------------------------------------
 1 | import { NamedNode, sym } from 'rdflib'
 2 | import * as debug from '../util/debug'
 3 | 
 4 | /**
 5 |  * find a user or app's context as set in window.SolidAppContext
 6 |  * this is a const, not a function, because we have problems to jest mock it otherwise
 7 |  * see: https://github.com/facebook/jest/issues/936#issuecomment-545080082 for more
 8 |  * @return {any} - an appContext object
 9 |  */
10 | export const appContext = ():any => {
11 |     let { SolidAppContext }: any = window
12 |     SolidAppContext ||= {}
13 |     SolidAppContext.viewingNoAuthPage = false
14 |     if (SolidAppContext.noAuth && window.document) {
15 |         const currentPage = window.document.location.href
16 |         if (currentPage.startsWith(SolidAppContext.noAuth)) {
17 |         SolidAppContext.viewingNoAuthPage = true
18 |         const params = new URLSearchParams(window.document.location.search)
19 |         if (params) {
20 |             let viewedPage = SolidAppContext.viewedPage = params.get('uri') || null
21 |             if (viewedPage) {
22 |             viewedPage = decodeURI(viewedPage)
23 |             if (!viewedPage.startsWith(SolidAppContext.noAuth)) {
24 |                 const ary = viewedPage.split(/\//)
25 |                 SolidAppContext.idp = ary[0] + '//' + ary[2]
26 |                 SolidAppContext.viewingNoAuthPage = false
27 |             }
28 |             }
29 |         }
30 |         }
31 |     }
32 |     return SolidAppContext
33 | }
34 | 
35 | /**
36 |  * Returns `sym($SolidTestEnvironment.username)` if
37 |  * `$SolidTestEnvironment.username` is defined as a global
38 |  * or 
39 |  * returns testID defined in the HTML page
40 |  * @returns {NamedNode|null}
41 |  */
42 | export function offlineTestID (): NamedNode | null {
43 |     const { $SolidTestEnvironment }: any = window
44 |     if (
45 |       typeof $SolidTestEnvironment !== 'undefined' &&
46 |       $SolidTestEnvironment.username
47 |     ) {
48 |       // Test setup
49 |       debug.log('Assuming the user is ' + $SolidTestEnvironment.username)
50 |       return sym($SolidTestEnvironment.username)
51 |     }
52 |     // hack that makes SolidOS work in offline mode by adding the webId directly in html
53 |     // example usage: https://github.com/solidos/mashlib/blob/29b8b53c46bf02e0e219f0bacd51b0e9951001dd/test/contact/local.html#L37
54 |     if (
55 |       typeof document !== 'undefined' &&
56 |       document.location &&
57 |       ('' + document.location).slice(0, 16) === 'http://localhost'
58 |     ) {
59 |       const div = document.getElementById('appTarget')
60 |       if (!div) return null
61 |       const id = div.getAttribute('testID')
62 |       if (!id) return null
63 |       debug.log('Assuming user is ' + id)
64 |       return sym(id)
65 |     }
66 |     return null
67 | }
68 | 


--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "name": "solid-logic",
 3 |   "version": "4.0.1",
 4 |   "description": "Core business logic of SolidOS",
 5 |   "type": "module",
 6 |   "main": "dist/solid-logic.js",
 7 |   "module": "dist/solid-logic.esm.js",
 8 |   "types": "dist/index.d.ts",
 9 |   "exports": {
10 |     ".": {
11 |       "import": "./dist/solid-logic.esm.js",
12 |       "require": "./dist/solid-logic.js",
13 |       "types": "./dist/index.d.ts"
14 |     }
15 |   },
16 |   "sideEffects": false,
17 |   "files": [
18 |     "dist/",
19 |     "README.md",
20 |     "LICENSE"
21 |   ],
22 |   "scripts": {
23 |     "clean": "rm -rf dist src/versionInfo.ts",
24 |     "build": "npm run clean && npm run typecheck && npm run build-version && npm run build-js && npm run build-dist && npm run postbuild-js",
25 |     "build-version": "./timestamp.sh > src/versionInfo.ts  && eslint 'src/versionInfo.ts' --fix",
26 |     "build-js": "tsc",
27 |     "build-dist": "webpack --progress",
28 |     "postbuild-js": "rm -f dist/versionInfo.d.ts dist/versionInfo.d.ts.map",
29 |     "lint": "eslint",
30 |     "typecheck": "tsc --noEmit",
31 |     "typecheck-test": "tsc --noEmit -p tsconfig.test.json",
32 |     "test": "jest --no-coverage",
33 |     "test-coverage": "jest --coverage",
34 |     "test-debug": "node --inspect-brk ./node_modules/.bin/jest -i --env jest-environment-node-debug",
35 |     "prepublishOnly": "npm run build && npm run lint && npm run test",
36 |     "preversion": "npm run lint && npm run typecheck && npm test",
37 |     "postversion": "git push origin main --follow-tags",
38 |     "watch": "npm run build-version && tsc --watch"
39 |   },
40 |   "repository": {
41 |     "type": "git",
42 |     "url": "git+https://github.com/solidos/solid-logic.git"
43 |   },
44 |   "author": "",
45 |   "license": "MIT",
46 |   "bugs": {
47 |     "url": "https://github.com/solidos/solid-logic/issues"
48 |   },
49 |   "homepage": "https://github.com/solidos/solid-logic#readme",
50 |   "devDependencies": {
51 |     "@babel/core": "^7.28.4",
52 |     "@babel/preset-env": "^7.28.3",
53 |     "@babel/preset-typescript": "^7.27.1",
54 |     "@types/jest": "^30.0.0",
55 |     "@typescript-eslint/parser": "^8.34.0",
56 |     "babel-jest": "^30.1.2",
57 |     "babel-loader": "^10.0.0",
58 |     "eslint": "^9.38.0",
59 |     "eslint-config-prettier": "^10.1.8",
60 |     "eslint-plugin-import": "^2.32.0",
61 |     "jest": "^30.2.0",
62 |     "jest-environment-jsdom": "^30.0.2",
63 |     "jest-fetch-mock": "^3.0.3",
64 |     "terser-webpack-plugin": "^5.3.14",
65 |     "ts-loader": "^9.5.4",
66 |     "tslib": "^2.8.1",
67 |     "typescript": "^5.9.2",
68 |     "webpack": "^5.101.3",
69 |     "webpack-cli": "^6.0.1"
70 |   },
71 |   "dependencies": {
72 |     "@inrupt/solid-client-authn-browser": "^3.1.0",
73 |     "solid-namespace": "^0.5.4"
74 |   },
75 |   "peerDependencies": {
76 |     "rdflib": "^2.3.0"
77 |   }
78 | }
79 | 


--------------------------------------------------------------------------------
/webpack.config.mjs:
--------------------------------------------------------------------------------
  1 | import path from 'path'
  2 | import TerserPlugin from 'terser-webpack-plugin'
  3 | 
  4 | 
  5 | const externalsBase = {
  6 |   'fs': 'null',
  7 |   'node-fetch': 'fetch',
  8 |   'isomorphic-fetch': 'fetch',
  9 |   'text-encoding': 'TextEncoder',
 10 |   '@trust/webcrypto': 'crypto',
 11 |   '@xmldom/xmldom': 'window',
 12 |   'whatwg-url': 'URL',
 13 |   'rdflib': '$rdf'
 14 | }
 15 | 
 16 | const externalsESM = {
 17 |   ...externalsBase,
 18 |   'rdflib': 'rdflib'
 19 | }
 20 | 
 21 | const commonConfig = {
 22 |   mode: 'production',
 23 |   entry: './src/index.ts',
 24 | 
 25 |   module: {
 26 |     rules: [
 27 |       {
 28 |         test: /\.ts$/,
 29 |         use: 'ts-loader',
 30 |         exclude: /node_modules/
 31 |       }
 32 |     ]
 33 |   },
 34 |   resolve: {
 35 |     extensions: ['.ts', '.js']
 36 |   },
 37 |   devtool: 'source-map',
 38 | };
 39 | 
 40 | export default [
 41 |   // UMD with rdflib as external
 42 |   {
 43 |     ...commonConfig,
 44 |     output: {
 45 |       path: path.resolve(process.cwd(), 'dist'),
 46 |       filename: 'solid-logic.js',
 47 |       library: {
 48 |         name: 'SolidLogic',
 49 |         type: 'umd',
 50 |         umdNamedDefine: true
 51 |       },
 52 |       globalObject: 'this',
 53 |       clean: false
 54 |     },
 55 |     externals: externalsBase,
 56 |     optimization: {
 57 |       minimize: false
 58 |     }
 59 |   },
 60 |   // Minified UMD with rdflib as external
 61 |   {
 62 |     ...commonConfig,
 63 |     output: {
 64 |       path: path.resolve(process.cwd(), 'dist'),
 65 |       filename: 'solid-logic.min.js',
 66 |       library: {
 67 |         name: 'SolidLogic',
 68 |         type: 'umd',
 69 |         umdNamedDefine: true
 70 |       },
 71 |       globalObject: 'this',
 72 |       clean: false
 73 |     },
 74 |     externals: externalsBase,
 75 |     optimization: {
 76 |       minimize: true,
 77 |       minimizer: [new TerserPlugin({ extractComments: false })]
 78 |     }
 79 |   },
 80 |   // Unminified ESM with rdflib as external
 81 |   {
 82 |     ...commonConfig,
 83 |     output: {
 84 |       path: path.resolve(process.cwd(), 'dist'),
 85 |       filename: 'solid-logic.esm.js',
 86 |       library: {
 87 |         type: 'module'
 88 |       },
 89 |       environment: { module: true },
 90 |       clean: false
 91 |     },
 92 |     externals: externalsESM,
 93 |     experiments: {
 94 |       outputModule: true
 95 |     },
 96 |     optimization: {
 97 |       minimize: false
 98 |     }
 99 |   },
100 |   // Minified ESM with rdflib as external
101 |   {
102 |     ...commonConfig,
103 |     output: {
104 |       path: path.resolve(process.cwd(), 'dist'),
105 |       filename: 'solid-logic.esm.min.js',
106 |       library: {
107 |         type: 'module'
108 |       },
109 |       environment: { module: true },
110 |       clean: false
111 |     },
112 |     externals: externalsESM,
113 |     experiments: {
114 |       outputModule: true
115 |     },
116 |     optimization: {
117 |       minimize: true,
118 |       minimizer: [new TerserPlugin({ extractComments: false })]
119 |     }
120 |   }
121 | ]
122 | 


--------------------------------------------------------------------------------
/src/logic/solidLogic.ts:
--------------------------------------------------------------------------------
 1 | import { Session } from '@inrupt/solid-client-authn-browser'
 2 | import * as rdf from 'rdflib'
 3 | import { LiveStore, NamedNode, Statement } from 'rdflib'
 4 | import { createAclLogic } from '../acl/aclLogic'
 5 | import { SolidAuthnLogic } from '../authn/SolidAuthnLogic'
 6 | import { createChatLogic } from '../chat/chatLogic'
 7 | import { createInboxLogic } from '../inbox/inboxLogic'
 8 | import { createProfileLogic } from '../profile/profileLogic'
 9 | import { createTypeIndexLogic } from '../typeIndex/typeIndexLogic'
10 | import { createContainerLogic } from '../util/containerLogic'
11 | import { createUtilityLogic } from '../util/utilityLogic'
12 | import { AuthnLogic, SolidLogic } from '../types'
13 | import * as debug from '../util/debug'
14 | /*
15 | ** It is important to distinquish `fetch`, a function provided by the browser
16 | ** and `Fetcher`, a helper object for the rdflib Store which turns it
17 | ** into a `ConnectedStore` or a `LiveStore`.  A Fetcher object is
18 | ** available at store.fetcher, and `fetch` function at `store.fetcher._fetch`,
19 | */
20 | export function createSolidLogic(specialFetch: { fetch: (url: any, requestInit: any) => any }, session: Session): SolidLogic {
21 | 
22 |     debug.log('SolidLogic: Unique instance created.  There should only be one of these.')
23 |     const store: LiveStore = rdf.graph() as LiveStore
24 |     rdf.fetcher(store, {fetch: specialFetch.fetch}) // Attach a web I/O module, store.fetcher
25 |     store.updater = new rdf.UpdateManager(store) // Add real-time live updates store.updater
26 |     store.features = [] // disable automatic node merging on store load
27 | 
28 |     const authn: AuthnLogic = new SolidAuthnLogic(session)
29 |     
30 |     const acl = createAclLogic(store)
31 |     const containerLogic = createContainerLogic(store)
32 |     const utilityLogic = createUtilityLogic(store, acl, containerLogic)
33 |     const profile = createProfileLogic(store, authn, utilityLogic)
34 |     const chat = createChatLogic(store, profile)
35 |     const inbox = createInboxLogic(store, profile, utilityLogic, containerLogic, acl)
36 |     const typeIndex = createTypeIndexLogic(store, authn, profile, utilityLogic)
37 |     debug.log('SolidAuthnLogic initialized')
38 | 
39 |     function load(doc: NamedNode | NamedNode[] | string) {
40 |         return store.fetcher.load(doc)
41 |     }
42 | 
43 |     // @@@@ use the one in rdflib.js when it is available and delete this
44 |     function updatePromise(
45 |         del: Array,
46 |         ins: Array = []
47 |     ): Promise {
48 |         return new Promise((resolve, reject) => {
49 |         store.updater.update(del, ins, function (_uri, ok, errorBody) {
50 |             if (!ok) {
51 |             reject(new Error(errorBody))
52 |             } else {
53 |             resolve()
54 |             }
55 |         }) // callback
56 |         }) // promise
57 |     }
58 | 
59 |     function clearStore() {
60 |         store.statements.slice().forEach(store.remove.bind(store))
61 |     }
62 | 
63 |     return {
64 |         store,
65 |         authn,
66 |         acl,
67 |         inbox,
68 |         chat,
69 |         profile,
70 |         typeIndex,
71 |         load,
72 |         updatePromise,
73 |         clearStore
74 |     }
75 | }
76 | 


--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
 3 | 
 4 | name: CI
 5 | permissions:
 6 |   contents: write
 7 |   pull-requests: write
 8 | on:
 9 |   push:
10 |     branches:
11 |       - main
12 |   pull_request:
13 |     branches:
14 |       - main
15 |   workflow_dispatch:
16 | 
17 | jobs:
18 |   build:
19 | 
20 |     runs-on: ubuntu-latest
21 | 
22 |     strategy:
23 |       matrix:
24 |         node-version: 
25 |           - 20.x
26 |           - 22.x
27 | 
28 |     steps:
29 |       - uses: actions/checkout@v6
30 |       - name: Use Node.js ${{ matrix.node-version }}
31 |         uses: actions/setup-node@v6
32 |         with:
33 |           node-version: ${{ matrix.node-version }}
34 |       - run: npm ci
35 |       - run: npm run lint --if-present
36 |       - run: npm test
37 |       - run: npm run build --if-present
38 |       - name: Save build
39 |         if: matrix.node-version == '20.x'
40 |         uses: actions/upload-artifact@v6
41 |         with:
42 |           name: build
43 |           path: |
44 |             .
45 |             !node_modules
46 |           retention-days: 1
47 |   
48 |   dependabot:
49 |     name: 'Dependabot'
50 |     needs: build # After the E2E and build jobs, if one of them fails, it won't merge the PR.
51 |     runs-on: ubuntu-latest
52 |     if: ${{ github.actor == 'dependabot[bot]' && github.event_name == 'pull_request'}} # Detect that the PR author is dependabot
53 |     steps:
54 |       - name: Enable auto-merge for Dependabot PRs
55 |         run: gh pr merge --auto --merge "$PR_URL" # Use Github CLI to merge automatically the PR
56 |         env:
57 |           PR_URL: ${{github.event.pull_request.html_url}}
58 |           GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
59 | 
60 |   npm-publish-build:
61 |     needs: build
62 |     runs-on: ubuntu-latest
63 |     steps:
64 |       - uses: actions/download-artifact@v7
65 |         with:
66 |           name: build
67 |       - uses: actions/setup-node@v6
68 |         with:
69 |           node-version: 20.x
70 |       - uses: rlespinasse/github-slug-action@v4.x
71 |       - name: Append commit hash to package version
72 |         run: 'sed -i -E "s/(\"version\": *\"[^\"]+)/\1-${GITHUB_SHA_SHORT}/" package.json'
73 |       - name: Disable pre- and post-publish actions
74 |         run: 'sed -i -E "s/\"((pre|post)publish)/\"ignore:\1/" package.json'
75 |       - uses: JS-DevTools/npm-publish@v4.1.0
76 |         if: github.actor != 'dependabot[bot]' && github.actor != 'dependabot-preview[bot]'
77 |         with:
78 |           token: ${{ secrets.NPM_TOKEN }}
79 |           tag: ${{ env.GITHUB_REF_SLUG }}
80 |           
81 |   npm-publish-latest:
82 |     needs: [build, npm-publish-build]
83 |     runs-on: ubuntu-latest
84 |     if: github.ref == 'refs/heads/main'
85 |     steps:
86 |       - uses: actions/download-artifact@v7
87 |         with:
88 |           name: build
89 |       - uses: actions/setup-node@v6
90 |         with:
91 |           node-version: 20.x
92 |       - name: Disable pre- and post-publish actions
93 |         run: 'sed -i -E "s/\"((pre|post)publish)/\"ignore:\1/" package.json'
94 |       - uses: JS-DevTools/npm-publish@v4.1.0
95 |         if: github.actor != 'dependabot[bot]' && github.actor != 'dependabot-preview[bot]'
96 |         with:
97 |           token: ${{ secrets.NPM_TOKEN }}
98 |           tag: latest
99 | 


--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
  1 | # solid-logic
  2 | 
  3 | [Solid logo]
  4 | 
  5 | [![MIT license](https://img.shields.io/github/license/solidos/solidos)](https://github.com/solidos/solidos/blob/main/LICENSE.md)
  6 | 
  7 | 
  8 | Core business logic of SolidOS which can be used for any webapp as well.
  9 | 
 10 | 
 11 | # Usage
 12 | 
 13 | ## 📦 Install via npm
 14 | 
 15 | ```sh
 16 | npm install solid-logic rdflib
 17 | ```
 18 | 
 19 | > **Important**: `rdflib` is a peer dependency - you must install it separately.
 20 | 
 21 | ### Import in your project (ESM/TypeScript)
 22 | 
 23 | ```js
 24 | import { solidLogicSingleton, store, authn } from 'solid-logic';
 25 | 
 26 | // Example usage
 27 | console.log('Current user:', authn.currentUser());
 28 | ```
 29 | 
 30 | ## 🌐 Use directly in a browser
 31 | 
 32 | Both UMD and ESM bundles externalize rdflib to keep bundle sizes small and avoid version conflicts.
 33 | 
 34 | ## Available Files
 35 | 
 36 | | Format | File | Usage | Global Variable |
 37 | |--------|------|-------|----------------|
 38 | | UMD | `dist/solid-logic.js` | Development | `window.SolidLogic` |
 39 | | UMD | `dist/solid-logic.min.js` | Production | `window.SolidLogic` |
 40 | | ESM | `dist/solid-logic.esm.js` | Development | Import only |
 41 | | ESM | `dist/solid-logic.esm.min.js` | Production | Import only |
 42 | 
 43 | ### UMD Bundle (Script Tags)
 44 | 
 45 | **⚠️ Important**: Load rdflib **before** solid-logic or you'll get `$rdf is not defined` errors.
 46 | 
 47 | ```html
 48 | 
 49 | 
 50 | 
 51 | 
 52 | 
 53 | 
 54 | 
 62 | ```
 63 | 
 64 | ### ESM Bundle (Native Modules)
 65 | 
 66 | ```html
 67 | 
 75 | ```
 76 | 
 77 | ### ESM with Import Maps (Recommended)
 78 | 
 79 | ```html
 80 | 
 88 | 
 96 | ```
 97 | 
 98 | ## Common Exports
 99 | 
100 | ```js
101 | import { 
102 | 	solidLogicSingleton,  // Complete singleton instance
103 | 	store,                // RDF store
104 | 	authn,                // Authentication logic
105 | 	authSession,          // Authentication session
106 | 	ACL_LINK,            // ACL constants
107 | 	getSuggestedIssuers,  // Identity provider suggestions
108 | 	createTypeIndexLogic, // Type index functionality
109 | 	// Error classes
110 | 	UnauthorizedError,
111 | 	NotFoundError,
112 | 	FetchError
113 | } from 'solid-logic';
114 | ```
115 | 
116 | # How to develop
117 | 
118 | Check the scripts in the `package.json` for build, watch, lint and test.
119 | 
120 | # Used stack
121 | 
122 | * TypeScript + Babel
123 | * Jest
124 | * ESLint
125 | * Webpack
126 | 
127 | # How to release
128 | 
129 | Change version and push directly to main. This will trigger the npm release latest script in CI.
130 | 
131 | # History
132 | 
133 | Solid-logic was a move to separate business logic from UI functionality so that people using different UI frameworks could use logic code. 
134 | 
135 | It was created when the "chat with me" feature was built. We needed to share logic between chat-pane and profile-pane (which was part of solid-panes back then I think) due to that feature. The whole idea of it is to separate core solid logic from UI components, to make logic reusable, e.g. by other UI libraries or even non web apps like CLI tools etc. 
136 | 


--------------------------------------------------------------------------------
/src/authn/SolidAuthnLogic.ts:
--------------------------------------------------------------------------------
  1 | import { namedNode, NamedNode, sym } from 'rdflib'
  2 | import { appContext, offlineTestID } from './authUtil'
  3 | import * as debug from '../util/debug'
  4 | import { EVENTS, Session } from '@inrupt/solid-client-authn-browser'
  5 | import { AuthenticationContext, AuthnLogic } from '../types'
  6 | 
  7 | export class SolidAuthnLogic implements AuthnLogic {
  8 |   private session: Session
  9 | 
 10 |   constructor(solidAuthSession: Session) {
 11 |     this.session = solidAuthSession
 12 |   }
 13 | 
 14 |   // we created authSession getter because we want to access it as authn.authSession externally
 15 |   get authSession():Session { return this.session }
 16 | 
 17 |   currentUser(): NamedNode | null {
 18 |     const app = appContext()
 19 |     if (app.viewingNoAuthPage) {
 20 |       return sym(app.webId)
 21 |     }
 22 |     if (this && this.session && this.session.info && this.session.info.webId && this.session.info.isLoggedIn) {
 23 |       return sym(this.session.info.webId)
 24 |     }
 25 |     return offlineTestID() // null unless testing
 26 |   }
 27 | 
 28 |   /**
 29 |    * Retrieves currently logged in webId from either
 30 |    * defaultTestUser or SolidAuth
 31 |    * Also activates a session after login
 32 |    * @param [setUserCallback] Optional callback
 33 |    * @returns Resolves with webId uri, if no callback provided
 34 |    */
 35 |   async checkUser (
 36 |     setUserCallback?: (me: NamedNode | null) => T
 37 |   ): Promise {
 38 |     // Save hash for "restorePreviousSession"
 39 |     const preLoginRedirectHash = new URL(window.location.href).hash
 40 |     if (preLoginRedirectHash) {
 41 |       window.localStorage.setItem('preLoginRedirectHash', preLoginRedirectHash)
 42 |     }
 43 |     this.session.events.on(EVENTS.SESSION_RESTORED, (url) => {
 44 |       debug.log(`Session restored to ${url}`)
 45 |       if (document.location.toString() !== url) history.replaceState(null, '', url)
 46 |     })
 47 | 
 48 |     /**
 49 |      * Handle a successful authentication redirect
 50 |      */
 51 |     const redirectUrl = new URL(window.location.href)
 52 |     redirectUrl.hash = ''
 53 |     await this.session
 54 |       .handleIncomingRedirect({
 55 |         restorePreviousSession: true,
 56 |         url: redirectUrl.href
 57 |       })
 58 | 
 59 |     // Check to see if a hash was stored in local storage
 60 |     const postLoginRedirectHash = window.localStorage.getItem('preLoginRedirectHash')
 61 |     if (postLoginRedirectHash) {
 62 |       const curUrl = new URL(window.location.href)
 63 |       if (curUrl.hash !== postLoginRedirectHash) {
 64 |         if (history.pushState) {
 65 |           // debug.log('Setting window.location.has using pushState')
 66 |           history.pushState(null, document.title, postLoginRedirectHash)
 67 |         } else {
 68 |           // debug.warn('Setting window.location.has using location.hash')
 69 |           location.hash = postLoginRedirectHash
 70 |         }
 71 |         curUrl.hash = postLoginRedirectHash
 72 |       }
 73 |       // See https://stackoverflow.com/questions/3870057/how-can-i-update-window-location-hash-without-jumping-the-document
 74 |       // window.location.href = curUrl.toString()// @@ See https://developer.mozilla.org/en-US/docs/Web/API/Window/location
 75 |       window.localStorage.setItem('preLoginRedirectHash', '')
 76 |     }
 77 | 
 78 |     // Check to see if already logged in / have the WebID
 79 |     let me = offlineTestID()
 80 |     if (me) {
 81 |       return Promise.resolve(setUserCallback ? setUserCallback(me) : me)
 82 |     }
 83 | 
 84 |     const webId = this.webIdFromSession(this.session.info)
 85 |     if (webId) {
 86 |       me = this.saveUser(webId)
 87 |     }
 88 | 
 89 |     if (me) {
 90 |       debug.log(`(Logged in as ${me} by authentication)`)
 91 |     }
 92 | 
 93 |     return Promise.resolve(setUserCallback ? setUserCallback(me) : me)
 94 |   }
 95 | 
 96 |   /**
 97 |    * Saves `webId` in `context.me`
 98 |    * @param webId
 99 |    * @param context
100 |    *
101 |    * @returns Returns the WebID, after setting it
102 |    */
103 |   saveUser (
104 |     webId: NamedNode | string | null,
105 |     context?: AuthenticationContext
106 |   ): NamedNode | null {
107 |     let webIdUri: string
108 |     if (webId) {
109 |       webIdUri = (typeof webId === 'string') ? webId : webId.uri
110 |       const me = namedNode(webIdUri)
111 |       if (context) {
112 |         context.me = me
113 |       }
114 |       return me
115 |     }
116 |     return null
117 |   }
118 | 
119 |   /**
120 |    * @returns {Promise} Resolves with WebID URI or null
121 |    */
122 |   webIdFromSession (session?: { webId?: string, isLoggedIn: boolean }): string | null {
123 |     const webId = session?.webId && session.isLoggedIn ? session.webId : null
124 |     return webId
125 |   }
126 | 
127 | }
128 | 


--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
  1 | import { Session } from '@inrupt/solid-client-authn-browser'
  2 | import { LiveStore, NamedNode, Statement } from 'rdflib'
  3 | 
  4 | export type AppDetails = {
  5 |     noun: string
  6 |     appPathSegment: string
  7 | }
  8 | 
  9 | export type AuthenticationContext = {
 10 |     containers?: Array
 11 |     div?: HTMLElement
 12 |     dom?: HTMLDocument
 13 |     index?: { [key: string]: Array }
 14 |     instances?: Array
 15 |     me?: NamedNode | null
 16 |     noun?: string
 17 |     preferencesFile?: NamedNode
 18 |     preferencesFileError?: string
 19 |     publicProfile?: NamedNode
 20 |     statusArea?: HTMLElement
 21 | }
 22 | 
 23 | export interface AuthnLogic {
 24 |     authSession: Session //this needs to be deprecated in the future. Is only here to allow imports like panes.UI.authn.authSession prior to moving authn from ui to logic
 25 |     currentUser: () => NamedNode | null
 26 |     checkUser: (setUserCallback?: (me: NamedNode | null) => T) => Promise
 27 |     saveUser: (webId: NamedNode | string | null,
 28 |         context?: AuthenticationContext) => NamedNode | null
 29 | }
 30 | 
 31 | export interface SolidNamespace {
 32 |     [key: string]: (term: string) => NamedNode
 33 | }
 34 | 
 35 | export type TypeIndexScope = { label: string, index: NamedNode, agent: NamedNode }
 36 | export type ScopedApp = { instance: NamedNode, type: NamedNode, scope: TypeIndexScope }
 37 | 
 38 | export interface NewPaneOptions {
 39 |     me?: NamedNode;
 40 |     newInstance?: NamedNode;
 41 |     newBase: string;
 42 | }
 43 | 
 44 | export interface CreatedPaneOptions {
 45 | newInstance: NamedNode;
 46 | }
 47 | 
 48 | export interface ChatLogic {
 49 |     setAcl: (chatContainer: NamedNode, me: NamedNode, invitee: NamedNode) => Promise,
 50 |     addToPrivateTypeIndex: (chatThing, me) => void | Promise,
 51 |     findChat: (invitee: NamedNode) => Promise,
 52 |     createChatThing: (chatContainer: NamedNode, me: NamedNode) => Promise,
 53 |     mintNew: (newPaneOptions: NewPaneOptions) => Promise,
 54 |     getChat: (invitee: NamedNode, boolean) => Promise,
 55 |     sendInvite: (invitee: NamedNode, chatThing: NamedNode) => void
 56 | }
 57 | 
 58 | export interface Chat {
 59 |     me: NamedNode,
 60 |     chatContainer: NamedNode,
 61 |     exists: boolean
 62 | }
 63 | 
 64 | export interface ProfileLogic {
 65 |     silencedLoadPreferences: (user: NamedNode) => Promise,
 66 |     loadPreferences: (user: NamedNode) => Promise,
 67 |     loadProfile: (user: NamedNode) => Promise,
 68 |     loadMe: () => Promise,
 69 |     getPodRoot: (user: NamedNode) => NamedNode,
 70 |     getMainInbox: (user: NamedNode) => Promise,
 71 |     findStorage: (me: NamedNode) => Node | null
 72 | }
 73 | 
 74 | export interface AclLogic {
 75 |     findAclDocUrl: (url: NamedNode) => Promise,
 76 |     setACLUserPublic: (docURI: string, me: NamedNode,
 77 |         options: {
 78 |             defaultForNew?: boolean,
 79 |             public?: []
 80 |         }
 81 |     ) => Promise,
 82 |     genACLText: (docURI: string, me: NamedNode, aclURI: string,
 83 |         options: {
 84 |             defaultForNew?: boolean,
 85 |             public?: []
 86 |         }
 87 |     ) => string | undefined
 88 | }
 89 | 
 90 | export interface InboxLogic {
 91 |     createInboxFor: (peerWebId: string, nick: string) => Promise,
 92 |     getNewMessages: (user?: NamedNode) => Promise,
 93 |     markAsRead: (url: string, date: Date) => void
 94 | }
 95 | 
 96 | export interface TypeIndexLogic {
 97 |     getRegistrations: (instance, theClass) => Node[],
 98 |     loadTypeIndexesFor: (user: NamedNode) => Promise>,
 99 |     loadCommunityTypeIndexes: (user: NamedNode) => Promise,
100 |     loadAllTypeIndexes: (user: NamedNode) => Promise>,
101 |     getScopedAppInstances: (klass: NamedNode, user: NamedNode) => Promise,
102 |     getAppInstances: (klass: NamedNode) => Promise,
103 |     suggestPublicTypeIndex: (me: NamedNode) => NamedNode,
104 |     suggestPrivateTypeIndex: (preferencesFile: NamedNode) => NamedNode,
105 |     registerInTypeIndex: (instance: NamedNode, index: NamedNode, theClass: NamedNode) => Promise,
106 |     deleteTypeIndexRegistration: (item: any) => Promise
107 |     getScopedAppsFromIndex: (scope: TypeIndexScope, theClass: NamedNode | null) => Promise
108 | }
109 | 
110 | export interface SolidLogic {
111 |     store: LiveStore,
112 |     authn: AuthnLogic,
113 |     acl: AclLogic,
114 |     profile: ProfileLogic,
115 |     inbox: InboxLogic,
116 |     typeIndex: TypeIndexLogic,
117 |     chat: ChatLogic,
118 |     load: (doc: NamedNode | NamedNode[] | string) => void,
119 |     updatePromise: (del: Array, ins: Array) => Promise,
120 |     clearStore: () => void
121 | }
122 | 


--------------------------------------------------------------------------------
/src/acl/aclLogic.ts:
--------------------------------------------------------------------------------
  1 | import { graph, NamedNode, Namespace, serialize, sym } from 'rdflib'
  2 | import { AclLogic } from '../types'
  3 | import { ns as namespace } from '../util/ns'
  4 | 
  5 | 
  6 | export const ACL_LINK = sym(
  7 |     'http://www.iana.org/assignments/link-relations/acl'
  8 | )
  9 | 
 10 | export function createAclLogic(store): AclLogic {
 11 | 
 12 |     const ns = namespace
 13 |     
 14 |     async function findAclDocUrl(url: NamedNode) {
 15 |         await store.fetcher.load(url)
 16 |         const docNode = store.any(url, ACL_LINK)
 17 |         if (!docNode) {
 18 |             throw new Error(`No ACL link discovered for ${url}`)
 19 |         }
 20 |         return docNode.value
 21 |     }
 22 |     /**
 23 |      * Simple Access Control
 24 |      *
 25 |      * This function sets up a simple default ACL for a resource, with
 26 |      * RWC for the owner, and a specified access (default none) for the public.
 27 |      * In all cases owner has read write control.
 28 |      * Parameter lists modes allowed to public
 29 |      *
 30 |      * @param options
 31 |      * @param options.public eg ['Read', 'Write']
 32 |      *
 33 |      * @returns Resolves with aclDoc uri on successful write
 34 |      */
 35 |     function setACLUserPublic ( 
 36 |     docURI: string,
 37 |     me: NamedNode,
 38 |     options: {
 39 |         defaultForNew?: boolean,
 40 |         public?: []
 41 |     }
 42 |     ): Promise {
 43 |     const aclDoc = store.any(
 44 |         store.sym(docURI),
 45 |         ACL_LINK
 46 |     )
 47 | 
 48 |     return Promise.resolve()
 49 |         .then(() => {
 50 |         if (aclDoc) {
 51 |             return aclDoc as NamedNode
 52 |         }
 53 | 
 54 |         return fetchACLRel(docURI).catch(err => {
 55 |             throw new Error(`Error fetching rel=ACL header for ${docURI}: ${err}`)
 56 |         })
 57 |         })
 58 |         .then(aclDoc => {
 59 |         const aclText = genACLText(docURI, me, aclDoc.uri, options)
 60 |         if (!store.fetcher) {
 61 |             throw new Error('Cannot PUT this, store has no fetcher')
 62 |         }
 63 |         return store.fetcher
 64 |             .webOperation('PUT', aclDoc.uri, {
 65 |             data: aclText,
 66 |             contentType: 'text/turtle'
 67 |             })
 68 |             .then(result => {
 69 |             if (!result.ok) {
 70 |                 throw new Error('Error writing ACL text: ' + result.error)
 71 |             }
 72 | 
 73 |             return aclDoc
 74 |             })
 75 |         })
 76 |     }
 77 | 
 78 |     /**
 79 |      * @param docURI
 80 |      * @returns
 81 |      */
 82 |     function fetchACLRel (docURI: string): Promise {
 83 |         const fetcher = store.fetcher
 84 |         if (!fetcher) {
 85 |             throw new Error('Cannot fetch ACL rel, store has no fetcher')
 86 |         }
 87 | 
 88 |         return fetcher.load(docURI).then(result => {
 89 |             if (!result.ok) {
 90 |                 throw new Error('fetchACLRel: While loading:' + (result as any).error)
 91 |             }
 92 | 
 93 |             const aclDoc = store.any(
 94 |             store.sym(docURI),
 95 |             ACL_LINK
 96 |             )
 97 | 
 98 |             if (!aclDoc) {
 99 |             throw new Error('fetchACLRel: No Link rel=ACL header for ' + docURI)
100 |             }
101 | 
102 |             return aclDoc as NamedNode
103 |         })
104 |     }
105 | 
106 |     /**
107 |      * @param docURI
108 |      * @param me
109 |      * @param aclURI
110 |      * @param options
111 |      *
112 |      * @returns Serialized ACL
113 |      */
114 |     function genACLText (
115 |     docURI: string,
116 |     me: NamedNode,
117 |     aclURI: string,
118 |     options: {
119 |             defaultForNew?: boolean,
120 |             public?: []
121 |         } = {}
122 |     ): string | undefined {
123 |         const optPublic = options.public || []
124 |         const g = graph()
125 |         const auth = Namespace('http://www.w3.org/ns/auth/acl#')
126 |         let a = g.sym(`${aclURI}#a1`)
127 |         const acl = g.sym(aclURI)
128 |         const doc = g.sym(docURI)
129 |         g.add(a, ns.rdf('type'), auth('Authorization'), acl)
130 |         g.add(a, auth('accessTo'), doc, acl)
131 |         if (options.defaultForNew) {
132 |             g.add(a, auth('default'), doc, acl)
133 |         }
134 |         g.add(a, auth('agent'), me, acl)
135 |         g.add(a, auth('mode'), auth('Read'), acl)
136 |         g.add(a, auth('mode'), auth('Write'), acl)
137 |         g.add(a, auth('mode'), auth('Control'), acl)
138 | 
139 |         if (optPublic.length) {
140 |             a = g.sym(`${aclURI}#a2`)
141 |             g.add(a, ns.rdf('type'), auth('Authorization'), acl)
142 |             g.add(a, auth('accessTo'), doc, acl)
143 |             g.add(a, auth('agentClass'), ns.foaf('Agent'), acl)
144 |             for (let p = 0; p < optPublic.length; p++) {
145 |             g.add(a, auth('mode'), auth(optPublic[p]), acl) // Like 'Read' etc
146 |             }
147 |         }
148 |         return serialize(acl, g, aclURI)
149 |     }
150 |     return {
151 |         findAclDocUrl,
152 |         setACLUserPublic,
153 |         genACLText
154 |     }
155 | }


--------------------------------------------------------------------------------
/src/profile/profileLogic.ts:
--------------------------------------------------------------------------------
  1 | import { NamedNode } from 'rdflib'
  2 | import { CrossOriginForbiddenError, FetchError, NotEditableError, SameOriginForbiddenError, UnauthorizedError, WebOperationError } from '../logic/CustomError'
  3 | import * as debug from '../util/debug'
  4 | import { ns as namespace } from '../util/ns'
  5 | import { differentOrigin, suggestPreferencesFile } from '../util/utils'
  6 | import { ProfileLogic } from '../types'
  7 | 
  8 | export function createProfileLogic(store, authn, utilityLogic): ProfileLogic {
  9 |     const ns = namespace
 10 | 
 11 |     /**
 12 |      * loads the preference without throwing errors - if it can create it it does so.
 13 |      * remark: it still throws error if it cannot load profile.
 14 |      * @param user
 15 |      * @returns undefined if preferenceFile cannot be returned or NamedNode if it can find it or create it
 16 |      */
 17 |     async function silencedLoadPreferences(user: NamedNode): Promise  {
 18 |         try {
 19 |             return await loadPreferences(user)
 20 |         } catch (err) {
 21 |             return undefined
 22 |         }
 23 |     }
 24 | 
 25 |     /**
 26 |      * loads the preference without returning different errors if it cannot create or load it.
 27 |      * remark: it also throws error if it cannot load profile.
 28 |      * @param user
 29 |      * @returns undefined if preferenceFile cannot be an Error or NamedNode if it can find it or create it
 30 |      */
 31 |     async function loadPreferences (user: NamedNode): Promise  {
 32 |         await loadProfile(user)
 33 | 
 34 |         const possiblePreferencesFile = suggestPreferencesFile(user)
 35 |         let preferencesFile
 36 |         try {
 37 |             preferencesFile = await utilityLogic.followOrCreateLink(user, ns.space('preferencesFile') as NamedNode, possiblePreferencesFile, user.doc())
 38 |         } catch (err) {
 39 |             const message = `User ${user} has no pointer in profile to preferences file.`
 40 |             debug.warn(message)
 41 |             // we are listing the possible errors
 42 |             if (err instanceof NotEditableError) { throw err }
 43 |             if (err instanceof WebOperationError) { throw err }
 44 |             if (err instanceof UnauthorizedError) { throw err }
 45 |             if (err instanceof CrossOriginForbiddenError) { throw err }
 46 |             if (err instanceof SameOriginForbiddenError) { throw err }
 47 |             if (err instanceof FetchError) { throw err }
 48 |             throw err
 49 |         }
 50 | 
 51 |         try {
 52 |             await store.fetcher.load(preferencesFile as NamedNode)
 53 |         } catch (err) { // Maybe a permission problem or origin problem
 54 |             const msg = `Unable to load preference of user ${user}: ${err}`
 55 |             debug.warn(msg)
 56 |             if (err.response.status === 401) {
 57 |                 throw new UnauthorizedError()
 58 |             }
 59 |             if (err.response.status === 403) {
 60 |                 if (differentOrigin(preferencesFile)) {
 61 |                 throw new CrossOriginForbiddenError()
 62 |                 }
 63 |                 throw new SameOriginForbiddenError()
 64 |             }
 65 |             /*if (err.response.status === 404) {
 66 |                 throw new NotFoundError();
 67 |             }*/
 68 |             throw new Error(msg)
 69 |         }
 70 |         return preferencesFile as NamedNode
 71 |     }
 72 | 
 73 |     async function loadProfile (user: NamedNode):Promise  {
 74 |         if (!user) {
 75 |             throw new Error('loadProfile: no user given.')
 76 |         }
 77 |         try {
 78 |             await store.fetcher.load(user.doc())
 79 |         } catch (err) {
 80 |             throw new Error(`Unable to load profile of user ${user}: ${err}`)
 81 |         }
 82 |         return user.doc()
 83 |     }
 84 | 
 85 |     async function loadMe(): Promise {
 86 |         const me = authn.currentUser()
 87 |         if (me === null) {
 88 |             throw new Error('Current user not found! Not logged in?')
 89 |         }
 90 |         await store.fetcher.load(me.doc())
 91 |         return me
 92 |     }
 93 | 
 94 |     function getPodRoot(user: NamedNode): NamedNode {
 95 |         const podRoot = findStorage(user)
 96 |         if (!podRoot) {
 97 |             throw new Error('User pod root not found!')
 98 |         }
 99 |         return podRoot as NamedNode
100 |     }
101 | 
102 |     async function getMainInbox(user: NamedNode): Promise {
103 |         await store.fetcher.load(user)
104 |         const mainInbox = store.any(user, ns.ldp('inbox'), undefined, user.doc())
105 |         if (!mainInbox) {
106 |             throw new Error('User main inbox not found!')
107 |         }
108 |         return mainInbox as NamedNode
109 |     }
110 | 
111 |     function findStorage(me: NamedNode) {
112 |         return store.any(me, ns.space('storage'), undefined, me.doc())
113 |     }
114 | 
115 |     return {
116 |         loadMe,
117 |         getPodRoot,
118 |         getMainInbox,
119 |         findStorage,
120 |         loadPreferences,
121 |         loadProfile,
122 |         silencedLoadPreferences
123 |     }
124 | }
125 | 


--------------------------------------------------------------------------------
/test/helpers/dataSetup.ts:
--------------------------------------------------------------------------------
  1 | import { sym } from 'rdflib'
  2 | 
  3 | //------ Club -------------------------------------------------------
  4 | const club = sym('https://club.example.com/profile/card.ttl#it')
  5 | const ClubPreferencesFile = sym('https://club.example.com/settings/prefs.ttl')
  6 | const ClubPublicTypeIndex = sym('https://club.example.com/profile/public-type-index.ttl')
  7 | const ClubPrivateTypeIndex = sym('https://club.example.com/settings/private-type-index.ttl')
  8 | 
  9 | const ClubProfile = `
 10 | 
 11 | <#it> a vcard:Organization;
 12 |     space:preferencesFile ${ClubPreferencesFile};
 13 |     solid:publicTypeIndex ${ClubPublicTypeIndex};
 14 |     vcard:fn "Card Club" .
 15 | `
 16 | const ClubPreferences =  `
 17 |     ${club} solid:privateTypeIndex ${ClubPrivateTypeIndex} .
 18 | `
 19 | const ClubPublicTypes = `
 20 | 
 21 | :chat1 solid:forClass meeting:LongChat; solid:instance <../publicStuff/ourChat.ttl#this> .
 22 | 
 23 | :todo solid:forClass wf:Tracker; solid:instance  <../publicStuff/actionItems.ttl#this>.
 24 | 
 25 | :issues solid:forClass wf:Tracker; solid:instance  <../project4/clubIssues.ttl#this>.
 26 | `
 27 | 
 28 | const ClubPrivateTypes = `
 29 | :id1592319218311 solid:forClass wf:Tracker; solid:instance  <../privateStuff/ToDo.ttl#this>.
 30 | 
 31 | :id1592319391415 solid:forClass wf:Tracker; solid:instance <../privateStuff/Goals.ttl#this>.
 32 | 
 33 | :id1595595377864 solid:forClass wf:Tracker; solid:instance <../privateStuff/tasks.ttl#this>.
 34 | 
 35 | :id1596123375929 solid:forClass meeting:Meeting; solid:instance  <../project4/clubMeeting.ttl#this>.
 36 | 
 37 | `
 38 | 
 39 | //------ Alice -------------------------------------------------------
 40 | const alice = sym('https://alice.example.com/profile/card.ttl#me')
 41 | const AliceProfileFile = alice.doc()
 42 | const AlicePreferencesFile = sym('https://alice.example.com/settings/prefs.ttl')
 43 | const AlicePublicTypeIndex = sym('https://alice.example.com/profile/public-type-index.ttl')
 44 | const AlicePrivateTypeIndex = sym('https://alice.example.com/settings/private-type-index.ttl')
 45 | const aliceDir = alice.dir()
 46 | const AlicePhotoFolder = sym((aliceDir && typeof aliceDir.uri === 'string' && aliceDir.uri.length > 0 ? aliceDir.uri : '') + 'Photos/')
 47 | const AliceProfile = `
 48 | <#me> a vcard:Individual;
 49 |     space:preferencesFile ${AlicePreferencesFile};
 50 |     solid:publicTypeIndex ${AlicePublicTypeIndex};
 51 |     vcard:fn "Alice" .
 52 | `
 53 | const AlicePreferences =  `
 54 |     ${alice} solid:privateTypeIndex ${AlicePrivateTypeIndex};
 55 |     solid:community ${club} .
 56 | `
 57 | const AlicePublicTypes = `
 58 | 
 59 | :chat1 solid:forClass meeting:LongChat; solid:instance <../publicStuff/myChat.ttl#this> .
 60 | 
 61 | :todo solid:forClass wf:Tracker; solid:instance  <../publicStuff/actionItems.ttl#this>.
 62 | 
 63 | :issues solid:forClass wf:Tracker; solid:instance  <../project4/issues.ttl#this>.
 64 | 
 65 | :photos solid:forClass schema:Image; solid:instanceContainer  ${AlicePhotoFolder} .
 66 | `
 67 | 
 68 | const AlicePrivateTypes = `
 69 | :id1592319218311 solid:forClass wf:Tracker; solid:instance  <../privateStuff/ToDo.ttl#this>.
 70 | 
 71 | :id1592319391415 solid:forClass wf:Tracker; solid:instance <../privateStuff/Goals.ttl#this>.
 72 | 
 73 | :id1595595377864 solid:forClass wf:Tracker; solid:instance <../privateStuff/workingOn.ttl#this>.
 74 | 
 75 | :id1596123375929 solid:forClass meeting:Meeting; solid:instance  <../project4/meeting1.ttl#this>.
 76 | 
 77 | `
 78 | 
 79 | const AlicePhotos = `
 80 | <>
 81 |     a ldp:BasicContainer, ldp:Container;
 82 |     dct:modified "2021-04-26T05:34:16Z"^^xsd:dateTime;
 83 |     ldp:contains
 84 |         , ,  ;
 85 |     stat:mtime 1619415256.541;
 86 |     stat:size 4096 .
 87 | `
 88 | 
 89 | //------ Bob -------------------------------------------------------
 90 | const bob = sym('https://bob.example.com/profile/card.ttl#me')
 91 | 
 92 | const BobProfile = `
 93 | <#me> a vcard:Individual;
 94 | vcard:fn "Bob" .
 95 | `
 96 | 
 97 | //------ Boby -------------------------------------------------------
 98 | const boby = sym('https://boby.example.com/profile/card.ttl#me')
 99 | 
100 | const BobyProfile = `
101 | <#me> a vcard:Individual;
102 | vcard:fn "Boby" .
103 | `
104 | export function loadWebObject() {
105 |     const web = {}
106 |     web[alice.doc().uri] = AliceProfile
107 |     web[AlicePreferencesFile.uri] = AlicePreferences
108 |     web[AlicePrivateTypeIndex.uri] = AlicePrivateTypes
109 |     web[AlicePublicTypeIndex.uri] = AlicePublicTypes
110 |     web[AlicePhotoFolder.uri] = AlicePhotos
111 |     web[bob.doc().uri] = BobProfile
112 |     web[boby.doc().uri] = BobyProfile
113 | 
114 |     web[club.doc().uri] = ClubProfile
115 |     web[ClubPreferencesFile.uri] = ClubPreferences
116 |     web[ClubPrivateTypeIndex.uri] = ClubPrivateTypes
117 |     web[ClubPublicTypeIndex.uri] = ClubPublicTypes
118 |     return web
119 | }
120 | 
121 | function clearLocalStore(store) {
122 |     store.statements.slice().forEach(store.remove.bind(store))
123 | }
124 | 
125 | export {
126 |     alice, bob, boby, club,
127 |     AlicePhotoFolder, AlicePreferences, AlicePhotos, AlicePreferencesFile, AlicePrivateTypeIndex, AlicePrivateTypes, AliceProfile, AliceProfileFile, AlicePublicTypeIndex, AlicePublicTypes,
128 |     BobProfile,
129 |     ClubPreferences, ClubPreferencesFile, ClubPrivateTypeIndex, ClubPrivateTypes, ClubProfile, ClubPublicTypeIndex, ClubPublicTypes,
130 |     clearLocalStore
131 | }


--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "compilerOptions": {
 3 |     /* Basic Options */
 4 |    "target": "ES2019" /* Specify ECMAScript target version - matches your browser support */,
 5 |     "module": "ESNext" /* Let bundlers handle module transformation for better tree-shaking */,
 6 |     "moduleResolution": "node", /* Use Node.js-style module resolution for Webpack compatibility. */
 7 |     "lib": [
 8 |       "DOM",
 9 |       "ES2019"
10 |     ] /* Specify library files to be included in the compilation. */,
11 |     // "allowJs": true,                       /* Allow javascript files to be compiled. */
12 |     // "checkJs": true,                       /* Report errors in .js files. */
13 |     // "jsx": "preserve",                     /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
14 |     "declaration": true /* Generates corresponding '.d.ts' file. */,
15 |     "declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */,
16 |     "sourceMap": true /* Generates corresponding '.map' file. */,
17 |     // "outFile": "./",                       /* Concatenate and emit output to single file. */
18 | 
19 |     "outDir": "dist" /* Redirect output structure to the directory. */,
20 |     "rootDir": "src/",
21 | 
22 |     // "rootDir": "./",                       /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
23 |     // "composite": true,                     /* Enable project compilation */
24 |     // "incremental": true,                   /* Enable incremental compilation */
25 |     // "tsBuildInfoFile": "./",               /* Specify file to store incremental compilation information */
26 |     // "removeComments": true,                /* Do not emit comments to output. */
27 |     // "noEmit": true,                        /* Do not emit outputs. */
28 |     // "importHelpers": true,                 /* Import emit helpers from 'tslib'. */
29 |     // "downlevelIteration": true,            /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
30 |     // "isolatedModules": true,               /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
31 | 
32 |     /* Strict Type-Checking Options */
33 |     "strict": false /* Enable all strict type-checking options. */,
34 |     "noImplicitAny": false /* Raise error on expressions and declarations with an implied 'any' type. */,
35 |     "strictNullChecks": true /* Enable strict null checks. */,
36 |     "strictFunctionTypes": true /* Enable strict checking of function types. */,
37 |     "strictBindCallApply": true /* Enable strict 'bind', 'call', and 'apply' methods on functions. */,
38 |     "strictPropertyInitialization": true /* Enable strict checking of property initialization in classes. */,
39 |     "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */,
40 |     "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */,
41 | 
42 |     /* Additional Checks */
43 |     "noUnusedLocals": true,                /* Report errors on unused locals. */
44 |     // "noUnusedParameters": true,            /* Report errors on unused parameters. */
45 |     // "noImplicitReturns": true,             /* Report error when not all code paths in function return a value. */
46 |     // "noFallthroughCasesInSwitch": true,    /* Report errors for fallthrough cases in switch statement. */
47 | 
48 |     /* Module Resolution Options */
49 |     // "moduleResolution": "node",            /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
50 |     // "baseUrl": "./",                       /* Base directory to resolve non-absolute module names. */
51 |     // "paths": {},                           /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
52 |     // "rootDirs": [],                        /* List of root folders whose combined content represents the structure of the project at runtime. */
53 |     // "typeRoots": [],                       /* List of folders to include type definitions from. */
54 |     "typeRoots": [
55 |       "node_modules/@types"
56 |     ] /* List of folders to include type definitions from. */,
57 |     // "allowSyntheticDefaultImports": true,  /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
58 |     "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
59 |     // "preserveSymlinks": true,              /* Do not resolve the real path of symlinks. */
60 | 
61 |     /* Source Map Options */
62 |     // "sourceRoot": "",                      /* Specify the location where debugger should locate TypeScript files instead of source locations. */
63 |     // "mapRoot": "",                         /* Specify the location where debugger should locate map files instead of generated locations. */
64 |     // "inlineSourceMap": true,               /* Emit a single file with source maps instead of having a separate file. */
65 |     // "inlineSources": true,                 /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
66 | 
67 |     /* Experimental Options */
68 |     // "experimentalDecorators": true,        /* Enables experimental support for ES7 decorators. */
69 |     // "emitDecoratorMetadata": true,         /* Enables experimental support for emitting type metadata for decorators. */
70 |   },
71 |   "include": [
72 |     "src/**/*"
73 |   ],
74 |   "exclude": ["node_modules", "dist"]
75 | }
76 | 


--------------------------------------------------------------------------------
/src/util/utilityLogic.ts:
--------------------------------------------------------------------------------
  1 | import { NamedNode, st, sym } from 'rdflib'
  2 | import { CrossOriginForbiddenError, FetchError, NotEditableError, SameOriginForbiddenError, UnauthorizedError, WebOperationError } from '../logic/CustomError'
  3 | import * as debug from '../util/debug'
  4 | import { differentOrigin } from './utils'
  5 | 
  6 | export function createUtilityLogic(store, aclLogic, containerLogic) {
  7 | 
  8 |   async function recursiveDelete(containerNode: NamedNode) {
  9 |       try {
 10 |         if (containerLogic.isContainer(containerNode)) {
 11 |           const aclDocUrl = await aclLogic.findAclDocUrl(containerNode)
 12 |           await store.fetcher._fetch(aclDocUrl, { method: 'DELETE' })
 13 |           const containerMembers = await containerLogic.getContainerMembers(containerNode)
 14 |           await Promise.all(
 15 |             containerMembers.map((url) => recursiveDelete(url))
 16 |           )
 17 |         }
 18 |         const nodeToStringHere = containerNode.value
 19 |         return store.fetcher._fetch(nodeToStringHere, { method: 'DELETE' })
 20 |       } catch (e) {
 21 |         debug.log(`Please manually remove ${containerNode.value} from your system.`, e)
 22 |       }
 23 |   }
 24 | 
 25 |   /**
 26 |    * Create a resource if it really does not exist
 27 |    * Be absolutely sure something does not exist before creating a new empty file
 28 |    * as otherwise existing could  be deleted.
 29 |    * @param doc {NamedNode} - The resource
 30 |    */
 31 |   async function loadOrCreateIfNotExists(doc: NamedNode) {
 32 |     let response
 33 |     try {
 34 |       response = await store.fetcher.load(doc)
 35 |     } catch (err) {
 36 |       if (err.response.status === 404) {
 37 |         try {
 38 |           await store.fetcher.webOperation('PUT', doc, { data: '', contentType: 'text/turtle' })
 39 |         } catch (err) {
 40 |           const msg = 'createIfNotExists: PUT FAILED: ' + doc + ': ' + err
 41 |           throw new WebOperationError(msg)
 42 |         }
 43 |         await store.fetcher.load(doc)
 44 |       } else {
 45 |         if (err.response.status === 401) {
 46 |           throw new UnauthorizedError()
 47 |         }
 48 |         if (err.response.status === 403) {
 49 |           if (differentOrigin(doc)) {
 50 |             throw new CrossOriginForbiddenError()
 51 |           }
 52 |           throw new SameOriginForbiddenError()
 53 |         }
 54 |         const msg = 'createIfNotExists doc load error NOT 404:  ' + doc + ': ' + err
 55 |         throw new FetchError(err.status, err.message + msg)
 56 |       }
 57 |     }
 58 |     return response
 59 |   }
 60 | 
 61 |   /* Follow link from this doc to another thing, or else make a new link
 62 |   **
 63 |   ** @returns existing object, or creates it if non existent
 64 |   */
 65 |   async function followOrCreateLink(subject: NamedNode, predicate: NamedNode,
 66 |     object: NamedNode, doc: NamedNode
 67 |   ): Promise {
 68 |     await store.fetcher.load(doc)
 69 |     const result = store.any(subject, predicate, null, doc)
 70 | 
 71 |     if (result) return result as NamedNode
 72 |     if (!store.updater.editable(doc)) {
 73 |       const msg = `followOrCreateLink: cannot edit ${doc.value}`
 74 |       debug.warn(msg)
 75 |       throw new NotEditableError(msg)
 76 |     }
 77 |     try {
 78 |       await store.updater.update([], [st(subject, predicate, object, doc)])
 79 |     } catch (err) {
 80 |       const msg = `followOrCreateLink: Error making link in ${doc} to ${object}: ${err}`
 81 |       debug.warn(msg)
 82 |       throw new WebOperationError(err)
 83 |     }
 84 | 
 85 |     try {
 86 |       await loadOrCreateIfNotExists(object)
 87 |       // store.fetcher.webOperation('PUT', object, { data: '', contentType: 'text/turtle'})
 88 |     } catch (err) {
 89 |       debug.warn(`followOrCreateLink: Error loading or saving new linked document: ${object}: ${err}`)
 90 |       throw err
 91 |     }
 92 |     return object
 93 |   }
 94 | 
 95 |   // Copied from https://github.com/solidos/web-access-control-tests/blob/v3.0.0/test/surface/delete.test.ts#L5
 96 |   async function setSinglePeerAccess(options: {
 97 |     ownerWebId: string,
 98 |     peerWebId: string,
 99 |     accessToModes?: string,
100 |     defaultModes?: string,
101 |     target: string
102 |   }) {
103 |     let str = [
104 |       '@prefix acl: .',
105 |       '',
106 |       `<#alice> a acl:Authorization;\n  acl:agent <${options.ownerWebId}>;`,
107 |       `  acl:accessTo <${options.target}>;`,
108 |       `  acl:default <${options.target}>;`,
109 |       '  acl:mode acl:Read, acl:Write, acl:Control.',
110 |       ''
111 |     ].join('\n')
112 |     if (options.accessToModes) {
113 |       str += [
114 |         '<#bobAccessTo> a acl:Authorization;',
115 |         `  acl:agent <${options.peerWebId}>;`,
116 |         `  acl:accessTo <${options.target}>;`,
117 |         `  acl:mode ${options.accessToModes}.`,
118 |         ''
119 |       ].join('\n')
120 |     }
121 |     if (options.defaultModes) {
122 |       str += [
123 |         '<#bobDefault> a acl:Authorization;',
124 |         `  acl:agent <${options.peerWebId}>;`,
125 |         `  acl:default <${options.target}>;`,
126 |         `  acl:mode ${options.defaultModes}.`,
127 |         ''
128 |       ].join('\n')
129 |     }
130 |     const aclDocUrl = await aclLogic.findAclDocUrl(sym(options.target))
131 |     return store.fetcher._fetch(aclDocUrl, {
132 |       method: 'PUT',
133 |       body: str,
134 |       headers: [
135 |         ['Content-Type', 'text/turtle']
136 |       ]
137 |     })
138 |   }
139 | 
140 |   async function createEmptyRdfDoc(doc: NamedNode, comment: string) {
141 |     await store.fetcher.webOperation('PUT', doc.uri, {
142 |       data: `# ${new Date()} ${comment}
143 |   `,
144 |       contentType: 'text/turtle',
145 |     })
146 |   }
147 |   
148 |   return {
149 |     recursiveDelete,
150 |     setSinglePeerAccess,
151 |     createEmptyRdfDoc,
152 |     followOrCreateLink,
153 |     loadOrCreateIfNotExists
154 |   }
155 | }
156 | 
157 | 


--------------------------------------------------------------------------------
/test/inboxLogic.test.ts:
--------------------------------------------------------------------------------
  1 | /**
  2 | * @jest-environment jsdom
  3 | *
  4 | */
  5 | import { UpdateManager, Store, Fetcher, sym } from 'rdflib'
  6 | import { createAclLogic } from '../src/acl/aclLogic'
  7 | import { createInboxLogic } from '../src/inbox/inboxLogic'
  8 | import { createProfileLogic } from '../src/profile/profileLogic'
  9 | import { createContainerLogic } from '../src/util/containerLogic'
 10 | import { createUtilityLogic } from '../src/util/utilityLogic'
 11 | 
 12 | const alice = sym('https://alice.example.com/profile/card#me')
 13 | const bob = sym('https://bob.example.com/profile/card#me')
 14 | 
 15 | describe('Inbox logic', () => {
 16 |   let store
 17 |   let inboxLogic
 18 |   beforeEach(() => {
 19 |     fetchMock.resetMocks()
 20 |     fetchMock.mockResponse('Not Found', {
 21 |       status: 404,
 22 |     })
 23 |     store = new Store()
 24 |     store.fetcher = new Fetcher(store, { fetch: fetch })
 25 |     store.updater = new UpdateManager(store)
 26 |     const authn = {
 27 |       currentUser: () => {
 28 |         return alice
 29 |       },
 30 |     }
 31 |     const containerLogic = createContainerLogic(store)
 32 |     const aclLogic = createAclLogic(store)
 33 |     const util = createUtilityLogic(store, aclLogic, containerLogic)
 34 |     const profile = createProfileLogic(store, authn, util)
 35 |     inboxLogic = createInboxLogic(store, profile, util, containerLogic, aclLogic)
 36 |   })
 37 | 
 38 |   describe('getNewMessages', () => {
 39 |     describe('When inbox is empty', () => {
 40 |       let result
 41 |       beforeEach(async () => {
 42 |         bobHasAnInbox()
 43 |         inboxIsEmpty()
 44 |         result = await inboxLogic.getNewMessages(bob)
 45 |       })
 46 |       it('Resolves to an empty array', () => {
 47 |         expect(result).toEqual([])
 48 |       })
 49 |     })
 50 |     describe('When container has some containment triples', () => {
 51 |       let result
 52 |       beforeEach(async () => {
 53 |         bobHasAnInbox()
 54 |         inboxHasSomeContainmentTriples()
 55 |         const messages = await inboxLogic.getNewMessages(bob)
 56 |         result = messages.map(oneMessage => oneMessage.value)
 57 |       })
 58 |       it('Resolves to an array with URLs of non-container resources in inbox', () => {
 59 |         expect(result.sort()).toEqual([
 60 |           'https://container.com/foo.txt'
 61 |         ].sort())
 62 |       })
 63 |     })
 64 |   })
 65 |   describe('createInboxFor', () => {
 66 |     beforeEach(async () => {
 67 |       aliceHasValidProfile()
 68 |       // First for the PUT:
 69 |       fetchMock.mockOnceIf(
 70 |         'https://alice.example.com/p2p-inboxes/Peer%20Person/',
 71 |         'Created', {
 72 |           status: 201
 73 |         }
 74 |       )
 75 |       // Then for the GET to read the ACL link:
 76 |       fetchMock.mockOnceIf(
 77 |         'https://alice.example.com/p2p-inboxes/Peer%20Person/',
 78 |         ' ', {
 79 |           status: 200,
 80 |           headers: {
 81 |             Link: '; rel="acl"',
 82 |           }
 83 |         }
 84 |       )
 85 |       fetchMock.mockIf('https://some/acl', 'Created', { status: 201 })
 86 | 
 87 |       await inboxLogic.createInboxFor('https://peer.com/#me', 'Peer Person')
 88 |     })
 89 |     it('creates the inbox', () => {
 90 |       expect(fetchMock.mock.calls).toEqual([
 91 |         [ 'https://alice.example.com/profile/card', fetchMock.mock.calls[0][1] ],
 92 |         [ 'https://alice.example.com/p2p-inboxes/Peer%20Person/', {
 93 |           body: ' ',
 94 |           headers: {
 95 |             'Content-Type': 'text/turtle',
 96 |             'If-None-Match': '*',
 97 |             Link: '; rel="type"',
 98 |           },
 99 |           method: 'PUT'      
100 |         }],
101 |         [ 'https://alice.example.com/p2p-inboxes/Peer%20Person/', fetchMock.mock.calls[2][1] ],
102 |         [ 'https://some/acl', {
103 |           body: '@prefix acl: .\n' +
104 |           '\n' +
105 |           '<#alice> a acl:Authorization;\n' +
106 |           '  acl:agent ;\n' +
107 |           '  acl:accessTo ;\n' +
108 |           '  acl:default ;\n' +
109 |           '  acl:mode acl:Read, acl:Write, acl:Control.\n' +
110 |           '<#bobAccessTo> a acl:Authorization;\n' +
111 |           '  acl:agent ;\n' +
112 |           '  acl:accessTo ;\n' +
113 |           '  acl:mode acl:Append.\n',
114 |           headers: [
115 |             [ 'Content-Type', 'text/turtle' ]
116 |           ],
117 |           method: 'PUT'
118 |         }]
119 |       ])
120 |     })
121 | 
122 |   })
123 |   describe('markAsRead', () => {
124 |     beforeEach(async () => {
125 |       fetchMock.mockOnceIf(
126 |         'https://container.com/item.ttl',
127 |         '<#some> <#inbox> <#item> .',
128 |         {
129 |           headers: { 'Content-Type': 'text/turtle' },
130 |         }
131 |       )
132 |       fetchMock.mockOnceIf(
133 |         'https://container.com/archive/2111/03/31/item.ttl',
134 |         'Created',
135 |         {
136 |           status: 201,
137 |           headers: { 'Content-Type': 'text/turtle' },
138 |         }
139 |       )
140 |       await inboxLogic.markAsRead('https://container.com/item.ttl', new Date('31 March 2111 UTC'))
141 |     })
142 |     it('moves the item to archive', async () => {
143 |       expect(fetchMock.mock.calls).toEqual([
144 |         [ 'https://container.com/item.ttl' ],
145 |         [
146 |           'https://container.com/archive/2111/03/31/item.ttl',
147 |           {
148 |             'body': '<#some> <#inbox> <#item> .',
149 |             'headers': [
150 |               [
151 |                 'Content-Type',
152 |                 'text/turtle',
153 |               ],
154 |             ],
155 |             'method': 'PUT',
156 |           },
157 |         ],
158 |         [ 'https://container.com/item.ttl', { method: 'DELETE' } ],
159 |       ])
160 |     })
161 |   })
162 | 
163 |   function aliceHasValidProfile() {
164 |     fetchMock.mockOnceIf(
165 |       'https://alice.example.com/profile/card',
166 |       `
167 |             
168 |                 ;
169 |                 ;
170 |             .`,
171 |       {
172 |         headers: {
173 |           'Content-Type': 'text/turtle',
174 |         },
175 |       }
176 |     )
177 |   }
178 | 
179 |   function bobHasAnInbox() {
180 |     fetchMock.mockOnceIf(
181 |       'https://bob.example.com/profile/card',
182 |       '.',
183 |       {
184 |         headers: { 'Content-Type': 'text/turtle' },
185 |       }
186 |     )
187 |   }
188 | 
189 |   function inboxIsEmpty() {
190 |     fetchMock.mockOnceIf(
191 |       'https://container.com/',
192 |       ' ', // FIXME: https://github.com/jefflau/jest-fetch-mock/issues/189
193 |       {
194 |         headers: { 'Content-Type': 'text/turtle' },
195 |       }
196 |     )
197 |   }
198 | 
199 |   function inboxHasSomeContainmentTriples() {
200 |     fetchMock.mockOnceIf(
201 |       'https://container.com/',
202 |       '<.>  <./foo.txt>, <./bar/> .',
203 |       {
204 |         headers: { 'Content-Type': 'text/turtle' },
205 |       }
206 |     )
207 |   }
208 | 
209 | })


--------------------------------------------------------------------------------
/test/utilityLogic.test.ts:
--------------------------------------------------------------------------------
  1 | /**
  2 | * @jest-environment jsdom
  3 | * 
  4 | */
  5 | import fetchMock from 'jest-fetch-mock'
  6 | import { UpdateManager, sym, Fetcher, Store } from 'rdflib'
  7 | import { createAclLogic } from '../src/acl/aclLogic'
  8 | import { WebOperationError } from '../src/logic/CustomError'
  9 | import { createContainerLogic } from '../src/util/containerLogic'
 10 | import { ns } from '../src/util/ns'
 11 | import { createUtilityLogic } from '../src/util/utilityLogic'
 12 | import { alice, AlicePhotoFolder, AlicePhotos, AlicePreferences, AlicePreferencesFile, AlicePrivateTypeIndex, AlicePrivateTypes, AliceProfile, AlicePublicTypeIndex, AlicePublicTypes, bob, BobProfile, club, ClubPreferences, ClubPreferencesFile, ClubPrivateTypeIndex, ClubPrivateTypes, ClubProfile, ClubPublicTypeIndex, ClubPublicTypes } from './helpers/dataSetup'
 13 | 
 14 | window.$SolidTestEnvironment = { username: alice.uri }
 15 | const prefixes = Object.keys(ns).map(prefix => `@prefix ${prefix}: ${ns[prefix]('')}.\n`).join('') // In turtle
 16 | 
 17 | describe('utilityLogic', () => {
 18 |     let store
 19 |     let options
 20 |     let web = {}
 21 |     let requests: Request[] = []
 22 |     let statustoBeReturned = 200
 23 |     let utilityLogic
 24 |     beforeEach(() => {
 25 |         fetchMock.resetMocks()
 26 |         fetchMock.mockResponse('Not Found', {
 27 |             status: 404,
 28 |         })
 29 |         requests = []
 30 |         statustoBeReturned = 200
 31 | 
 32 |         fetchMock.mockIf(/^https?.*$/, async req => {
 33 | 
 34 |             if (req.method !== 'GET') {
 35 |                 requests.push(req)
 36 |                 if (req.method === 'PUT') {
 37 |                     const contents = await req.text()
 38 |                     web[req.url] = contents // Update our dummy web
 39 |                     console.log(`Tetst: Updated ${req.url} on PUT to <<<${web[req.url]}>>>`)
 40 |                 }
 41 |                 return { status: statustoBeReturned }
 42 |             }
 43 |             const contents = web[req.url]
 44 |             if (contents !== undefined) { //
 45 |                 return {
 46 |                     body: prefixes + contents, // Add namespaces to anything
 47 |                     status: 200,
 48 |                     headers: {
 49 |                         'Content-Type': 'text/turtle',
 50 |                         'WAC-Allow': 'user="write", public="read"',
 51 |                         'Accept-Patch': 'application/sparql-update'
 52 |                     }
 53 |                 }
 54 |             } // if contents
 55 |             return {
 56 |                 status: 404,
 57 |                 body: 'Not Found'
 58 |             }
 59 |         })
 60 |         web = {}
 61 |         web[alice.doc().uri] = AliceProfile
 62 |         web[AlicePreferencesFile.uri] = AlicePreferences
 63 |         web[AlicePrivateTypeIndex.uri] = AlicePrivateTypes
 64 |         web[AlicePublicTypeIndex.uri] = AlicePublicTypes
 65 |         web[AlicePhotoFolder.uri] = AlicePhotos
 66 |         web[bob.doc().uri] = BobProfile
 67 | 
 68 |         web[club.doc().uri] = ClubProfile
 69 |         web[ClubPreferencesFile.uri] = ClubPreferences
 70 |         web[ClubPrivateTypeIndex.uri] = ClubPrivateTypes
 71 |         web[ClubPublicTypeIndex.uri] = ClubPublicTypes
 72 |     
 73 |         options = { fetch: fetch }
 74 |         store = new Store()
 75 |         store.fetcher = new Fetcher(store, options)
 76 |         store.updater = new UpdateManager(store)
 77 |         requests = []
 78 | 		utilityLogic = createUtilityLogic(store, createAclLogic(store), createContainerLogic(store))
 79 |     })
 80 | 
 81 |     describe('loadOrCreateIfNotExists', () => {
 82 |         it('exists', () => {
 83 |             expect(utilityLogic.loadOrCreateIfNotExists).toBeInstanceOf(Function)
 84 |         })
 85 |         it('does nothing if existing file', async () => {
 86 |             await utilityLogic.loadOrCreateIfNotExists(alice.doc())
 87 |             expect(requests).toEqual([])
 88 | 
 89 |         })
 90 |         it('creates empty file if did not exist', async () => {
 91 |             const suggestion = 'https://bob.example.com/settings/prefsSuggestion.ttl'
 92 |             await utilityLogic.loadOrCreateIfNotExists(sym(suggestion))
 93 |             expect(requests[0].method).toEqual('PUT')
 94 |             expect(requests[0].url).toEqual(suggestion)
 95 |         })
 96 |     })
 97 | 
 98 |     describe('followOrCreateLink', () => {
 99 |         it('exists', () => {
100 |             expect(utilityLogic.followOrCreateLink).toBeInstanceOf(Function)
101 |         })
102 |         it('follows existing link', async () => {
103 |             const suggestion = 'https://alice.example.com/settings/prefsSuggestion.ttl'
104 |             const result = await utilityLogic.followOrCreateLink(alice, ns.space('preferencesFile'), sym(suggestion), alice.doc())
105 |             expect(result).toEqual(AlicePreferencesFile)
106 | 
107 |         })
108 |         it('creates empty file if did not exist and new link', async () => {
109 |             const suggestion = 'https://bob.example.com/settings/prefsSuggestion.ttl'
110 |             const result = await utilityLogic.followOrCreateLink(bob, ns.space('preferencesFile'), sym(suggestion), bob.doc())
111 |             expect(result).toEqual(sym(suggestion))
112 |             expect(requests[0].method).toEqual('PATCH')
113 |             expect(requests[0].url).toEqual(bob.doc().uri)
114 |             expect(requests[1].method).toEqual('PUT')
115 |             expect(requests[1].url).toEqual(suggestion)
116 |             expect(store.holds(bob, ns.space('preferencesFile'), sym(suggestion), bob.doc())).toEqual(true)
117 |         })
118 |         //
119 |         it('returns null if it cannot create the new file', async () => {
120 |             const suggestion = 'https://bob.example.com/settings/prefsSuggestion.ttl'
121 |             statustoBeReturned = 403 // Unauthorized
122 |             expect(async () => {
123 | 				await utilityLogic.followOrCreateLink(bob, ns.space('preferencesFile'), sym(suggestion), bob.doc())
124 | 			}).rejects.toThrow(WebOperationError)
125 |         })
126 | 
127 |     })
128 | describe('setSinglePeerAccess', () => {
129 | 	beforeEach(() => {
130 | 		fetchMock.mockOnceIf(
131 | 		'https://owner.com/some/resource',
132 | 		'hello', {
133 | 		headers: {
134 | 			Link: '; rel="acl"'
135 | 		}
136 | 		})
137 | 		fetchMock.mockOnceIf(
138 | 		'https://owner.com/some/acl',
139 | 		'Created', {
140 | 		status: 201
141 | 		})
142 | 	})
143 | 	it('Creates the right ACL doc', async () => {
144 | 		await utilityLogic.setSinglePeerAccess({
145 | 		ownerWebId: 'https://owner.com/#me',
146 | 		peerWebId: 'https://peer.com/#me',
147 | 		accessToModes: 'acl:Read, acl:Control',
148 | 		defaultModes: 'acl:Write',
149 | 		target: 'https://owner.com/some/resource'
150 | 		})
151 | 		expect(fetchMock.mock.calls).toEqual([
152 | 		[ 'https://owner.com/some/resource', fetchMock.mock.calls[0][1] ],
153 | 		[ 'https://owner.com/some/acl', {
154 | 			body: '@prefix acl: .\n' +
155 | 			'\n' +
156 | 			'<#alice> a acl:Authorization;\n' +
157 | 			'  acl:agent ;\n' +
158 | 			'  acl:accessTo ;\n' +
159 | 			'  acl:default ;\n' +
160 | 			'  acl:mode acl:Read, acl:Write, acl:Control.\n' +
161 | 			'<#bobAccessTo> a acl:Authorization;\n' +
162 | 			'  acl:agent ;\n' +
163 | 			'  acl:accessTo ;\n' +
164 | 			'  acl:mode acl:Read, acl:Control.\n' +
165 | 			'<#bobDefault> a acl:Authorization;\n' +
166 | 			'  acl:agent ;\n' +
167 | 			'  acl:default ;\n' +
168 | 			'  acl:mode acl:Write.\n',
169 | 			headers: [
170 | 				['Content-Type', 'text/turtle']
171 | 			],
172 | 			method: 'PUT'
173 | 		}]
174 | 		])
175 | 	})
176 | })
177 | 
178 | })


--------------------------------------------------------------------------------
/src/chat/chatLogic.ts:
--------------------------------------------------------------------------------
  1 | import { NamedNode, Node, st, term } from 'rdflib'
  2 | import { ChatLogic, CreatedPaneOptions, NewPaneOptions, Chat } from '../types'
  3 | import { ns as namespace } from '../util/ns'
  4 | import { determineChatContainer, newThing } from '../util/utils'
  5 | 
  6 | const CHAT_LOCATION_IN_CONTAINER = 'index.ttl#this'
  7 | 
  8 | export function createChatLogic(store, profileLogic): ChatLogic {
  9 |     const ns = namespace
 10 | 
 11 |     async function setAcl(
 12 |         chatContainer: NamedNode,
 13 |         me: NamedNode,
 14 |         invitee: NamedNode
 15 |     ): Promise {
 16 |         // Some servers don't present a Link http response header
 17 |         // if the container doesn't exist yet, so refetch the container
 18 |         // now that it has been created:
 19 |         await store.fetcher.load(chatContainer)
 20 | 
 21 |         // FIXME: check the Why value on this quad:
 22 |         const chatAclDoc = store.any(
 23 |             chatContainer,
 24 |             new NamedNode('http://www.iana.org/assignments/link-relations/acl')
 25 |         )
 26 |         if (!chatAclDoc) {
 27 |             throw new Error('Chat ACL doc not found!')
 28 |         }
 29 | 
 30 |         const aclBody = `
 31 |             @prefix acl: .
 32 |             <#owner>
 33 |             a acl:Authorization;
 34 |             acl:agent <${me.value}>;
 35 |             acl:accessTo <.>;
 36 |             acl:default <.>;
 37 |             acl:mode
 38 |                 acl:Read, acl:Write, acl:Control.
 39 |             <#invitee>
 40 |             a acl:Authorization;
 41 |             acl:agent <${invitee.value}>;
 42 |             acl:accessTo <.>;
 43 |             acl:default <.>;
 44 |             acl:mode
 45 |                 acl:Read, acl:Append.
 46 |             `
 47 |         await store.fetcher.webOperation('PUT', chatAclDoc.value, {
 48 |             data: aclBody,
 49 |             contentType: 'text/turtle',
 50 |         })
 51 |     }
 52 | 
 53 |     async function addToPrivateTypeIndex(chatThing, me) {
 54 |         // Add to private type index
 55 |         const privateTypeIndex = store.any(
 56 |             me,
 57 |             ns.solid('privateTypeIndex')
 58 |         ) as NamedNode | null
 59 |         if (!privateTypeIndex) {
 60 |             throw new Error('Private type index not found!')
 61 |         }
 62 |         await store.fetcher.load(privateTypeIndex)
 63 |         const reg = newThing(privateTypeIndex)
 64 |         const ins = [
 65 |             st(
 66 |                 reg,
 67 |                 ns.rdf('type'),
 68 |                 ns.solid('TypeRegistration'),
 69 |                 privateTypeIndex.doc()
 70 |             ),
 71 |             st(
 72 |                 reg,
 73 |                 ns.solid('forClass'),
 74 |                 ns.meeting('LongChat'),
 75 |                 privateTypeIndex.doc()
 76 |             ),
 77 |             st(reg, ns.solid('instance'), chatThing, privateTypeIndex.doc()),
 78 |         ]
 79 |         await new Promise((resolve, reject) => {
 80 |             store.updater.update([], ins, function (_uri, ok, errm) {
 81 |                 if (!ok) {
 82 |                     reject(new Error(errm))
 83 |                 } else {
 84 |                     resolve(null)
 85 |                 }
 86 |             })
 87 |         })
 88 |     }
 89 | 
 90 |     async function findChat(invitee: NamedNode): Promise {
 91 |         const me = await profileLogic.loadMe()
 92 |         const podRoot = await profileLogic.getPodRoot(me)
 93 |         const chatContainer = determineChatContainer(invitee, podRoot)
 94 |         let exists = true
 95 |         try {
 96 |             await store.fetcher.load(
 97 |                 new NamedNode(chatContainer.value + 'index.ttl#this')
 98 |             )
 99 |         } catch (e) {
100 |             exists = false
101 |         }
102 |         return { me, chatContainer, exists }
103 |     }
104 | 
105 |     async function createChatThing(
106 |         chatContainer: NamedNode,
107 |         me: NamedNode
108 |     ): Promise {
109 |         const created = await mintNew({
110 |             me,
111 |             newBase: chatContainer.value,
112 |         })
113 |         return created.newInstance
114 |     }
115 | 
116 |     function mintNew(newPaneOptions: NewPaneOptions): Promise {
117 |         const kb = store
118 |         const updater = kb.updater
119 |         if (newPaneOptions.me && !newPaneOptions.me.uri) {
120 |             throw new Error('chat mintNew:  Invalid userid ' + newPaneOptions.me)
121 |         }
122 | 
123 |         const newInstance = (newPaneOptions.newInstance =
124 |             newPaneOptions.newInstance ||
125 |             kb.sym(newPaneOptions.newBase + CHAT_LOCATION_IN_CONTAINER))
126 |         const newChatDoc = newInstance.doc()
127 | 
128 |         kb.add(
129 |             newInstance,
130 |             ns.rdf('type'),
131 |             ns.meeting('LongChat'),
132 |             newChatDoc
133 |         )
134 |         kb.add(newInstance, ns.dc('title'), 'Chat channel', newChatDoc)
135 |         kb.add(
136 |             newInstance,
137 |             ns.dc('created'),
138 |             term(new Date(Date.now())),
139 |             newChatDoc
140 |         )
141 |         if (newPaneOptions.me) {
142 |             kb.add(newInstance, ns.dc('author'), newPaneOptions.me, newChatDoc)
143 |         }
144 | 
145 |         return new Promise(function (resolve, reject) {
146 |             updater?.put(
147 |                 newChatDoc,
148 |                 kb.statementsMatching(undefined, undefined, undefined, newChatDoc),
149 |                 'text/turtle',
150 |                 function (uri2, ok, message) {
151 |                     if (ok) {
152 |                         resolve({
153 |                             ...newPaneOptions,
154 |                             newInstance,
155 |                         })
156 |                     } else {
157 |                         reject(
158 |                             new Error(
159 |                                 'FAILED to save new chat channel at: ' + uri2 + ' : ' + message
160 |                             )
161 |                         )
162 |                     }
163 |                 }
164 |             )
165 |         })
166 |     }
167 | 
168 |     /**
169 |      * Find (and optionally create) an individual chat between the current user and the given invitee
170 |      * @param invitee - The person to chat with
171 |      * @param createIfMissing - Whether the chat should be created, if missing
172 |      * @returns null if missing, or a node referring to an already existing chat, or the newly created chat
173 |      */
174 |     async function getChat(
175 |         invitee: NamedNode,
176 |         createIfMissing = true
177 |     ): Promise {
178 |         const { me, chatContainer, exists } = await findChat(invitee)
179 |         if (exists) {
180 |             return new NamedNode(chatContainer.value + CHAT_LOCATION_IN_CONTAINER)
181 |         }
182 | 
183 |         if (createIfMissing) {
184 |             const chatThing = await createChatThing(chatContainer, me)
185 |             await sendInvite(invitee, chatThing)
186 |             await setAcl(chatContainer, me, invitee)
187 |             await addToPrivateTypeIndex(chatThing, me)
188 |             return chatThing
189 |         }
190 |         return null
191 |     }
192 | 
193 |     async function sendInvite(invitee: NamedNode, chatThing: NamedNode) {
194 |         await store.fetcher.load(invitee.doc())
195 |         const inviteeInbox = store.any(
196 |             invitee,
197 |             ns.ldp('inbox'),
198 |             undefined,
199 |             invitee.doc()
200 |         )
201 |         if (!inviteeInbox) {
202 |             throw new Error(`Invitee inbox not found! ${invitee.value}`)
203 |         }
204 |         const inviteBody = `
205 |         <> a  ;
206 |         ${ns.rdf('seeAlso')} <${chatThing.value}> .
207 |         `
208 | 
209 |         const inviteResponse = await store.fetcher?.webOperation(
210 |             'POST',
211 |             inviteeInbox.value,
212 |             {
213 |                 data: inviteBody,
214 |                 contentType: 'text/turtle',
215 |             }
216 |         )
217 |         const locationStr = inviteResponse?.headers.get('location')
218 |         if (!locationStr) {
219 |             throw new Error(`Invite sending returned a ${inviteResponse?.status}`)
220 |         }
221 |     }
222 |     return {
223 |         setAcl, addToPrivateTypeIndex, findChat, createChatThing, getChat, sendInvite, mintNew
224 |     }
225 | }
226 | 


--------------------------------------------------------------------------------
/src/typeIndex/typeIndexLogic.ts:
--------------------------------------------------------------------------------
  1 | import { NamedNode, st, sym } from 'rdflib'
  2 | import { ScopedApp, TypeIndexLogic, TypeIndexScope } from '../types'
  3 | import * as debug from '../util/debug'
  4 | import { ns as namespace } from '../util/ns'
  5 | import { newThing } from '../util/utils'
  6 | 
  7 | export function createTypeIndexLogic(store, authn, profileLogic, utilityLogic): TypeIndexLogic {
  8 |     const ns = namespace
  9 | 
 10 |     function getRegistrations(instance, theClass) {
 11 |         return store
 12 |             .each(undefined, ns.solid('instance'), instance)
 13 |             .filter((r) => {
 14 |                 return store.holds(r, ns.solid('forClass'), theClass)
 15 |             })
 16 |     }
 17 | 
 18 |     async function loadTypeIndexesFor(user: NamedNode): Promise> {
 19 |         if (!user) throw new Error('loadTypeIndexesFor: No user given')
 20 |         const profile = await profileLogic.loadProfile(user)
 21 | 
 22 |         const suggestion = suggestPublicTypeIndex(user)
 23 |         let publicTypeIndex
 24 |         try {
 25 |             publicTypeIndex = await utilityLogic.followOrCreateLink(user, ns.solid('publicTypeIndex') as NamedNode, suggestion, profile)
 26 |         } catch (err) {
 27 |             const message = `User ${user} has no pointer in profile to publicTypeIndex file.`
 28 |             debug.warn(message)
 29 |         }
 30 |         const publicScopes = publicTypeIndex ? [{ label: 'public', index: publicTypeIndex as NamedNode, agent: user }] : []
 31 | 
 32 |         let preferencesFile
 33 |         try {
 34 |             preferencesFile = await profileLogic.silencedLoadPreferences(user)
 35 |         } catch (err) {
 36 |             preferencesFile = null
 37 |         }
 38 | 
 39 |         let privateScopes
 40 |         if (preferencesFile) { // watch out - can be in either as spec was not clear.  Legacy is profile.
 41 |             // If there is a legacy one linked from the profile, use that.
 42 |             // Otherwiae use or make one linked from Preferences
 43 |             const suggestedPrivateTypeIndex = suggestPrivateTypeIndex(preferencesFile)
 44 |             let privateTypeIndex
 45 |             try {
 46 |                 privateTypeIndex = store.any(user, ns.solid('privateTypeIndex'), undefined, profile) ||
 47 |                     await utilityLogic.followOrCreateLink(user, ns.solid('privateTypeIndex') as NamedNode, suggestedPrivateTypeIndex, preferencesFile)
 48 |                 } catch (err) {
 49 |                 const message = `User ${user} has no pointer in preference file to privateTypeIndex file.`
 50 |                 debug.warn(message)
 51 |             }
 52 |             privateScopes = privateTypeIndex ? [{ label: 'private', index: privateTypeIndex as NamedNode, agent: user }] : []
 53 |         } else {
 54 |             privateScopes = []
 55 |         }
 56 |         const scopes = publicScopes.concat(privateScopes)
 57 |         if (scopes.length === 0) return scopes
 58 |         const files = scopes.map(scope => scope.index)
 59 |         try {
 60 |             await store.fetcher.load(files)
 61 |         } catch (err) {
 62 |             debug.warn('Problems loading type index: ', err)
 63 |         }
 64 |         return scopes
 65 |     }
 66 | 
 67 |     async function loadCommunityTypeIndexes(user: NamedNode): Promise {
 68 |         let preferencesFile
 69 |         try {
 70 |             preferencesFile = await profileLogic.silencedLoadPreferences(user)
 71 |         } catch (err) {
 72 |             const message = `User ${user} has no pointer in profile to preferences file.`
 73 |             debug.warn(message)
 74 |         }
 75 |         if (preferencesFile) { // For now, pick up communities as simple links from the preferences file.
 76 |             const communities = store.each(user, ns.solid('community'), undefined, preferencesFile as NamedNode).concat(
 77 |                 store.each(user, ns.solid('community'), undefined, user.doc() as NamedNode)
 78 |             )
 79 |             let result = []
 80 |             for (const org of communities) {
 81 |                 result = result.concat(await loadTypeIndexesFor(org as NamedNode) as any)
 82 |             }
 83 |             return result
 84 |         }
 85 |         return [] // No communities
 86 |     }
 87 | 
 88 |     async function loadAllTypeIndexes(user: NamedNode) {
 89 |         return (await loadTypeIndexesFor(user)).concat((await loadCommunityTypeIndexes(user)).flat())
 90 |     }
 91 | 
 92 |     async function getScopedAppInstances(klass: NamedNode, user: NamedNode): Promise {
 93 |         const scopes = await loadAllTypeIndexes(user)
 94 |         let scopedApps = []
 95 |         for (const scope of scopes) {
 96 |             const scopedApps0 = await getScopedAppsFromIndex(scope, klass) as any
 97 |             scopedApps = scopedApps.concat(scopedApps0)
 98 |         }
 99 |         return scopedApps
100 |     }
101 | 
102 |     // This is the function signature which used to be in solid-ui/logic
103 |     // Recommended to use getScopedAppInstances instead as it provides more information.
104 |     //
105 |     async function getAppInstances(klass: NamedNode): Promise {
106 |         const user = authn.currentUser()
107 |         if (!user) throw new Error('getAppInstances: Must be logged in to find apps.')
108 |         const scopedAppInstances = await getScopedAppInstances(klass, user)
109 |         return scopedAppInstances.map(scoped => scoped.instance)
110 |     }
111 | 
112 |     function suggestPublicTypeIndex(me: NamedNode) {
113 |         return sym(me.doc().dir()?.uri + 'publicTypeIndex.ttl')
114 |     }
115 |     // Note this one is based off the pref file not the profile
116 | 
117 |     function suggestPrivateTypeIndex(preferencesFile: NamedNode) {
118 |         return sym(preferencesFile.doc().dir()?.uri + 'privateTypeIndex.ttl')
119 |     }
120 | 
121 |     /*
122 |     * Register a new app in a type index
123 |     * used in chat in bookmark.js (solid-ui)
124 |     * Returns the registration object if successful else null
125 |     */
126 |     async function registerInTypeIndex(
127 |         instance: NamedNode,
128 |         index: NamedNode,
129 |         theClass: NamedNode,
130 |         // agent: NamedNode
131 |     ): Promise {
132 |         const registration = newThing(index)
133 |         const ins = [
134 |             // See https://github.com/solid/solid/blob/main/proposals/data-discovery.md
135 |             st(registration, ns.rdf('type'), ns.solid('TypeRegistration'), index),
136 |             st(registration, ns.solid('forClass'), theClass, index),
137 |             st(registration, ns.solid('instance'), instance, index)
138 |         ]
139 |         try {
140 |             await store.updater.update([], ins)
141 |         } catch (err) {
142 |             const msg = `Unable to register ${instance} in index ${index}: ${err}`
143 |             console.warn(msg)
144 |             return null
145 |         }
146 |         return registration
147 |     }
148 | 
149 |     async function deleteTypeIndexRegistration(item) {
150 |         const reg = store.the(null, ns.solid('instance'), item.instance, item.scope.index) as NamedNode
151 |         if (!reg) throw new Error(`deleteTypeIndexRegistration: No registration found for ${item.instance}`)
152 |         const statements = store.statementsMatching(reg, null, null, item.scope.index)
153 |         await store.updater.update(statements, [])
154 |     }
155 | 
156 |     async function getScopedAppsFromIndex(scope: TypeIndexScope, theClass: NamedNode | null): Promise {
157 |         const index = scope.index
158 |         const results: ScopedApp[] = []
159 |         const registrations = store.statementsMatching(null, ns.solid('instance'), null, index)
160 |             .concat(store.statementsMatching(null, ns.solid('instanceContainer'), null, index))
161 |             .map(st => st.subject)
162 |         for (const reg of registrations) {
163 |           const klass = store.any(reg, ns.solid('forClass'), null, index)
164 |           if (!theClass || klass.sameTerm(theClass)) {
165 |             const instances = store.each(reg, ns.solid('instance'), null, index)
166 |             for (const instance of instances) {
167 |               results.push({ instance, type: klass, scope })
168 |             }
169 |             const containers = store.each(reg, ns.solid('instanceContainer'), null, index)
170 |             for (const instance of containers) {
171 |                 await store.fetcher.load(instance)
172 |                 results.push({ instance: sym(instance.value), type: klass,  scope })
173 |             }
174 |           }
175 |         }
176 |         return results
177 |     }
178 | 
179 |     return {
180 |         registerInTypeIndex,
181 |         getRegistrations,
182 |         loadTypeIndexesFor,
183 |         loadCommunityTypeIndexes,
184 |         loadAllTypeIndexes,
185 |         getScopedAppInstances,
186 |         getAppInstances,
187 |         suggestPublicTypeIndex,
188 |         suggestPrivateTypeIndex,
189 |         deleteTypeIndexRegistration,
190 |         getScopedAppsFromIndex
191 |     }
192 | }
193 | 


--------------------------------------------------------------------------------
/test/profileLogic.test.ts:
--------------------------------------------------------------------------------
  1 | /**
  2 | * @jest-environment jsdom
  3 | * 
  4 | */
  5 | import { UpdateManager, Store, Fetcher } from 'rdflib'
  6 | import { createProfileLogic } from '../src/profile/profileLogic'
  7 | import { createUtilityLogic } from '../src/util/utilityLogic'
  8 | import { ns } from '../src/util/ns'
  9 | import {
 10 |     alice, AlicePreferencesFile, AlicePrivateTypeIndex, AliceProfileFile, bob, boby, loadWebObject
 11 | } from './helpers/dataSetup'
 12 | import { createAclLogic } from '../src/acl/aclLogic'
 13 | import { createContainerLogic } from '../src/util/containerLogic'
 14 | 
 15 | const prefixes = Object.keys(ns).map(prefix => `@prefix ${prefix}: ${ns[prefix]('')}.\n`).join('') // In turtle
 16 | const user = alice
 17 | const profile = user.doc()
 18 | let requests: Request[] = []
 19 | let profileLogic
 20 | 
 21 | describe('Profile', () => {
 22 | 
 23 |     describe('loadProfile', () => {
 24 |         window.$SolidTestEnvironment = { username: alice.uri }
 25 |         let store
 26 |         requests = []
 27 |         const statustoBeReturned = 200
 28 |         let web = {}
 29 |         const authn = {
 30 |             currentUser: () => {
 31 |                 return alice
 32 |             },
 33 |         }
 34 |         beforeEach(() => {
 35 |             fetchMock.resetMocks()
 36 |             web = loadWebObject()
 37 |             requests = []
 38 |             fetchMock.mockIf(/^https?.*$/, async req => {
 39 | 
 40 |                 if (req.method !== 'GET') {
 41 |                     requests.push(req)
 42 |                     if (req.method === 'PUT') {
 43 |                     const contents = await req.text()
 44 |                     web[req.url] = contents // Update our dummy web
 45 |                     console.log(`Tetst: Updated ${req.url} on PUT to <<<${web[req.url]}>>>`)
 46 |                     }
 47 |                     return { status: statustoBeReturned }
 48 |                 }
 49 |                 const contents = web[req.url]
 50 |                 if (contents !== undefined) { //
 51 |                     return {
 52 |                     body: prefixes + contents, // Add namespaces to anything
 53 |                     status: 200,
 54 |                     headers: {
 55 |                         'Content-Type': 'text/turtle',
 56 |                         'WAC-Allow':    'user="write", public="read"',
 57 |                         'Accept-Patch': 'application/sparql-update'
 58 |                     }
 59 |                     }
 60 |                 } // if contents
 61 |                 return {
 62 |                     status: 404,
 63 |                     body: 'Not Found'
 64 |                     }
 65 |             })
 66 | 
 67 |             store = new Store()
 68 |             store.fetcher = new Fetcher(store, { fetch: fetch })
 69 |             store.updater = new UpdateManager(store)
 70 |             const util = createUtilityLogic(store, createAclLogic(store), createContainerLogic(store))
 71 |             profileLogic = createProfileLogic(store, authn, util)
 72 |         })
 73 |         it('exists', () => {
 74 |             expect(profileLogic.loadProfile).toBeInstanceOf(Function)
 75 |         })
 76 |         it('loads data', async () => {
 77 |             const result = await profileLogic.loadProfile(user)
 78 |             expect(result).toBeInstanceOf(Object)
 79 |             expect(result.uri).toEqual(AliceProfileFile.uri)
 80 |             expect(store.holds(user, ns.rdf('type'), ns.vcard('Individual'), profile)).toEqual(true)
 81 |             expect(store.holds(user, ns.space('preferencesFile'), AlicePreferencesFile, profile)).toEqual(true)
 82 |             expect(store.statementsMatching(null, null, null, profile).length).toEqual(4)
 83 |         })
 84 |     })
 85 |     
 86 |     describe('silencedLoadPreferences', () => {
 87 |         window.$SolidTestEnvironment = { username: alice.uri }
 88 |         let store
 89 |         requests = []
 90 |         const statustoBeReturned = 200
 91 |         let web = {}
 92 |         const authn = {
 93 |             currentUser: () => {
 94 |                 return alice
 95 |             },
 96 |         }
 97 |         beforeEach(() => {
 98 |             fetchMock.resetMocks()
 99 |             web = loadWebObject()
100 |             requests = []
101 |             fetchMock.mockIf(/^https?.*$/, async req => {
102 | 
103 |                 if (req.method !== 'GET') {
104 |                     requests.push(req)
105 |                     if (req.method === 'PUT') {
106 |                     const contents = await req.text()
107 |                     web[req.url] = contents // Update our dummy web
108 |                     console.log(`Tetst: Updated ${req.url} on PUT to <<<${web[req.url]}>>>`)
109 |                     }
110 |                     return { status: statustoBeReturned }
111 |                 }
112 |                 const contents = web[req.url]
113 |                 if (contents !== undefined) { //
114 |                     return {
115 |                     body: prefixes + contents, // Add namespaces to anything
116 |                     status: 200,
117 |                     headers: {
118 |                         'Content-Type': 'text/turtle',
119 |                         'WAC-Allow':    'user="write", public="read"',
120 |                         'Accept-Patch': 'application/sparql-update'
121 |                     }
122 |                     }
123 |                 } // if contents
124 |                 return {
125 |                     status: 404,
126 |                     body: 'Not Found'
127 |                     }
128 |             })
129 | 
130 |             store = new Store()
131 |             store.fetcher = new Fetcher(store, { fetch: fetch })
132 |             store.updater = new UpdateManager(store)
133 |                 const util = createUtilityLogic(store, createAclLogic(store), createContainerLogic(store))
134 |             profileLogic = createProfileLogic(store, authn, util)
135 |         })
136 |         it('exists', () => {
137 |             expect(profileLogic.silencedLoadPreferences).toBeInstanceOf(Function)
138 |         })
139 |         it('loads data', async () => {
140 |             const result = await profileLogic.silencedLoadPreferences(alice)
141 |             expect(result).toBeInstanceOf(Object)
142 |             expect(result.uri).toEqual(AlicePreferencesFile.uri)
143 |             expect(store.holds(user, ns.rdf('type'), ns.vcard('Individual'), profile)).toEqual(true)
144 |             expect(store.statementsMatching(null, null, null, profile).length).toEqual(4)
145 | 
146 |             expect(store.statementsMatching(null, null, null, AlicePreferencesFile).length).toEqual(2)
147 |             expect(store.holds(user, ns.solid('privateTypeIndex'), AlicePrivateTypeIndex, AlicePreferencesFile)).toEqual(true)
148 |         })
149 |         it('creates new file', async () => {
150 |              await profileLogic.silencedLoadPreferences(bob)
151 | 
152 |             const patchRequest = requests[0]
153 |             expect(patchRequest.method).toEqual('PATCH')
154 |             expect(patchRequest.url).toEqual(bob.doc().uri)
155 |             const text = await patchRequest.text()
156 |             expect(text).toContain('INSERT DATA {    .')
157 | 
158 |             const putRequest = requests[1]
159 |             expect(putRequest.method).toEqual('PUT')
160 |             expect(putRequest.url).toEqual('https://bob.example.com/Settings/Preferences.ttl')
161 |             expect(web[putRequest.url]).toEqual('')
162 | 
163 |         })
164 |     })
165 | 
166 | 
167 |     describe('loadPreferences', () => {
168 |         window.$SolidTestEnvironment = { username: boby.uri }
169 |         let store
170 |         requests = []
171 |         const statustoBeReturned = 200
172 |         let web = {}
173 |         const authn = {
174 |             currentUser: () => {
175 |                 return boby
176 |             },
177 |         }
178 |         beforeEach(() => {
179 |             fetchMock.resetMocks()
180 |             web = loadWebObject()
181 |             requests = []
182 |             fetchMock.mockIf(/^https?.*$/, async req => {
183 | 
184 |                 if (req.method !== 'GET') {
185 |                     requests.push(req)
186 |                     if (req.method === 'PUT') {
187 |                     const contents = await req.text()
188 |                     web[req.url] = contents // Update our dummy web
189 |                     console.log(`Tetst: Updated ${req.url} on PUT to <<<${web[req.url]}>>>`)
190 |                     }
191 |                     return { status: statustoBeReturned }
192 |                 }
193 |                 const contents = web[req.url]
194 |                 if (contents !== undefined) { //
195 |                     return {
196 |                     body: prefixes + contents, // Add namespaces to anything
197 |                     status: 200,
198 |                     headers: {
199 |                         'Content-Type': 'text/turtle',
200 |                         'WAC-Allow':    'user="write", public="read"',
201 |                         'Accept-Patch': 'application/sparql-update'
202 |                     }
203 |                     }
204 |                 } // if contents
205 |                 return {
206 |                     status: 404,
207 |                     body: 'Not Found'
208 |                     }
209 |             })
210 | 
211 |             store = new Store()
212 |             store.fetcher = new Fetcher(store, { fetch: fetch })
213 |             store.updater = new UpdateManager(store)
214 |             const util = createUtilityLogic(store, createAclLogic(store), createContainerLogic(store))
215 |             profileLogic = createProfileLogic(store, authn, util)
216 |         })
217 |         it('exists', () => {
218 |             expect(profileLogic.loadPreferences).toBeInstanceOf(Function)
219 |         })
220 |         it('loads data', async () => {
221 |             const result = await profileLogic.loadPreferences(alice)
222 |             expect(result).toBeInstanceOf(Object)
223 |             expect(result.uri).toEqual(AlicePreferencesFile.uri)
224 |             expect(store.holds(user, ns.rdf('type'), ns.vcard('Individual'), profile)).toEqual(true)
225 |             expect(store.statementsMatching(null, null, null, profile).length).toEqual(4)
226 | 
227 |             expect(store.statementsMatching(null, null, null, AlicePreferencesFile).length).toEqual(2)
228 |             expect(store.holds(user, ns.solid('privateTypeIndex'), AlicePrivateTypeIndex, AlicePreferencesFile)).toEqual(true)
229 |         })
230 |         it('creates new file', async () => {
231 |              await profileLogic.loadPreferences(boby)
232 | 
233 |             const patchRequest = requests[0]
234 |             expect(patchRequest.method).toEqual('PATCH')
235 |             expect(patchRequest.url).toEqual(boby.doc().uri)
236 |             const text = await patchRequest.text()
237 |             expect(text).toContain('INSERT DATA {    .')
238 | 
239 |             const putRequest = requests[1]
240 |             expect(putRequest.method).toEqual('PUT')
241 |             expect(putRequest.url).toEqual('https://boby.example.com/Settings/Preferences.ttl')
242 |             expect(web[putRequest.url]).toEqual('')
243 | 
244 |         })
245 |     })
246 | })
247 | 


--------------------------------------------------------------------------------
/test/chatLogic.test.ts:
--------------------------------------------------------------------------------
  1 | /**
  2 | * @jest-environment jsdom
  3 | *
  4 | */
  5 | import { UpdateManager, Store, Fetcher } from 'rdflib'
  6 | import { createAclLogic } from '../src/acl/aclLogic'
  7 | import { createChatLogic } from '../src/chat/chatLogic'
  8 | import { createProfileLogic } from '../src/profile/profileLogic'
  9 | import { createContainerLogic } from '../src/util/containerLogic'
 10 | import { createUtilityLogic } from '../src/util/utilityLogic'
 11 | import { alice, bob } from './helpers/dataSetup'
 12 | 
 13 | declare global {
 14 |   interface Window {
 15 |     $SolidTestEnvironment?: { username: string }
 16 |   }
 17 | }
 18 | 
 19 | window.$SolidTestEnvironment = { username: alice.uri }
 20 | 
 21 | describe('Chat logic', () => {
 22 |   let chatLogic
 23 |   let store
 24 |   beforeEach(() => {
 25 |     fetchMock.resetMocks()
 26 |     fetchMock.mockResponse('Not Found', {
 27 |       status: 404,
 28 |     })
 29 |     store = new Store()
 30 |     store.fetcher = new Fetcher(store, { fetch: fetch })
 31 |     store.updater = new UpdateManager(store)
 32 |     const authn = {
 33 |       currentUser: () => {
 34 |         return alice
 35 |       },
 36 |     }
 37 |     const util = createUtilityLogic(store, createAclLogic(store), createContainerLogic(store))
 38 |     chatLogic = createChatLogic(store, createProfileLogic(store, authn, util))
 39 |   })
 40 | 
 41 |   describe('get chat, without creating', () => {
 42 |     describe('when no chat exists yet', () => {
 43 |       let result
 44 |       beforeEach(async () => {
 45 |         aliceHasValidProfile()
 46 |         noChatWithBobExists()
 47 |         result = await chatLogic.getChat(bob, false)
 48 |       })
 49 |       it('does not return a chat', async () => {
 50 |         expect(result).toBeNull()
 51 |       })
 52 |       it('loaded the current user profile', () => {
 53 |         expect(fetchMock.mock.calls[0][0]).toBe(
 54 |           'https://alice.example.com/profile/card.ttl'
 55 |         )
 56 |       })
 57 |       it('tried to load the chat document', () => {
 58 |         expect(fetchMock.mock.calls[1][0]).toBe(
 59 |           'https://alice.example.com/IndividualChats/bob.example.com/index.ttl'
 60 |         )
 61 |       })
 62 |       it('has no additional fetch requests', () => {
 63 |         expect(fetchMock.mock.calls.length).toBe(2)
 64 |       })
 65 |     })
 66 |   })
 67 | 
 68 |   describe('get chat, create if missing', () => {
 69 |     describe('when no chat exists yet', () => {
 70 |       let result
 71 |       beforeEach(async () => {
 72 |         Date.now = jest.fn(() =>
 73 |           new Date(Date.UTC(2021, 1, 6, 10, 11, 12)).valueOf()
 74 |         )
 75 |         aliceHasValidProfile()
 76 |         noChatWithBobExists()
 77 |         chatWithBobCanBeCreated()
 78 |         bobHasAnInbox()
 79 |         invitationCanBeSent()
 80 |         chatContainerIsFound()
 81 |         chatContainerAclCanBeSet()
 82 |         editablePrivateTypeIndexIsFound()
 83 |         privateTypeIndexIsUpdated()
 84 |         result = await chatLogic.getChat(bob, true)
 85 |       })
 86 |       it('returns the chat URI based on the invitee\'s WebID', () => {
 87 |         expect(result.uri).toBe(
 88 |           'https://alice.example.com/IndividualChats/bob.example.com/index.ttl#this'
 89 |         )
 90 |       })
 91 |       it('created a chat document', () => {
 92 |         const request = getRequestTo(
 93 |           'PUT',
 94 |           'https://alice.example.com/IndividualChats/bob.example.com/index.ttl'
 95 |         )
 96 |         expect(request.body).toBe(`@prefix : <#>.
 97 | @prefix dc: .
 98 | @prefix meeting: .
 99 | @prefix xsd: .
100 | @prefix c: .
101 | 
102 | :this
103 |     a meeting:LongChat;
104 |     dc:author c:me;
105 |     dc:created "2021-02-06T10:11:12Z"^^xsd:dateTime;
106 |     dc:title "Chat channel".
107 | `)
108 |       })
109 |       it('allowed Bob to participate in the chat by adding an ACL', () => {
110 |         const request = getRequestTo(
111 |           'PUT',
112 |           'https://alice.example.com/IndividualChats/bob.example.com/.acl'
113 |         )
114 |         expect(request.body).toBe(`
115 |             @prefix acl: .
116 |             <#owner>
117 |             a acl:Authorization;
118 |             acl:agent ;
119 |             acl:accessTo <.>;
120 |             acl:default <.>;
121 |             acl:mode
122 |                 acl:Read, acl:Write, acl:Control.
123 |             <#invitee>
124 |             a acl:Authorization;
125 |             acl:agent ;
126 |             acl:accessTo <.>;
127 |             acl:default <.>;
128 |             acl:mode
129 |                 acl:Read, acl:Append.
130 |             `)
131 |       })
132 |       it('sent an invitation to invitee inbox', () => {
133 |         const request = getRequestTo('POST', 'https://bob.example.com/inbox')
134 |         expect(request.body).toContain(`
135 |         <> a  ;
136 |           .
137 |         `)
138 |       })
139 |       it('added the new chat to private type index', () => {
140 |         const request = getRequestTo(
141 |           'PATCH',
142 |           'https://alice.example.com/settings/privateTypeIndex.ttl'
143 |         )
144 |         expect(request.body)
145 |           .toBe(`INSERT DATA {    .
146 |    .
147 |    .
148 |  }
149 | `)
150 |       })
151 |       it('has no additional fetch requests', () => {
152 |         expect(fetchMock.mock.calls.length).toBe(9)
153 |       })
154 |     })
155 |   })
156 | 
157 |   describe('possible errors', () => {
158 |     it('profile does not link to storage', async () => {
159 |       fetchMock.mockOnceIf('https://alice.example.com/profile/card.ttl', '<><><>.', {
160 |         headers: {
161 |           'Content-Type': 'text/turtle',
162 |         },
163 |       })
164 |       const expectedError = new Error('User pod root not found!')
165 |       await expect(chatLogic.getChat(bob, false)).rejects.toEqual(expectedError)
166 |     })
167 | 
168 |     it('invitee inbox not found', async () => {
169 |       aliceHasValidProfile()
170 |       noChatWithBobExists()
171 |       chatWithBobCanBeCreated()
172 |       bobDoesNotHaveAnInbox()
173 |       const expectedError = new Error(
174 |         'Invitee inbox not found! https://bob.example.com/profile/card.ttl#me'
175 |       )
176 |       await expect(chatLogic.getChat(bob, true)).rejects.toEqual(expectedError)
177 |     })
178 |   })
179 | 
180 |   function aliceHasValidProfile() {
181 |     fetchMock.mockOnceIf(
182 |       'https://alice.example.com/profile/card.ttl',
183 |       `
184 |             
185 |                 ;
186 |                 ;
187 |             .`,
188 |       {
189 |         headers: {
190 |           'Content-Type': 'text/turtle',
191 |         },
192 |       }
193 |     )
194 |   }
195 | 
196 |   function noChatWithBobExists() {
197 |     return fetchMock.mockOnceIf(
198 |       ({ url, method }) =>
199 |         url === 'https://alice.example.com/IndividualChats/bob.example.com/index.ttl' &&
200 |         method === 'GET',
201 |       'Not found',
202 |       {
203 |         status: 404,
204 |       }
205 |     )
206 |   }
207 | 
208 |   function chatWithBobCanBeCreated() {
209 |     return fetchMock.mockOnceIf(
210 |       ({ url, method }) =>
211 |         url === 'https://alice.example.com/IndividualChats/bob.example.com/index.ttl' &&
212 |         method === 'PUT',
213 |       'Created',
214 |       {
215 |         status: 201,
216 |       }
217 |     )
218 |   }
219 | 
220 |   function bobHasAnInbox() {
221 |     fetchMock.mockOnceIf(
222 |       'https://bob.example.com/profile/card.ttl',
223 |       '.',
224 |       {
225 |         headers: { 'Content-Type': 'text/turtle' },
226 |       }
227 |     )
228 |   }
229 | 
230 |   function bobDoesNotHaveAnInbox() {
231 |     fetchMock.mockOnceIf('https://bob.example.com/profile/card.ttl', '<><><>.', {
232 |       headers: {
233 |         'Content-Type': 'text/turtle',
234 |       },
235 |     })
236 |   }
237 | 
238 |   function invitationCanBeSent() {
239 |     return fetchMock.mockOnceIf(
240 |       ({ url, method }) =>
241 |         url === 'https://bob.example.com/inbox' && method === 'POST',
242 |       'Created',
243 |       {
244 |         status: 201,
245 |         headers: {
246 |           location:
247 |             'https://bob.example.com/inbox/22373339-6cc0-49fc-b69e-0402edda6e4e.ttl',
248 |         },
249 |       }
250 |     )
251 |   }
252 | 
253 |   function chatContainerIsFound() {
254 |     return fetchMock.mockOnceIf(
255 |       ({ url, method }) =>
256 |         url === 'https://alice.example.com/IndividualChats/bob.example.com/' &&
257 |         method === 'GET',
258 |       '<><><>.',
259 |       {
260 |         status: 200,
261 |         headers: {
262 |           'Content-Type': 'text/turtle',
263 |           Link: '<.acl>; rel="acl"',
264 |         },
265 |       }
266 |     )
267 |   }
268 | 
269 |   function chatContainerAclCanBeSet() {
270 |     return fetchMock.mockOnceIf(
271 |       ({ url, method }) =>
272 |         url === 'https://alice.example.com/IndividualChats/bob.example.com/.acl' &&
273 |         method === 'PUT',
274 |       'Created',
275 |       {
276 |         status: 201,
277 |       }
278 |     )
279 |   }
280 | 
281 |   function editablePrivateTypeIndexIsFound() {
282 |     return fetchMock.mockOnceIf(
283 |       ({ url, method }) =>
284 |         url === 'https://alice.example.com/settings/privateTypeIndex.ttl' &&
285 |         method === 'GET',
286 |       '<><><>.',
287 |       {
288 |         status: 200,
289 |         headers: {
290 |           'Content-Type': 'text/turtle',
291 |           'wac-allow': 'user="read write append control",public=""',
292 |           'ms-author-via': 'SPARQL',
293 |         },
294 |       }
295 |     )
296 |   }
297 | 
298 |   function privateTypeIndexIsUpdated() {
299 |     return fetchMock.mockOnceIf(
300 |       ({ url, method }) =>
301 |         url === 'https://alice.example.com/settings/privateTypeIndex.ttl' &&
302 |         method === 'PATCH',
303 |       'OK',
304 |       {
305 |         status: 200,
306 |       }
307 |     )
308 |   }
309 | 
310 |   function getRequestTo(
311 |     method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'PATCH',
312 |     url: string
313 |   ): RequestInit {
314 |     const call = fetchMock.mock.calls.find(
315 |       (it) => it[0] === url && method === it[1]?.method
316 |     )
317 |     expect(call).not.toBeNull()
318 |     const request = call?.[1]
319 |     expect(request).not.toBeNull()
320 |     return request!
321 |   }
322 | })
323 | 


--------------------------------------------------------------------------------
/test/typeIndexLogic.test.ts:
--------------------------------------------------------------------------------
  1 | /**
  2 | * @jest-environment jsdom
  3 | *
  4 | */
  5 | import { Fetcher, Store, sym, UpdateManager } from 'rdflib'
  6 | import { createAclLogic } from '../src/acl/aclLogic'
  7 | import { createProfileLogic } from '../src/profile/profileLogic'
  8 | import { createTypeIndexLogic} from '../src/typeIndex/typeIndexLogic'
  9 | import { createContainerLogic } from '../src/util/containerLogic'
 10 | import { ns } from '../src/util/ns'
 11 | import { createUtilityLogic } from '../src/util/utilityLogic'
 12 | import { uniqueNodes } from '../src/util/utils'
 13 | import { alice, AlicePhotoFolder, AlicePhotos, AlicePreferences, AlicePreferencesFile, AlicePrivateTypeIndex, AlicePrivateTypes, AliceProfile, AlicePublicTypeIndex, AlicePublicTypes, bob, BobProfile, club, ClubPreferences, ClubPreferencesFile, ClubPrivateTypeIndex, ClubPrivateTypes, ClubProfile, ClubPublicTypeIndex, ClubPublicTypes } from './helpers/dataSetup'
 14 | 
 15 | const prefixes = Object.keys(ns).map(prefix => `@prefix ${prefix}: ${ns[prefix]('')}.\n`).join('') // In turtle
 16 | window.$SolidTestEnvironment = { username: alice.uri }
 17 | 
 18 | const Tracker = ns.wf('Tracker')
 19 | const Image = ns.schema('Image')
 20 | 
 21 | //let web = {}
 22 | //web = loadWebObject()
 23 | const user = alice
 24 | const profile = user.doc()
 25 | const web = {}
 26 | web[profile.uri] = AliceProfile
 27 | web[AlicePreferencesFile.uri] = AlicePreferences
 28 | web[AlicePrivateTypeIndex.uri] = AlicePrivateTypes
 29 | web[AlicePublicTypeIndex.uri] = AlicePublicTypes
 30 | web[AlicePhotoFolder.uri] = AlicePhotos
 31 | web[bob.doc().uri] = BobProfile
 32 | 
 33 | web[club.doc().uri] = ClubProfile
 34 | web[ClubPreferencesFile.uri] = ClubPreferences
 35 | web[ClubPrivateTypeIndex.uri] = ClubPrivateTypes
 36 | web[ClubPublicTypeIndex.uri] = ClubPublicTypes
 37 | let requests: Request[] = []
 38 | let statustoBeReturned = 200
 39 | let typeIndexLogic
 40 | 
 41 | describe('TypeIndex logic NEW', () => {
 42 |     let store
 43 |     const authn = {
 44 |         currentUser: () => {
 45 |             return alice
 46 |         },
 47 |     }
 48 | 
 49 |     beforeEach(() => {
 50 |         fetchMock.resetMocks()
 51 |         requests = []
 52 |         statustoBeReturned = 200
 53 | 
 54 |         fetchMock.mockIf(/^https?.*$/, async req => {
 55 | 
 56 |         if (req.method !== 'GET') {
 57 |             requests.push(req)
 58 |             if (req.method === 'PUT') {
 59 |             const contents = await req.text()
 60 |             web[req.url] = contents // Update our dummy web
 61 |             console.log(`Tetst: Updated ${req.url} on PUT to <<<${web[req.url]}>>>`)
 62 |             }
 63 |             return { status: statustoBeReturned }
 64 |         }
 65 |         const contents = web[req.url]
 66 |         if (contents !== undefined) { //
 67 |             return {
 68 |             body: prefixes + contents, // Add namespaces to anything
 69 |             status: 200,
 70 |             headers: {
 71 |                 'Content-Type': 'text/turtle',
 72 |                 'WAC-Allow': 'user="write", public="read"',
 73 |                 'Accept-Patch': 'application/sparql-update'
 74 |             }
 75 |             }
 76 |         } // if contents
 77 |         return {
 78 |             status: 404,
 79 |             body: 'Not Found'
 80 |         }
 81 |         })
 82 | 
 83 |         store = new Store()
 84 |         store.fetcher = new Fetcher(store, { fetch: fetch })
 85 |         store.updater = new UpdateManager(store)
 86 |         const util = createUtilityLogic(store, createAclLogic(store), createContainerLogic(store))
 87 |         typeIndexLogic = createTypeIndexLogic(store, authn, createProfileLogic(store, authn, util), util)
 88 |     })
 89 | 
 90 |     describe('loadAllTypeIndexes', () => {
 91 |         it('exists', () => {
 92 |         expect(typeIndexLogic.loadAllTypeIndexes).toBeInstanceOf(Function)
 93 |         })
 94 |     })
 95 | 
 96 |     const AliceScopes = [{
 97 |         'agent': {
 98 |         'classOrder': 5,
 99 |         'termType': 'NamedNode',
100 |         'value': 'https://alice.example.com/profile/card.ttl#me',
101 |         },
102 |         'index': {
103 |         'classOrder': 5,
104 |         'termType': 'NamedNode',
105 |         'value': 'https://alice.example.com/profile/public-type-index.ttl',
106 |         },
107 |         'label': 'public',
108 |     },
109 |     {
110 |         'agent': {
111 |         'classOrder': 5,
112 |         'termType': 'NamedNode',
113 |         'value': 'https://alice.example.com/profile/card.ttl#me',
114 |         },
115 |         'index': {
116 |         'classOrder': 5,
117 |         'termType': 'NamedNode',
118 |         'value': 'https://alice.example.com/settings/private-type-index.ttl',
119 |         },
120 |         'label': 'private',
121 |     }
122 |     ]
123 | 
124 |     describe('loadTypeIndexesFor', () => {
125 |         it('exists', () => {
126 |             expect(typeIndexLogic.loadTypeIndexesFor).toBeInstanceOf(Function)
127 |         })
128 |         it('loads data', async () => {
129 |             const result = await typeIndexLogic.loadTypeIndexesFor(alice)
130 |             expect(result).toEqual(AliceScopes)
131 |             expect(store.statementsMatching(null, null, null, AlicePrivateTypeIndex).length).toEqual(8)
132 |             expect(store.statementsMatching(null, null, null, AlicePublicTypeIndex).length).toEqual(8)
133 |         })
134 |     })
135 | 
136 |     const ClubScopes =
137 |         [
138 |         {
139 |             'agent': {
140 |             'classOrder': 5,
141 |             'termType': 'NamedNode',
142 |             'value': 'https://club.example.com/profile/card.ttl#it',
143 |             },
144 |             'index': {
145 |             'classOrder': 5,
146 |             'termType': 'NamedNode',
147 |             'value': 'https://club.example.com/profile/public-type-index.ttl',
148 |             },
149 |             'label': 'public',
150 |         },
151 |         {
152 |             'agent': {
153 |             'classOrder': 5,
154 |             'termType': 'NamedNode',
155 |             'value': 'https://club.example.com/profile/card.ttl#it',
156 |             },
157 |             'index': {
158 |             'classOrder': 5,
159 |             'termType': 'NamedNode',
160 |             'value': 'https://club.example.com/settings/private-type-index.ttl',
161 |             },
162 |             'label': 'private',
163 |         }
164 |         ]
165 |     describe('loadCommunityTypeIndexes', () => {
166 |         it('exists', () => {
167 |         expect(typeIndexLogic.loadCommunityTypeIndexes).toBeInstanceOf(Function)
168 |         })
169 |         it('loads data', async () => {
170 |         const result = await typeIndexLogic.loadCommunityTypeIndexes(alice)
171 |         expect(result).toEqual(ClubScopes)
172 |         })
173 |     })
174 | 
175 |     const AliceAndClubScopes =  [{'instance': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://alice.example.com/publicStuff/actionItems.ttl#this'}, 'scope': {'agent': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://alice.example.com/profile/card.ttl#me'}, 'index': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://alice.example.com/profile/public-type-index.ttl'}, 'label': 'public'}, 'type': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'http://www.w3.org/2005/01/wf/flow#Tracker'}}, {'instance': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://alice.example.com/project4/issues.ttl#this'}, 'scope': {'agent': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://alice.example.com/profile/card.ttl#me'}, 'index': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://alice.example.com/profile/public-type-index.ttl'}, 'label': 'public'}, 'type': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'http://www.w3.org/2005/01/wf/flow#Tracker'}}, {'instance': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://alice.example.com/privateStuff/ToDo.ttl#this'}, 'scope': {'agent': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://alice.example.com/profile/card.ttl#me'}, 'index': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://alice.example.com/settings/private-type-index.ttl'}, 'label': 'private'}, 'type': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'http://www.w3.org/2005/01/wf/flow#Tracker'}}, {'instance': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://alice.example.com/privateStuff/Goals.ttl#this'}, 'scope': {'agent': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://alice.example.com/profile/card.ttl#me'}, 'index': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://alice.example.com/settings/private-type-index.ttl'}, 'label': 'private'}, 'type': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'http://www.w3.org/2005/01/wf/flow#Tracker'}}, {'instance': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://alice.example.com/privateStuff/workingOn.ttl#this'}, 'scope': {'agent': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://alice.example.com/profile/card.ttl#me'}, 'index': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://alice.example.com/settings/private-type-index.ttl'}, 'label': 'private'}, 'type': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'http://www.w3.org/2005/01/wf/flow#Tracker'}}, {'instance': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://club.example.com/publicStuff/actionItems.ttl#this'}, 'scope': {'agent': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://club.example.com/profile/card.ttl#it'}, 'index': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://club.example.com/profile/public-type-index.ttl'}, 'label': 'public'}, 'type': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'http://www.w3.org/2005/01/wf/flow#Tracker'}}, {'instance': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://club.example.com/project4/clubIssues.ttl#this'}, 'scope': {'agent': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://club.example.com/profile/card.ttl#it'}, 'index': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://club.example.com/profile/public-type-index.ttl'}, 'label': 'public'}, 'type': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'http://www.w3.org/2005/01/wf/flow#Tracker'}}, {'instance': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://club.example.com/privateStuff/ToDo.ttl#this'}, 'scope': {'agent': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://club.example.com/profile/card.ttl#it'}, 'index': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://club.example.com/settings/private-type-index.ttl'}, 'label': 'private'}, 'type': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'http://www.w3.org/2005/01/wf/flow#Tracker'}}, {'instance': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://club.example.com/privateStuff/Goals.ttl#this'}, 'scope': {'agent': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://club.example.com/profile/card.ttl#it'}, 'index': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://club.example.com/settings/private-type-index.ttl'}, 'label': 'private'}, 'type': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'http://www.w3.org/2005/01/wf/flow#Tracker'}}, {'instance': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://club.example.com/privateStuff/tasks.ttl#this'}, 'scope': {'agent': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://club.example.com/profile/card.ttl#it'}, 'index': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://club.example.com/settings/private-type-index.ttl'}, 'label': 'private'}, 'type': {'classOrder': 5, 'termType': 'NamedNode', 'value': 'http://www.w3.org/2005/01/wf/flow#Tracker'}}]
176 | 
177 |     describe('getScopedAppInstances', () => {
178 |         it('exists', () => {
179 |         expect(typeIndexLogic.getScopedAppInstances).toBeInstanceOf(Function)
180 |         })
181 |         it('pulls in users scopes and also community ones', async () => {
182 |         const result = await typeIndexLogic.getScopedAppInstances(Tracker, alice)
183 |         expect(result).toEqual(AliceAndClubScopes) // @@ AliceAndClubScopes
184 |         })
185 |         it('creates new preferenceFile and typeIndex files where they dont exist', async () => {
186 |         await typeIndexLogic.getScopedAppInstances(Tracker, bob)
187 | 
188 |         expect(requests[0].method).toEqual('PATCH') // Add preferrencesFile link to profile
189 |         expect(requests[0].url).toEqual('https://bob.example.com/profile/card.ttl')
190 | 
191 |         expect(requests[1].method).toEqual('PUT') // create publiTypeIndex
192 |         expect(requests[1].url).toEqual('https://bob.example.com/profile/publicTypeIndex.ttl')
193 | 
194 |         expect(requests[2].method).toEqual('PATCH') // Add link of publiTypeIndex to profile
195 |         expect(requests[2].url).toEqual('https://bob.example.com/profile/card.ttl')
196 | 
197 |         expect(requests[3].method).toEqual('PUT') // create preferenceFile
198 |         expect(requests[3].url).toEqual('https://bob.example.com/Settings/Preferences.ttl')
199 | 
200 |         expect(requests[4].method).toEqual('PATCH') // Add privateTypeIndex link preference file
201 |         expect(requests[4].url).toEqual('https://bob.example.com/Settings/Preferences.ttl')
202 | 
203 |         expect(requests[5].method).toEqual('PUT') //create privatTypeIndex
204 |         expect(requests[5].url).toEqual('https://bob.example.com/Settings/privateTypeIndex.ttl')
205 | 
206 |         expect(requests.length).toEqual(6)
207 | 
208 |         })
209 |     })
210 | 
211 |     const TRACKERS =
212 | [{'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://alice.example.com/publicStuff/actionItems.ttl#this'},
213 |  {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://alice.example.com/project4/issues.ttl#this'},
214 |  {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://alice.example.com/privateStuff/ToDo.ttl#this'},
215 |  {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://alice.example.com/privateStuff/Goals.ttl#this'},
216 |  {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://alice.example.com/privateStuff/workingOn.ttl#this'},
217 |  {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://club.example.com/publicStuff/actionItems.ttl#this'},
218 |  {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://club.example.com/project4/clubIssues.ttl#this'},
219 |  {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://club.example.com/privateStuff/ToDo.ttl#this'},
220 |  {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://club.example.com/privateStuff/Goals.ttl#this'},
221 |  {'classOrder': 5, 'termType': 'NamedNode', 'value': 'https://club.example.com/privateStuff/tasks.ttl#this'} ]
222 | 
223 |     describe('getAppInstances', () => {
224 |         it('exists', () => {
225 |         expect(typeIndexLogic.getAppInstances).toBeInstanceOf(Function)
226 |         })
227 |         it('finds trackers', async () => {
228 |         const result = await typeIndexLogic.getAppInstances(Tracker)
229 |         expect(result).toEqual(TRACKERS) // TRACKERS @@
230 |         expect(result).toEqual(uniqueNodes(result)) // shoud have no dups
231 |         })
232 |         it('finds containers', async () => {
233 |         const result = await typeIndexLogic.getAppInstances(Image)
234 |         expect(result.length).toEqual(1)
235 |         expect(result).toEqual(uniqueNodes(result)) // shoud have no dups
236 |         expect(result.map(x => x.uri).join()).toEqual('https://alice.example.com/profile/Photos/')
237 |         })
238 |     })
239 | 
240 |     describe('registerInTypeIndex', () => {
241 |         it('exists', () => {
242 |             expect(typeIndexLogic.registerInTypeIndex).toBeInstanceOf(Function)
243 |         })
244 |         it('throws error', async () => {
245 |             const result = await typeIndexLogic.registerInTypeIndex(
246 |                 sym('https://test.test#'),
247 |                 sym('https://test.test#'),
248 |                 sym('https://test.test/TheClass')
249 |             )
250 |             console.log(result)
251 |             expect(result).toEqual(null)
252 |         })
253 |     })
254 | })
255 | 


--------------------------------------------------------------------------------