From 7c794a47c9ab9723ff50eaff6962ce1af237aced Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Thu, 26 Mar 2026 21:41:19 +1100 Subject: [PATCH 01/30] createa populated pref file --- src/profile/profileLogic.ts | 93 ++++++++++++++++++++++++++++++++++++- test/profileLogic.test.ts | 50 +++++++++++++------- 2 files changed, 123 insertions(+), 20 deletions(-) diff --git a/src/profile/profileLogic.ts b/src/profile/profileLogic.ts index 2f38a7e..7ee8252 100644 --- a/src/profile/profileLogic.ts +++ b/src/profile/profileLogic.ts @@ -1,4 +1,4 @@ -import { NamedNode } from 'rdflib' +import { literal, NamedNode, st, sym } from 'rdflib' import { CrossOriginForbiddenError, FetchError, NotEditableError, SameOriginForbiddenError, UnauthorizedError, WebOperationError } from '../logic/CustomError' import * as debug from '../util/debug' import { ns as namespace } from '../util/ns' @@ -8,6 +8,85 @@ import { ProfileLogic } from '../types' export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { const ns = namespace + function isAbsoluteHttpUri(uri: string | null | undefined): boolean { + return !!uri && (uri.startsWith('https://') || uri.startsWith('http://')) + } + + function docDirUri(node: NamedNode): string | null { + const doc = node.doc() + const dir = doc.dir() + if (dir?.uri && isAbsoluteHttpUri(dir.uri)) return dir.uri + const docUri = doc.uri + if (!docUri || !isAbsoluteHttpUri(docUri)) return null + const withoutFragment = docUri.split('#')[0] + const lastSlash = withoutFragment.lastIndexOf('/') + if (lastSlash === -1) return null + return withoutFragment.slice(0, lastSlash + 1) + } + + function suggestTypeIndexInPreferences(preferencesFile: NamedNode, filename: string): NamedNode { + const dirUri = docDirUri(preferencesFile) + if (!dirUri) throw new Error(`Cannot derive directory for preferences file ${preferencesFile.uri}`) + return sym(dirUri + filename) + } + + async function initializePreferencesDefaults(user: NamedNode, preferencesFile: NamedNode): Promise { + const preferencesDoc = preferencesFile.doc() as NamedNode + await store.fetcher.load(preferencesDoc) + + const publicTypeIndex = + (store.any(user, ns.solid('publicTypeIndex'), null, preferencesDoc) as NamedNode | null) || + (store.any(user, ns.solid('publicTypeIndex'), null, user.doc()) as NamedNode | null) || + suggestTypeIndexInPreferences(preferencesFile, 'publicTypeIndex.ttl') + const privateTypeIndex = + (store.any(user, ns.solid('privateTypeIndex'), null, preferencesDoc) as NamedNode | null) || + suggestTypeIndexInPreferences(preferencesFile, 'privateTypeIndex.ttl') + + const toInsert: any[] = [] + if (!store.holds(preferencesDoc, ns.rdf('type'), ns.space('ConfigurationFile'), preferencesDoc)) { + toInsert.push(st(preferencesDoc, ns.rdf('type'), ns.space('ConfigurationFile'), preferencesDoc)) + } + if (!store.holds(preferencesDoc, ns.dct('title'), undefined, preferencesDoc)) { + toInsert.push(st(preferencesDoc, ns.dct('title'), literal('Preferences file'), preferencesDoc)) + } + if (!store.holds(user, ns.solid('publicTypeIndex'), publicTypeIndex, preferencesDoc)) { + toInsert.push(st(user, ns.solid('publicTypeIndex'), publicTypeIndex, preferencesDoc)) + } + if (!store.holds(user, ns.solid('privateTypeIndex'), privateTypeIndex, preferencesDoc)) { + toInsert.push(st(user, ns.solid('privateTypeIndex'), privateTypeIndex, preferencesDoc)) + } + + if (toInsert.length > 0) { + await store.updater.update([], toInsert) + await store.fetcher.load(preferencesDoc) + } + + await utilityLogic.loadOrCreateIfNotExists(publicTypeIndex) + await utilityLogic.loadOrCreateIfNotExists(privateTypeIndex) + } + + async function ensurePreferencesDocExists(preferencesFile: NamedNode): Promise { + try { + await store.fetcher.load(preferencesFile) + return false + } catch (err) { + if (err.response?.status === 404) { + await utilityLogic.loadOrCreateIfNotExists(preferencesFile) + return true + } + if (err.response?.status === 401) { + throw new UnauthorizedError() + } + if (err.response?.status === 403) { + if (differentOrigin(preferencesFile)) { + throw new CrossOriginForbiddenError() + } + throw new SameOriginForbiddenError() + } + throw err + } + } + /** * loads the preference without throwing errors - if it can create it it does so. * remark: it still throws error if it cannot load profile. @@ -34,7 +113,17 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { const possiblePreferencesFile = suggestPreferencesFile(user) let preferencesFile try { - preferencesFile = await utilityLogic.followOrCreateLink(user, ns.space('preferencesFile') as NamedNode, possiblePreferencesFile, user.doc()) + const existingPreferencesFile = store.any(user, ns.space('preferencesFile'), null, user.doc()) as NamedNode | null + if (existingPreferencesFile) { + preferencesFile = existingPreferencesFile + } else { + preferencesFile = await utilityLogic.followOrCreateLink(user, ns.space('preferencesFile') as NamedNode, possiblePreferencesFile, user.doc()) + } + + const createdOrRepairedPreferencesDoc = await ensurePreferencesDocExists(preferencesFile as NamedNode) + if (!existingPreferencesFile || createdOrRepairedPreferencesDoc) { + await initializePreferencesDefaults(user, preferencesFile as NamedNode) + } } catch (err) { const message = `User ${user} has no pointer in profile to preferences file.` debug.warn(message) diff --git a/test/profileLogic.test.ts b/test/profileLogic.test.ts index 970fd78..1e5b084 100644 --- a/test/profileLogic.test.ts +++ b/test/profileLogic.test.ts @@ -149,16 +149,23 @@ describe('Profile', () => { it('creates new file', async () => { await profileLogic.silencedLoadPreferences(bob) - const patchRequest = requests[0] - expect(patchRequest.method).toEqual('PATCH') - expect(patchRequest.url).toEqual(bob.doc().uri) - const text = await patchRequest.text() - expect(text).toContain('INSERT DATA { .') + const profilePatch = requests.find(req => req.method === 'PATCH' && req.url === bob.doc().uri) + expect(profilePatch).toBeDefined() + const profilePatchText = await profilePatch.text() + expect(profilePatchText).toContain('INSERT DATA { .') - const putRequest = requests[1] - expect(putRequest.method).toEqual('PUT') - expect(putRequest.url).toEqual('https://bob.example.com/Settings/Preferences.ttl') - expect(web[putRequest.url]).toEqual('') + const preferencesPatch = requests.find(req => req.method === 'PATCH' && req.url === 'https://bob.example.com/Settings/Preferences.ttl') + expect(preferencesPatch).toBeDefined() + const preferencesPatchText = await preferencesPatch.text() + expect(preferencesPatchText).toContain(' .') + expect(preferencesPatchText).toContain(' "Preferences file" .') + expect(preferencesPatchText).toContain(' ') + expect(preferencesPatchText).toContain(' ') + + const putUrls = requests.filter(req => req.method === 'PUT').map(req => req.url) + expect(putUrls).toContain('https://bob.example.com/Settings/Preferences.ttl') + expect(putUrls).toContain('https://bob.example.com/Settings/publicTypeIndex.ttl') + expect(putUrls).toContain('https://bob.example.com/Settings/privateTypeIndex.ttl') }) }) @@ -230,16 +237,23 @@ describe('Profile', () => { it('creates new file', async () => { await profileLogic.loadPreferences(boby) - const patchRequest = requests[0] - expect(patchRequest.method).toEqual('PATCH') - expect(patchRequest.url).toEqual(boby.doc().uri) - const text = await patchRequest.text() - expect(text).toContain('INSERT DATA { .') + const profilePatch = requests.find(req => req.method === 'PATCH' && req.url === boby.doc().uri) + expect(profilePatch).toBeDefined() + const profilePatchText = await profilePatch.text() + expect(profilePatchText).toContain('INSERT DATA { .') + + const preferencesPatch = requests.find(req => req.method === 'PATCH' && req.url === 'https://boby.example.com/Settings/Preferences.ttl') + expect(preferencesPatch).toBeDefined() + const preferencesPatchText = await preferencesPatch.text() + expect(preferencesPatchText).toContain(' .') + expect(preferencesPatchText).toContain(' "Preferences file" .') + expect(preferencesPatchText).toContain(' ') + expect(preferencesPatchText).toContain(' ') - const putRequest = requests[1] - expect(putRequest.method).toEqual('PUT') - expect(putRequest.url).toEqual('https://boby.example.com/Settings/Preferences.ttl') - expect(web[putRequest.url]).toEqual('') + const putUrls = requests.filter(req => req.method === 'PUT').map(req => req.url) + expect(putUrls).toContain('https://boby.example.com/Settings/Preferences.ttl') + expect(putUrls).toContain('https://boby.example.com/Settings/publicTypeIndex.ttl') + expect(putUrls).toContain('https://boby.example.com/Settings/privateTypeIndex.ttl') }) }) From c33a03715b2bfb301d379637ceab36e57fb433d3 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Fri, 27 Mar 2026 21:58:25 +1100 Subject: [PATCH 02/30] acl logic for Settings container --- src/profile/profileLogic.ts | 75 +++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/src/profile/profileLogic.ts b/src/profile/profileLogic.ts index 7ee8252..44055d7 100644 --- a/src/profile/profileLogic.ts +++ b/src/profile/profileLogic.ts @@ -1,4 +1,5 @@ import { literal, NamedNode, st, sym } from 'rdflib' +import { ACL_LINK } from '../acl/aclLogic' import { CrossOriginForbiddenError, FetchError, NotEditableError, SameOriginForbiddenError, UnauthorizedError, WebOperationError } from '../logic/CustomError' import * as debug from '../util/debug' import { ns as namespace } from '../util/ns' @@ -30,6 +31,78 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { return sym(dirUri + filename) } + function isNotFoundError(err: any): boolean { + if (err?.response?.status === 404) return true + const text = `${err?.message || err || ''}` + return text.includes('404') || text.includes('Not Found') + } + + function ownerOnlyContainerAcl(webId: string): string { + return [ + '@prefix acl: .', + '', + '<#owner>', + 'a acl:Authorization;', + `acl:agent <${webId}>;`, + 'acl:accessTo <./>;', + 'acl:default <./>;', + 'acl:mode acl:Read, acl:Write, acl:Control.' + ].join('\n') + } + + async function ensureContainerExists(containerUri: string): Promise { + const containerNode = sym(containerUri) + try { + await store.fetcher.load(containerNode) + return + } catch (err) { + if (!isNotFoundError(err)) throw err + } + const result = await store.fetcher._fetch(containerUri, { + method: 'PUT', + headers: { + 'Content-Type': 'text/turtle', + 'If-None-Match': '*', + Link: '; rel="type"', + }, + body: ' ' + }) + if (result.status.toString()[0] !== '2') { + throw new Error(`Not OK: got ${result.status} response while creating container at ${containerUri}`) + } + } + + async function ensureOwnerOnlyAclForSettings(user: NamedNode, preferencesFile: NamedNode): Promise { + const dirUri = docDirUri(preferencesFile) + if (!dirUri) throw new Error(`Cannot derive settings directory from ${preferencesFile.uri}`) + await ensureContainerExists(dirUri) + + const containerNode = sym(dirUri) + let aclDocUri: string | undefined + try { + await store.fetcher.load(containerNode) + aclDocUri = store.any(containerNode, ACL_LINK)?.value + } catch (err) { + if (!isNotFoundError(err)) throw err + } + if (!aclDocUri) { + // Fallback for servers/tests where rel=acl is not exposed in mocked headers. + aclDocUri = `${dirUri}.acl` + } + const aclDoc = sym(aclDocUri) + try { + await store.fetcher.load(aclDoc) + return + } catch (err) { + if (!isNotFoundError(err)) throw err + } + + await store.fetcher.webOperation('PUT', aclDoc.uri, { + data: ownerOnlyContainerAcl(user.uri), + contentType: 'text/turtle' + }) + } + async function initializePreferencesDefaults(user: NamedNode, preferencesFile: NamedNode): Promise { const preferencesDoc = preferencesFile.doc() as NamedNode await store.fetcher.load(preferencesDoc) @@ -120,6 +193,8 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { preferencesFile = await utilityLogic.followOrCreateLink(user, ns.space('preferencesFile') as NamedNode, possiblePreferencesFile, user.doc()) } + await ensureOwnerOnlyAclForSettings(user, preferencesFile as NamedNode) + const createdOrRepairedPreferencesDoc = await ensurePreferencesDocExists(preferencesFile as NamedNode) if (!existingPreferencesFile || createdOrRepairedPreferencesDoc) { await initializePreferencesDefaults(user, preferencesFile as NamedNode) From f190a72995d72f1032df0098fbaf77c6488d2c76 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Fri, 27 Mar 2026 21:59:14 +1100 Subject: [PATCH 03/30] add acl tests --- test/profileLogic.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/profileLogic.test.ts b/test/profileLogic.test.ts index 1e5b084..d48fa4f 100644 --- a/test/profileLogic.test.ts +++ b/test/profileLogic.test.ts @@ -163,10 +163,22 @@ describe('Profile', () => { expect(preferencesPatchText).toContain(' ') const putUrls = requests.filter(req => req.method === 'PUT').map(req => req.url) + expect(putUrls).toContain('https://bob.example.com/Settings/') + expect(putUrls).toContain('https://bob.example.com/Settings/.acl') expect(putUrls).toContain('https://bob.example.com/Settings/Preferences.ttl') expect(putUrls).toContain('https://bob.example.com/Settings/publicTypeIndex.ttl') expect(putUrls).toContain('https://bob.example.com/Settings/privateTypeIndex.ttl') + const settingsAclPut = requests.find(req => req.method === 'PUT' && req.url === 'https://bob.example.com/Settings/.acl') + expect(settingsAclPut).toBeDefined() + const settingsAclBody = web['https://bob.example.com/Settings/.acl'] + expect(settingsAclBody).toContain('@prefix acl: .') + expect(settingsAclBody).toContain('<#owner>') + expect(settingsAclBody).toContain('acl:agent ;') + expect(settingsAclBody).toContain('acl:accessTo <./>;') + expect(settingsAclBody).toContain('acl:default <./>;') + expect(settingsAclBody).toContain('acl:mode acl:Read, acl:Write, acl:Control.') + }) }) @@ -251,10 +263,22 @@ describe('Profile', () => { expect(preferencesPatchText).toContain(' ') const putUrls = requests.filter(req => req.method === 'PUT').map(req => req.url) + expect(putUrls).toContain('https://boby.example.com/Settings/') + expect(putUrls).toContain('https://boby.example.com/Settings/.acl') expect(putUrls).toContain('https://boby.example.com/Settings/Preferences.ttl') expect(putUrls).toContain('https://boby.example.com/Settings/publicTypeIndex.ttl') expect(putUrls).toContain('https://boby.example.com/Settings/privateTypeIndex.ttl') + const settingsAclPut = requests.find(req => req.method === 'PUT' && req.url === 'https://boby.example.com/Settings/.acl') + expect(settingsAclPut).toBeDefined() + const settingsAclBody = web['https://boby.example.com/Settings/.acl'] + expect(settingsAclBody).toContain('@prefix acl: .') + expect(settingsAclBody).toContain('<#owner>') + expect(settingsAclBody).toContain('acl:agent ;') + expect(settingsAclBody).toContain('acl:accessTo <./>;') + expect(settingsAclBody).toContain('acl:default <./>;') + expect(settingsAclBody).toContain('acl:mode acl:Read, acl:Write, acl:Control.') + }) }) }) From e155bb34b8d7cb650e79e086e27a035c9238172b Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Fri, 27 Mar 2026 22:07:42 +1100 Subject: [PATCH 04/30] publictypeindex acl --- src/profile/profileLogic.ts | 69 ++++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/src/profile/profileLogic.ts b/src/profile/profileLogic.ts index 44055d7..bf6dcff 100644 --- a/src/profile/profileLogic.ts +++ b/src/profile/profileLogic.ts @@ -50,6 +50,37 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { ].join('\n') } + function publicTypeIndexAcl(webId: string, publicTypeIndex: NamedNode): string { + const fileName = new URL(publicTypeIndex.uri).pathname.split('/').pop() || 'publicTypeIndex.ttl' + return [ + '# ACL resource for the Public Type Index', + '', + '@prefix acl: .', + '@prefix foaf: .', + '', + '<#owner>', + ' a acl:Authorization;', + '', + ' acl:agent', + ` <${webId}>;`, + '', + ` acl:accessTo <./${fileName}>;`, + '', + ' acl:mode', + ' acl:Read, acl:Write, acl:Control.', + '', + '# Public-readable', + '<#public>', + ' a acl:Authorization;', + '', + ' acl:agentClass foaf:Agent;', + '', + ` acl:accessTo <./${fileName}>;`, + '', + ' acl:mode acl:Read.' + ].join('\n') + } + async function ensureContainerExists(containerUri: string): Promise { const containerNode = sym(containerUri) try { @@ -103,6 +134,42 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { }) } + async function ensurePublicTypeIndexAclOnCreate(user: NamedNode, publicTypeIndex: NamedNode): Promise { + let created = false + try { + await store.fetcher.load(publicTypeIndex) + } catch (err) { + if (!isNotFoundError(err)) throw err + await utilityLogic.loadOrCreateIfNotExists(publicTypeIndex) + created = true + } + if (!created) return + + let aclDocUri: string | undefined + try { + await store.fetcher.load(publicTypeIndex) + aclDocUri = store.any(publicTypeIndex, ACL_LINK)?.value + } catch (err) { + if (!isNotFoundError(err)) throw err + } + if (!aclDocUri) { + aclDocUri = `${publicTypeIndex.uri}.acl` + } + + const aclDoc = sym(aclDocUri) + try { + await store.fetcher.load(aclDoc) + return + } catch (err) { + if (!isNotFoundError(err)) throw err + } + + await store.fetcher.webOperation('PUT', aclDoc.uri, { + data: publicTypeIndexAcl(user.uri, publicTypeIndex), + contentType: 'text/turtle' + }) + } + async function initializePreferencesDefaults(user: NamedNode, preferencesFile: NamedNode): Promise { const preferencesDoc = preferencesFile.doc() as NamedNode await store.fetcher.load(preferencesDoc) @@ -134,7 +201,7 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { await store.fetcher.load(preferencesDoc) } - await utilityLogic.loadOrCreateIfNotExists(publicTypeIndex) + await ensurePublicTypeIndexAclOnCreate(user, publicTypeIndex) await utilityLogic.loadOrCreateIfNotExists(privateTypeIndex) } From 224bf7700344bdc867552f8597650ae76fe3048e Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Fri, 27 Mar 2026 22:10:01 +1100 Subject: [PATCH 05/30] profile test --- test/profileLogic.test.ts | 75 +++++++++++++++++++++++++++++++++------ 1 file changed, 65 insertions(+), 10 deletions(-) diff --git a/test/profileLogic.test.ts b/test/profileLogic.test.ts index d48fa4f..49859bd 100644 --- a/test/profileLogic.test.ts +++ b/test/profileLogic.test.ts @@ -12,20 +12,28 @@ import { import { createAclLogic } from '../src/acl/aclLogic' import { createContainerLogic } from '../src/util/containerLogic' +declare const fetchMock: any + +declare global { + interface Window { + $SolidTestEnvironment?: { username: string } + } +} + const prefixes = Object.keys(ns).map(prefix => `@prefix ${prefix}: ${ns[prefix]('')}.\n`).join('') // In turtle const user = alice const profile = user.doc() let requests: Request[] = [] -let profileLogic +let profileLogic: ReturnType describe('Profile', () => { describe('loadProfile', () => { window.$SolidTestEnvironment = { username: alice.uri } - let store + let store: Store requests = [] const statustoBeReturned = 200 - let web = {} + let web: Record = {} const authn = { currentUser: () => { return alice @@ -35,7 +43,7 @@ describe('Profile', () => { fetchMock.resetMocks() web = loadWebObject() requests = [] - fetchMock.mockIf(/^https?.*$/, async req => { + fetchMock.mockIf(/^https?.*$/, async (req: Request) => { if (req.method !== 'GET') { requests.push(req) @@ -85,10 +93,10 @@ describe('Profile', () => { describe('silencedLoadPreferences', () => { window.$SolidTestEnvironment = { username: alice.uri } - let store + let store: Store requests = [] const statustoBeReturned = 200 - let web = {} + let web: Record = {} const authn = { currentUser: () => { return alice @@ -98,7 +106,7 @@ describe('Profile', () => { fetchMock.resetMocks() web = loadWebObject() requests = [] - fetchMock.mockIf(/^https?.*$/, async req => { + fetchMock.mockIf(/^https?.*$/, async (req: Request) => { if (req.method !== 'GET') { requests.push(req) @@ -139,6 +147,9 @@ describe('Profile', () => { it('loads data', async () => { const result = await profileLogic.silencedLoadPreferences(alice) expect(result).toBeInstanceOf(Object) + if (!result) { + throw new Error('Expected preferences document for alice') + } expect(result.uri).toEqual(AlicePreferencesFile.uri) expect(store.holds(user, ns.rdf('type'), ns.vcard('Individual'), profile)).toEqual(true) expect(store.statementsMatching(null, null, null, profile).length).toEqual(4) @@ -151,11 +162,17 @@ describe('Profile', () => { const profilePatch = requests.find(req => req.method === 'PATCH' && req.url === bob.doc().uri) expect(profilePatch).toBeDefined() + if (!profilePatch) { + throw new Error('Expected profile patch request for bob') + } const profilePatchText = await profilePatch.text() expect(profilePatchText).toContain('INSERT DATA { .') const preferencesPatch = requests.find(req => req.method === 'PATCH' && req.url === 'https://bob.example.com/Settings/Preferences.ttl') expect(preferencesPatch).toBeDefined() + if (!preferencesPatch) { + throw new Error('Expected preferences patch request for bob') + } const preferencesPatchText = await preferencesPatch.text() expect(preferencesPatchText).toContain(' .') expect(preferencesPatchText).toContain(' "Preferences file" .') @@ -167,6 +184,7 @@ describe('Profile', () => { expect(putUrls).toContain('https://bob.example.com/Settings/.acl') expect(putUrls).toContain('https://bob.example.com/Settings/Preferences.ttl') expect(putUrls).toContain('https://bob.example.com/Settings/publicTypeIndex.ttl') + expect(putUrls).toContain('https://bob.example.com/Settings/publicTypeIndex.ttl.acl') expect(putUrls).toContain('https://bob.example.com/Settings/privateTypeIndex.ttl') const settingsAclPut = requests.find(req => req.method === 'PUT' && req.url === 'https://bob.example.com/Settings/.acl') @@ -179,16 +197,31 @@ describe('Profile', () => { expect(settingsAclBody).toContain('acl:default <./>;') expect(settingsAclBody).toContain('acl:mode acl:Read, acl:Write, acl:Control.') + const publicTypeIndexAclPut = requests.find(req => req.method === 'PUT' && req.url === 'https://bob.example.com/Settings/publicTypeIndex.ttl.acl') + expect(publicTypeIndexAclPut).toBeDefined() + const publicTypeIndexAclBody = web['https://bob.example.com/Settings/publicTypeIndex.ttl.acl'] + expect(publicTypeIndexAclBody).toContain('@prefix acl: .') + expect(publicTypeIndexAclBody).toContain('@prefix foaf: .') + expect(publicTypeIndexAclBody).toContain('<#owner>') + expect(publicTypeIndexAclBody).toContain('acl:agent') + expect(publicTypeIndexAclBody).toContain(';') + expect(publicTypeIndexAclBody).toContain('acl:accessTo <./publicTypeIndex.ttl>;') + expect(publicTypeIndexAclBody).toContain('acl:mode') + expect(publicTypeIndexAclBody).toContain('acl:Read, acl:Write, acl:Control.') + expect(publicTypeIndexAclBody).toContain('<#public>') + expect(publicTypeIndexAclBody).toContain('acl:agentClass foaf:Agent;') + expect(publicTypeIndexAclBody).toContain('acl:mode acl:Read.') + }) }) describe('loadPreferences', () => { window.$SolidTestEnvironment = { username: boby.uri } - let store + let store: Store requests = [] const statustoBeReturned = 200 - let web = {} + let web: Record = {} const authn = { currentUser: () => { return boby @@ -198,7 +231,7 @@ describe('Profile', () => { fetchMock.resetMocks() web = loadWebObject() requests = [] - fetchMock.mockIf(/^https?.*$/, async req => { + fetchMock.mockIf(/^https?.*$/, async (req: Request) => { if (req.method !== 'GET') { requests.push(req) @@ -251,11 +284,17 @@ describe('Profile', () => { const profilePatch = requests.find(req => req.method === 'PATCH' && req.url === boby.doc().uri) expect(profilePatch).toBeDefined() + if (!profilePatch) { + throw new Error('Expected profile patch request for boby') + } const profilePatchText = await profilePatch.text() expect(profilePatchText).toContain('INSERT DATA { .') const preferencesPatch = requests.find(req => req.method === 'PATCH' && req.url === 'https://boby.example.com/Settings/Preferences.ttl') expect(preferencesPatch).toBeDefined() + if (!preferencesPatch) { + throw new Error('Expected preferences patch request for boby') + } const preferencesPatchText = await preferencesPatch.text() expect(preferencesPatchText).toContain(' .') expect(preferencesPatchText).toContain(' "Preferences file" .') @@ -267,6 +306,7 @@ describe('Profile', () => { expect(putUrls).toContain('https://boby.example.com/Settings/.acl') expect(putUrls).toContain('https://boby.example.com/Settings/Preferences.ttl') expect(putUrls).toContain('https://boby.example.com/Settings/publicTypeIndex.ttl') + expect(putUrls).toContain('https://boby.example.com/Settings/publicTypeIndex.ttl.acl') expect(putUrls).toContain('https://boby.example.com/Settings/privateTypeIndex.ttl') const settingsAclPut = requests.find(req => req.method === 'PUT' && req.url === 'https://boby.example.com/Settings/.acl') @@ -279,6 +319,21 @@ describe('Profile', () => { expect(settingsAclBody).toContain('acl:default <./>;') expect(settingsAclBody).toContain('acl:mode acl:Read, acl:Write, acl:Control.') + const publicTypeIndexAclPut = requests.find(req => req.method === 'PUT' && req.url === 'https://boby.example.com/Settings/publicTypeIndex.ttl.acl') + expect(publicTypeIndexAclPut).toBeDefined() + const publicTypeIndexAclBody = web['https://boby.example.com/Settings/publicTypeIndex.ttl.acl'] + expect(publicTypeIndexAclBody).toContain('@prefix acl: .') + expect(publicTypeIndexAclBody).toContain('@prefix foaf: .') + expect(publicTypeIndexAclBody).toContain('<#owner>') + expect(publicTypeIndexAclBody).toContain('acl:agent') + expect(publicTypeIndexAclBody).toContain(';') + expect(publicTypeIndexAclBody).toContain('acl:accessTo <./publicTypeIndex.ttl>;') + expect(publicTypeIndexAclBody).toContain('acl:mode') + expect(publicTypeIndexAclBody).toContain('acl:Read, acl:Write, acl:Control.') + expect(publicTypeIndexAclBody).toContain('<#public>') + expect(publicTypeIndexAclBody).toContain('acl:agentClass foaf:Agent;') + expect(publicTypeIndexAclBody).toContain('acl:mode acl:Read.') + }) }) }) From be1cffcb6861b5f1a7edf47fc9e91c8a2ab59528 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Fri, 27 Mar 2026 22:10:23 +1100 Subject: [PATCH 06/30] fix type index test --- test/typeIndexLogic.test.ts | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/test/typeIndexLogic.test.ts b/test/typeIndexLogic.test.ts index 529deb2..17b301e 100644 --- a/test/typeIndexLogic.test.ts +++ b/test/typeIndexLogic.test.ts @@ -206,25 +206,19 @@ describe('TypeIndex logic NEW', () => { it('creates new preferenceFile and typeIndex files where they dont exist', async () => { await typeIndexLogic.getScopedAppInstances(Tracker, bob) - expect(requests[0].method).toEqual('PATCH') // Add preferrencesFile link to profile - expect(requests[0].url).toEqual('https://bob.example.com/profile/card.ttl') + const byUrlAndMethod = (url: string, method: string) => + requests.some(req => req.url === url && req.method === method) - expect(requests[1].method).toEqual('PUT') // create publiTypeIndex - expect(requests[1].url).toEqual('https://bob.example.com/profile/publicTypeIndex.ttl') + // Existing behavior that must remain true + expect(byUrlAndMethod('https://bob.example.com/profile/card.ttl', 'PATCH')).toEqual(true) + expect(byUrlAndMethod('https://bob.example.com/profile/publicTypeIndex.ttl', 'PUT')).toEqual(true) + expect(byUrlAndMethod('https://bob.example.com/Settings/Preferences.ttl', 'PUT')).toEqual(true) + expect(byUrlAndMethod('https://bob.example.com/Settings/Preferences.ttl', 'PATCH')).toEqual(true) + expect(byUrlAndMethod('https://bob.example.com/Settings/privateTypeIndex.ttl', 'PUT')).toEqual(true) - expect(requests[2].method).toEqual('PATCH') // Add link of publiTypeIndex to profile - expect(requests[2].url).toEqual('https://bob.example.com/profile/card.ttl') - - expect(requests[3].method).toEqual('PUT') // create preferenceFile - expect(requests[3].url).toEqual('https://bob.example.com/Settings/Preferences.ttl') - - expect(requests[4].method).toEqual('PATCH') // Add privateTypeIndex link preference file - expect(requests[4].url).toEqual('https://bob.example.com/Settings/Preferences.ttl') - - expect(requests[5].method).toEqual('PUT') //create privatTypeIndex - expect(requests[5].url).toEqual('https://bob.example.com/Settings/privateTypeIndex.ttl') - - expect(requests.length).toEqual(6) + // New ACL/setup behavior + expect(byUrlAndMethod('https://bob.example.com/Settings/', 'PUT')).toEqual(true) + expect(byUrlAndMethod('https://bob.example.com/Settings/.acl', 'PUT')).toEqual(true) }) }) From 86c551b093ba3af40c1b0eb2631b32241b48addb Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Fri, 27 Mar 2026 22:16:36 +1100 Subject: [PATCH 07/30] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/profile/profileLogic.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/profile/profileLogic.ts b/src/profile/profileLogic.ts index bf6dcff..0886293 100644 --- a/src/profile/profileLogic.ts +++ b/src/profile/profileLogic.ts @@ -262,10 +262,8 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { await ensureOwnerOnlyAclForSettings(user, preferencesFile as NamedNode) - const createdOrRepairedPreferencesDoc = await ensurePreferencesDocExists(preferencesFile as NamedNode) - if (!existingPreferencesFile || createdOrRepairedPreferencesDoc) { - await initializePreferencesDefaults(user, preferencesFile as NamedNode) - } + await ensurePreferencesDocExists(preferencesFile as NamedNode) + await initializePreferencesDefaults(user, preferencesFile as NamedNode) } catch (err) { const message = `User ${user} has no pointer in profile to preferences file.` debug.warn(message) From 5a148a61073792600e211960cf7ac21e1b976e69 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Fri, 27 Mar 2026 22:21:23 +1100 Subject: [PATCH 08/30] fixed test --- test/profileLogic.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/profileLogic.test.ts b/test/profileLogic.test.ts index 49859bd..8f96894 100644 --- a/test/profileLogic.test.ts +++ b/test/profileLogic.test.ts @@ -154,7 +154,7 @@ describe('Profile', () => { expect(store.holds(user, ns.rdf('type'), ns.vcard('Individual'), profile)).toEqual(true) expect(store.statementsMatching(null, null, null, profile).length).toEqual(4) - expect(store.statementsMatching(null, null, null, AlicePreferencesFile).length).toEqual(2) + expect(store.statementsMatching(null, null, null, AlicePreferencesFile).length).toBeGreaterThanOrEqual(2) expect(store.holds(user, ns.solid('privateTypeIndex'), AlicePrivateTypeIndex, AlicePreferencesFile)).toEqual(true) }) it('creates new file', async () => { @@ -276,7 +276,7 @@ describe('Profile', () => { expect(store.holds(user, ns.rdf('type'), ns.vcard('Individual'), profile)).toEqual(true) expect(store.statementsMatching(null, null, null, profile).length).toEqual(4) - expect(store.statementsMatching(null, null, null, AlicePreferencesFile).length).toEqual(2) + expect(store.statementsMatching(null, null, null, AlicePreferencesFile).length).toBeGreaterThanOrEqual(2) expect(store.holds(user, ns.solid('privateTypeIndex'), AlicePrivateTypeIndex, AlicePreferencesFile)).toEqual(true) }) it('creates new file', async () => { From 8a01cb3dbde760a093abc046f78aad644461fbd6 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Fri, 27 Mar 2026 22:22:36 +1100 Subject: [PATCH 09/30] fixed another test issue --- test/profileLogic.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/profileLogic.test.ts b/test/profileLogic.test.ts index 8f96894..6367171 100644 --- a/test/profileLogic.test.ts +++ b/test/profileLogic.test.ts @@ -190,6 +190,7 @@ describe('Profile', () => { const settingsAclPut = requests.find(req => req.method === 'PUT' && req.url === 'https://bob.example.com/Settings/.acl') expect(settingsAclPut).toBeDefined() const settingsAclBody = web['https://bob.example.com/Settings/.acl'] + expect(settingsAclBody).toBeDefined() expect(settingsAclBody).toContain('@prefix acl: .') expect(settingsAclBody).toContain('<#owner>') expect(settingsAclBody).toContain('acl:agent ;') @@ -200,6 +201,7 @@ describe('Profile', () => { const publicTypeIndexAclPut = requests.find(req => req.method === 'PUT' && req.url === 'https://bob.example.com/Settings/publicTypeIndex.ttl.acl') expect(publicTypeIndexAclPut).toBeDefined() const publicTypeIndexAclBody = web['https://bob.example.com/Settings/publicTypeIndex.ttl.acl'] + expect(publicTypeIndexAclBody).toBeDefined() expect(publicTypeIndexAclBody).toContain('@prefix acl: .') expect(publicTypeIndexAclBody).toContain('@prefix foaf: .') expect(publicTypeIndexAclBody).toContain('<#owner>') @@ -312,6 +314,7 @@ describe('Profile', () => { const settingsAclPut = requests.find(req => req.method === 'PUT' && req.url === 'https://boby.example.com/Settings/.acl') expect(settingsAclPut).toBeDefined() const settingsAclBody = web['https://boby.example.com/Settings/.acl'] + expect(settingsAclBody).toBeDefined() expect(settingsAclBody).toContain('@prefix acl: .') expect(settingsAclBody).toContain('<#owner>') expect(settingsAclBody).toContain('acl:agent ;') @@ -322,6 +325,7 @@ describe('Profile', () => { const publicTypeIndexAclPut = requests.find(req => req.method === 'PUT' && req.url === 'https://boby.example.com/Settings/publicTypeIndex.ttl.acl') expect(publicTypeIndexAclPut).toBeDefined() const publicTypeIndexAclBody = web['https://boby.example.com/Settings/publicTypeIndex.ttl.acl'] + expect(publicTypeIndexAclBody).toBeDefined() expect(publicTypeIndexAclBody).toContain('@prefix acl: .') expect(publicTypeIndexAclBody).toContain('@prefix foaf: .') expect(publicTypeIndexAclBody).toContain('<#owner>') From 43b58afcbf0b17fae9090420f05ef2fbbb2a7187 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Fri, 27 Mar 2026 22:23:53 +1100 Subject: [PATCH 10/30] tests --- test/profileLogic.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/profileLogic.test.ts b/test/profileLogic.test.ts index 6367171..d5b6468 100644 --- a/test/profileLogic.test.ts +++ b/test/profileLogic.test.ts @@ -202,6 +202,7 @@ describe('Profile', () => { expect(publicTypeIndexAclPut).toBeDefined() const publicTypeIndexAclBody = web['https://bob.example.com/Settings/publicTypeIndex.ttl.acl'] expect(publicTypeIndexAclBody).toBeDefined() + expect(publicTypeIndexAclBody).not.toEqual('') expect(publicTypeIndexAclBody).toContain('@prefix acl: .') expect(publicTypeIndexAclBody).toContain('@prefix foaf: .') expect(publicTypeIndexAclBody).toContain('<#owner>') @@ -326,6 +327,7 @@ describe('Profile', () => { expect(publicTypeIndexAclPut).toBeDefined() const publicTypeIndexAclBody = web['https://boby.example.com/Settings/publicTypeIndex.ttl.acl'] expect(publicTypeIndexAclBody).toBeDefined() + expect(publicTypeIndexAclBody).not.toEqual('') expect(publicTypeIndexAclBody).toContain('@prefix acl: .') expect(publicTypeIndexAclBody).toContain('@prefix foaf: .') expect(publicTypeIndexAclBody).toContain('<#owner>') From 1d8dee3c6f10880a7f4670e5f712117e04ae2859 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Fri, 27 Mar 2026 22:28:49 +1100 Subject: [PATCH 11/30] make sure publictype is added to webid as well --- src/profile/profileLogic.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/profile/profileLogic.ts b/src/profile/profileLogic.ts index 0886293..c0eb9e4 100644 --- a/src/profile/profileLogic.ts +++ b/src/profile/profileLogic.ts @@ -134,7 +134,7 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { }) } - async function ensurePublicTypeIndexAclOnCreate(user: NamedNode, publicTypeIndex: NamedNode): Promise { + async function ensurePublicTypeIndexAclOnCreate(user: NamedNode, publicTypeIndex: NamedNode, ensureAcl = false): Promise { let created = false try { await store.fetcher.load(publicTypeIndex) @@ -143,7 +143,7 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { await utilityLogic.loadOrCreateIfNotExists(publicTypeIndex) created = true } - if (!created) return + if (!created && !ensureAcl) return let aclDocUri: string | undefined try { @@ -172,16 +172,27 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { async function initializePreferencesDefaults(user: NamedNode, preferencesFile: NamedNode): Promise { const preferencesDoc = preferencesFile.doc() as NamedNode + const profileDoc = user.doc() as NamedNode await store.fetcher.load(preferencesDoc) + const profilePublicTypeIndex = + (store.any(user, ns.solid('publicTypeIndex'), null, profileDoc) as NamedNode | null) + const preferencesPublicTypeIndex = + (store.any(user, ns.solid('publicTypeIndex'), null, preferencesDoc) as NamedNode | null) const publicTypeIndex = - (store.any(user, ns.solid('publicTypeIndex'), null, preferencesDoc) as NamedNode | null) || - (store.any(user, ns.solid('publicTypeIndex'), null, user.doc()) as NamedNode | null) || + profilePublicTypeIndex || + preferencesPublicTypeIndex || suggestTypeIndexInPreferences(preferencesFile, 'publicTypeIndex.ttl') const privateTypeIndex = (store.any(user, ns.solid('privateTypeIndex'), null, preferencesDoc) as NamedNode | null) || suggestTypeIndexInPreferences(preferencesFile, 'privateTypeIndex.ttl') + // Keep discovery consistent with typeIndexLogic, which resolves publicTypeIndex from the profile doc. + const createdProfilePublicTypeIndexLink = !profilePublicTypeIndex + if (createdProfilePublicTypeIndexLink) { + await utilityLogic.followOrCreateLink(user, ns.solid('publicTypeIndex') as NamedNode, publicTypeIndex, profileDoc) + } + const toInsert: any[] = [] if (!store.holds(preferencesDoc, ns.rdf('type'), ns.space('ConfigurationFile'), preferencesDoc)) { toInsert.push(st(preferencesDoc, ns.rdf('type'), ns.space('ConfigurationFile'), preferencesDoc)) @@ -201,7 +212,7 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { await store.fetcher.load(preferencesDoc) } - await ensurePublicTypeIndexAclOnCreate(user, publicTypeIndex) + await ensurePublicTypeIndexAclOnCreate(user, publicTypeIndex, createdProfilePublicTypeIndexLink) await utilityLogic.loadOrCreateIfNotExists(privateTypeIndex) } From 1473356fe5518aba5179d234dc35baae6958665b Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sat, 28 Mar 2026 20:23:47 +1100 Subject: [PATCH 12/30] create privite index with data --- src/profile/profileLogic.ts | 26 +++++++++++++++++++++++++- test/profileLogic.test.ts | 14 ++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/profile/profileLogic.ts b/src/profile/profileLogic.ts index c0eb9e4..fa193ef 100644 --- a/src/profile/profileLogic.ts +++ b/src/profile/profileLogic.ts @@ -81,6 +81,15 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { ].join('\n') } + function privateTypeIndexDocument(): string { + return [ + '@prefix solid: .', + '<>', + ' a solid:TypeIndex ;', + ' a solid:UnlistedDocument.' + ].join('\n') + } + async function ensureContainerExists(containerUri: string): Promise { const containerNode = sym(containerUri) try { @@ -170,6 +179,21 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { }) } + async function ensurePrivateTypeIndexOnCreate(privateTypeIndex: NamedNode): Promise { + try { + await store.fetcher.load(privateTypeIndex) + return + } catch (err) { + if (!isNotFoundError(err)) throw err + } + + await store.fetcher.webOperation('PUT', privateTypeIndex.uri, { + data: privateTypeIndexDocument(), + contentType: 'text/turtle' + }) + await store.fetcher.load(privateTypeIndex) + } + async function initializePreferencesDefaults(user: NamedNode, preferencesFile: NamedNode): Promise { const preferencesDoc = preferencesFile.doc() as NamedNode const profileDoc = user.doc() as NamedNode @@ -213,7 +237,7 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { } await ensurePublicTypeIndexAclOnCreate(user, publicTypeIndex, createdProfilePublicTypeIndexLink) - await utilityLogic.loadOrCreateIfNotExists(privateTypeIndex) + await ensurePrivateTypeIndexOnCreate(privateTypeIndex) } async function ensurePreferencesDocExists(preferencesFile: NamedNode): Promise { diff --git a/test/profileLogic.test.ts b/test/profileLogic.test.ts index d5b6468..65ba085 100644 --- a/test/profileLogic.test.ts +++ b/test/profileLogic.test.ts @@ -187,6 +187,13 @@ describe('Profile', () => { expect(putUrls).toContain('https://bob.example.com/Settings/publicTypeIndex.ttl.acl') expect(putUrls).toContain('https://bob.example.com/Settings/privateTypeIndex.ttl') + const privateTypeIndexBody = web['https://bob.example.com/Settings/privateTypeIndex.ttl'] + expect(privateTypeIndexBody).toBeDefined() + expect(privateTypeIndexBody).toContain('@prefix solid: .') + expect(privateTypeIndexBody).toContain('<>') + expect(privateTypeIndexBody).toContain('a solid:TypeIndex ;') + expect(privateTypeIndexBody).toContain('a solid:UnlistedDocument.') + const settingsAclPut = requests.find(req => req.method === 'PUT' && req.url === 'https://bob.example.com/Settings/.acl') expect(settingsAclPut).toBeDefined() const settingsAclBody = web['https://bob.example.com/Settings/.acl'] @@ -312,6 +319,13 @@ describe('Profile', () => { expect(putUrls).toContain('https://boby.example.com/Settings/publicTypeIndex.ttl.acl') expect(putUrls).toContain('https://boby.example.com/Settings/privateTypeIndex.ttl') + const privateTypeIndexBody = web['https://boby.example.com/Settings/privateTypeIndex.ttl'] + expect(privateTypeIndexBody).toBeDefined() + expect(privateTypeIndexBody).toContain('@prefix solid: .') + expect(privateTypeIndexBody).toContain('<>') + expect(privateTypeIndexBody).toContain('a solid:TypeIndex ;') + expect(privateTypeIndexBody).toContain('a solid:UnlistedDocument.') + const settingsAclPut = requests.find(req => req.method === 'PUT' && req.url === 'https://boby.example.com/Settings/.acl') expect(settingsAclPut).toBeDefined() const settingsAclBody = web['https://boby.example.com/Settings/.acl'] From aa376622829f6fbc57cc857db9e4d5b4adfc5ae2 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sat, 28 Mar 2026 20:30:19 +1100 Subject: [PATCH 13/30] still call loadorcreate --- src/profile/profileLogic.ts | 49 ++++++++++++++++++++++--------------- test/profileLogic.test.ts | 14 +++++++++++ 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/src/profile/profileLogic.ts b/src/profile/profileLogic.ts index fa193ef..d3db667 100644 --- a/src/profile/profileLogic.ts +++ b/src/profile/profileLogic.ts @@ -81,6 +81,15 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { ].join('\n') } + function publicTypeIndexDocument(): string { + return [ + '@prefix solid: .', + '<>', + ' a solid:TypeIndex ;', + ' a solid:ListedDocument.' + ].join('\n') + } + function privateTypeIndexDocument(): string { return [ '@prefix solid: .', @@ -90,6 +99,23 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { ].join('\n') } + async function ensureTypeIndexOnCreate(typeIndex: NamedNode, data: string): Promise { + try { + await store.fetcher.load(typeIndex) + return false + } catch (err) { + if (!isNotFoundError(err)) throw err + } + + await utilityLogic.loadOrCreateIfNotExists(typeIndex) + await store.fetcher.webOperation('PUT', typeIndex.uri, { + data, + contentType: 'text/turtle' + }) + await store.fetcher.load(typeIndex) + return true + } + async function ensureContainerExists(containerUri: string): Promise { const containerNode = sym(containerUri) try { @@ -144,14 +170,7 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { } async function ensurePublicTypeIndexAclOnCreate(user: NamedNode, publicTypeIndex: NamedNode, ensureAcl = false): Promise { - let created = false - try { - await store.fetcher.load(publicTypeIndex) - } catch (err) { - if (!isNotFoundError(err)) throw err - await utilityLogic.loadOrCreateIfNotExists(publicTypeIndex) - created = true - } + const created = await ensureTypeIndexOnCreate(publicTypeIndex, publicTypeIndexDocument()) if (!created && !ensureAcl) return let aclDocUri: string | undefined @@ -180,18 +199,7 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { } async function ensurePrivateTypeIndexOnCreate(privateTypeIndex: NamedNode): Promise { - try { - await store.fetcher.load(privateTypeIndex) - return - } catch (err) { - if (!isNotFoundError(err)) throw err - } - - await store.fetcher.webOperation('PUT', privateTypeIndex.uri, { - data: privateTypeIndexDocument(), - contentType: 'text/turtle' - }) - await store.fetcher.load(privateTypeIndex) + await ensureTypeIndexOnCreate(privateTypeIndex, privateTypeIndexDocument()) } async function initializePreferencesDefaults(user: NamedNode, preferencesFile: NamedNode): Promise { @@ -214,6 +222,7 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { // Keep discovery consistent with typeIndexLogic, which resolves publicTypeIndex from the profile doc. const createdProfilePublicTypeIndexLink = !profilePublicTypeIndex if (createdProfilePublicTypeIndexLink) { + await ensureTypeIndexOnCreate(publicTypeIndex, publicTypeIndexDocument()) await utilityLogic.followOrCreateLink(user, ns.solid('publicTypeIndex') as NamedNode, publicTypeIndex, profileDoc) } diff --git a/test/profileLogic.test.ts b/test/profileLogic.test.ts index 65ba085..a6f7a24 100644 --- a/test/profileLogic.test.ts +++ b/test/profileLogic.test.ts @@ -187,6 +187,13 @@ describe('Profile', () => { expect(putUrls).toContain('https://bob.example.com/Settings/publicTypeIndex.ttl.acl') expect(putUrls).toContain('https://bob.example.com/Settings/privateTypeIndex.ttl') + const publicTypeIndexBody = web['https://bob.example.com/Settings/publicTypeIndex.ttl'] + expect(publicTypeIndexBody).toBeDefined() + expect(publicTypeIndexBody).toContain('@prefix solid: .') + expect(publicTypeIndexBody).toContain('<>') + expect(publicTypeIndexBody).toContain('a solid:TypeIndex ;') + expect(publicTypeIndexBody).toContain('a solid:ListedDocument.') + const privateTypeIndexBody = web['https://bob.example.com/Settings/privateTypeIndex.ttl'] expect(privateTypeIndexBody).toBeDefined() expect(privateTypeIndexBody).toContain('@prefix solid: .') @@ -319,6 +326,13 @@ describe('Profile', () => { expect(putUrls).toContain('https://boby.example.com/Settings/publicTypeIndex.ttl.acl') expect(putUrls).toContain('https://boby.example.com/Settings/privateTypeIndex.ttl') + const publicTypeIndexBody = web['https://boby.example.com/Settings/publicTypeIndex.ttl'] + expect(publicTypeIndexBody).toBeDefined() + expect(publicTypeIndexBody).toContain('@prefix solid: .') + expect(publicTypeIndexBody).toContain('<>') + expect(publicTypeIndexBody).toContain('a solid:TypeIndex ;') + expect(publicTypeIndexBody).toContain('a solid:ListedDocument.') + const privateTypeIndexBody = web['https://boby.example.com/Settings/privateTypeIndex.ttl'] expect(privateTypeIndexBody).toBeDefined() expect(privateTypeIndexBody).toContain('@prefix solid: .') From baa13a71fa3373704ab8fbc66fc9857d8e4490b9 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sat, 28 Mar 2026 20:33:17 +1100 Subject: [PATCH 14/30] change Preferences.ttl to prefs.ttl to match server creation --- src/util/utils.ts | 2 +- test/profileLogic.test.ts | 20 ++++++++++---------- test/typeIndexLogic.test.ts | 4 ++-- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/util/utils.ts b/src/util/utils.ts index c1ccd99..5eab07e 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -34,7 +34,7 @@ export function suggestPreferencesFile (me:NamedNode) { const stripped = me.uri.replace('/profile/', '/').replace('/public/', '/') // const stripped = me.uri.replace(\/[p|P]rofile/\g, '/').replace(\/[p|P]ublic/\g, '/') const folderURI = stripped.split('/').slice(0,-1).join('/') + '/Settings/' - const fileURI = folderURI + 'Preferences.ttl' + const fileURI = folderURI + 'prefs.ttl' return sym(fileURI) } diff --git a/test/profileLogic.test.ts b/test/profileLogic.test.ts index a6f7a24..f734c77 100644 --- a/test/profileLogic.test.ts +++ b/test/profileLogic.test.ts @@ -166,23 +166,23 @@ describe('Profile', () => { throw new Error('Expected profile patch request for bob') } const profilePatchText = await profilePatch.text() - expect(profilePatchText).toContain('INSERT DATA { .') + expect(profilePatchText).toContain('INSERT DATA { .') - const preferencesPatch = requests.find(req => req.method === 'PATCH' && req.url === 'https://bob.example.com/Settings/Preferences.ttl') + const preferencesPatch = requests.find(req => req.method === 'PATCH' && req.url === 'https://bob.example.com/Settings/prefs.ttl') expect(preferencesPatch).toBeDefined() if (!preferencesPatch) { throw new Error('Expected preferences patch request for bob') } const preferencesPatchText = await preferencesPatch.text() - expect(preferencesPatchText).toContain(' .') - expect(preferencesPatchText).toContain(' "Preferences file" .') + expect(preferencesPatchText).toContain(' .') + expect(preferencesPatchText).toContain(' "Preferences file" .') expect(preferencesPatchText).toContain(' ') expect(preferencesPatchText).toContain(' ') const putUrls = requests.filter(req => req.method === 'PUT').map(req => req.url) expect(putUrls).toContain('https://bob.example.com/Settings/') expect(putUrls).toContain('https://bob.example.com/Settings/.acl') - expect(putUrls).toContain('https://bob.example.com/Settings/Preferences.ttl') + expect(putUrls).toContain('https://bob.example.com/Settings/prefs.ttl') expect(putUrls).toContain('https://bob.example.com/Settings/publicTypeIndex.ttl') expect(putUrls).toContain('https://bob.example.com/Settings/publicTypeIndex.ttl.acl') expect(putUrls).toContain('https://bob.example.com/Settings/privateTypeIndex.ttl') @@ -305,23 +305,23 @@ describe('Profile', () => { throw new Error('Expected profile patch request for boby') } const profilePatchText = await profilePatch.text() - expect(profilePatchText).toContain('INSERT DATA { .') + expect(profilePatchText).toContain('INSERT DATA { .') - const preferencesPatch = requests.find(req => req.method === 'PATCH' && req.url === 'https://boby.example.com/Settings/Preferences.ttl') + const preferencesPatch = requests.find(req => req.method === 'PATCH' && req.url === 'https://boby.example.com/Settings/prefs.ttl') expect(preferencesPatch).toBeDefined() if (!preferencesPatch) { throw new Error('Expected preferences patch request for boby') } const preferencesPatchText = await preferencesPatch.text() - expect(preferencesPatchText).toContain(' .') - expect(preferencesPatchText).toContain(' "Preferences file" .') + expect(preferencesPatchText).toContain(' .') + expect(preferencesPatchText).toContain(' "Preferences file" .') expect(preferencesPatchText).toContain(' ') expect(preferencesPatchText).toContain(' ') const putUrls = requests.filter(req => req.method === 'PUT').map(req => req.url) expect(putUrls).toContain('https://boby.example.com/Settings/') expect(putUrls).toContain('https://boby.example.com/Settings/.acl') - expect(putUrls).toContain('https://boby.example.com/Settings/Preferences.ttl') + expect(putUrls).toContain('https://boby.example.com/Settings/prefs.ttl') expect(putUrls).toContain('https://boby.example.com/Settings/publicTypeIndex.ttl') expect(putUrls).toContain('https://boby.example.com/Settings/publicTypeIndex.ttl.acl') expect(putUrls).toContain('https://boby.example.com/Settings/privateTypeIndex.ttl') diff --git a/test/typeIndexLogic.test.ts b/test/typeIndexLogic.test.ts index 17b301e..30edae8 100644 --- a/test/typeIndexLogic.test.ts +++ b/test/typeIndexLogic.test.ts @@ -212,8 +212,8 @@ describe('TypeIndex logic NEW', () => { // Existing behavior that must remain true expect(byUrlAndMethod('https://bob.example.com/profile/card.ttl', 'PATCH')).toEqual(true) expect(byUrlAndMethod('https://bob.example.com/profile/publicTypeIndex.ttl', 'PUT')).toEqual(true) - expect(byUrlAndMethod('https://bob.example.com/Settings/Preferences.ttl', 'PUT')).toEqual(true) - expect(byUrlAndMethod('https://bob.example.com/Settings/Preferences.ttl', 'PATCH')).toEqual(true) + expect(byUrlAndMethod('https://bob.example.com/Settings/prefs.ttl', 'PUT')).toEqual(true) + expect(byUrlAndMethod('https://bob.example.com/Settings/prefs.ttl', 'PATCH')).toEqual(true) expect(byUrlAndMethod('https://bob.example.com/Settings/privateTypeIndex.ttl', 'PUT')).toEqual(true) // New ACL/setup behavior From 2bef43570cf26e33a2d8fbd3d80ed3cb67ebf23e Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sat, 28 Mar 2026 20:45:42 +1100 Subject: [PATCH 15/30] clean up tests --- src/profile/profileLogic.ts | 23 +++-------------- src/typeIndex/typeIndexLogic.ts | 45 ++++++++++++++++++++++++++------- src/util/utilityLogic.ts | 23 ++++++++++++++++- test/typeIndexLogic.test.ts | 24 ++++++++++++++---- test/utilityLogic.test.ts | 27 ++++++++++++++++++++ 5 files changed, 107 insertions(+), 35 deletions(-) diff --git a/src/profile/profileLogic.ts b/src/profile/profileLogic.ts index d3db667..185f6af 100644 --- a/src/profile/profileLogic.ts +++ b/src/profile/profileLogic.ts @@ -99,23 +99,6 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { ].join('\n') } - async function ensureTypeIndexOnCreate(typeIndex: NamedNode, data: string): Promise { - try { - await store.fetcher.load(typeIndex) - return false - } catch (err) { - if (!isNotFoundError(err)) throw err - } - - await utilityLogic.loadOrCreateIfNotExists(typeIndex) - await store.fetcher.webOperation('PUT', typeIndex.uri, { - data, - contentType: 'text/turtle' - }) - await store.fetcher.load(typeIndex) - return true - } - async function ensureContainerExists(containerUri: string): Promise { const containerNode = sym(containerUri) try { @@ -170,7 +153,7 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { } async function ensurePublicTypeIndexAclOnCreate(user: NamedNode, publicTypeIndex: NamedNode, ensureAcl = false): Promise { - const created = await ensureTypeIndexOnCreate(publicTypeIndex, publicTypeIndexDocument()) + const created = await utilityLogic.loadOrCreateWithContentOnCreate(publicTypeIndex, publicTypeIndexDocument()) if (!created && !ensureAcl) return let aclDocUri: string | undefined @@ -199,7 +182,7 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { } async function ensurePrivateTypeIndexOnCreate(privateTypeIndex: NamedNode): Promise { - await ensureTypeIndexOnCreate(privateTypeIndex, privateTypeIndexDocument()) + await utilityLogic.loadOrCreateWithContentOnCreate(privateTypeIndex, privateTypeIndexDocument()) } async function initializePreferencesDefaults(user: NamedNode, preferencesFile: NamedNode): Promise { @@ -222,7 +205,7 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { // Keep discovery consistent with typeIndexLogic, which resolves publicTypeIndex from the profile doc. const createdProfilePublicTypeIndexLink = !profilePublicTypeIndex if (createdProfilePublicTypeIndexLink) { - await ensureTypeIndexOnCreate(publicTypeIndex, publicTypeIndexDocument()) + await utilityLogic.loadOrCreateWithContentOnCreate(publicTypeIndex, publicTypeIndexDocument()) await utilityLogic.followOrCreateLink(user, ns.solid('publicTypeIndex') as NamedNode, publicTypeIndex, profileDoc) } diff --git a/src/typeIndex/typeIndexLogic.ts b/src/typeIndex/typeIndexLogic.ts index 9505f65..f857681 100644 --- a/src/typeIndex/typeIndexLogic.ts +++ b/src/typeIndex/typeIndexLogic.ts @@ -7,6 +7,24 @@ import { newThing } from '../util/utils' export function createTypeIndexLogic(store, authn, profileLogic, utilityLogic): TypeIndexLogic { const ns = namespace + function publicTypeIndexDocument(): string { + return [ + '@prefix solid: .', + '<>', + ' a solid:TypeIndex ;', + ' a solid:ListedDocument.' + ].join('\n') + } + + function privateTypeIndexDocument(): string { + return [ + '@prefix solid: .', + '<>', + ' a solid:TypeIndex ;', + ' a solid:UnlistedDocument.' + ].join('\n') + } + function isAbsoluteHttpUri(uri: string | null | undefined): boolean { return !!uri && (uri.startsWith('https://') || uri.startsWith('http://')) } @@ -32,11 +50,15 @@ export function createTypeIndexLogic(store, authn, profileLogic, utilityLogic): } let publicTypeIndex try { - publicTypeIndex = - store.any(user, ns.solid('publicTypeIndex'), undefined, profile) || - (suggestion - ? await utilityLogic.followOrCreateLink(user, ns.solid('publicTypeIndex') as NamedNode, suggestion, profile) - : null) + const existingPublicTypeIndex = store.any(user, ns.solid('publicTypeIndex'), undefined, profile) + if (existingPublicTypeIndex) { + publicTypeIndex = existingPublicTypeIndex + } else if (suggestion) { + await utilityLogic.loadOrCreateWithContentOnCreate(suggestion, publicTypeIndexDocument()) + publicTypeIndex = await utilityLogic.followOrCreateLink(user, ns.solid('publicTypeIndex') as NamedNode, suggestion, profile) + } else { + publicTypeIndex = null + } } catch (err) { const message = `User ${user} has no pointer in profile to publicTypeIndex file: ${err}` debug.warn(message) @@ -63,10 +85,15 @@ export function createTypeIndexLogic(store, authn, profileLogic, utilityLogic): } let privateTypeIndex try { - privateTypeIndex = store.any(user, ns.solid('privateTypeIndex'), undefined, profile) || - (suggestedPrivateTypeIndex - ? await utilityLogic.followOrCreateLink(user, ns.solid('privateTypeIndex') as NamedNode, suggestedPrivateTypeIndex, preferencesFile) - : null) + const existingPrivateTypeIndex = store.any(user, ns.solid('privateTypeIndex'), undefined, profile) + if (existingPrivateTypeIndex) { + privateTypeIndex = existingPrivateTypeIndex + } else if (suggestedPrivateTypeIndex) { + await utilityLogic.loadOrCreateWithContentOnCreate(suggestedPrivateTypeIndex, privateTypeIndexDocument()) + privateTypeIndex = await utilityLogic.followOrCreateLink(user, ns.solid('privateTypeIndex') as NamedNode, suggestedPrivateTypeIndex, preferencesFile) + } else { + privateTypeIndex = null + } } catch (err) { const message = `User ${user} has no pointer in preference file to privateTypeIndex file: ${err}` debug.warn(message) diff --git a/src/util/utilityLogic.ts b/src/util/utilityLogic.ts index d82c63f..5417439 100644 --- a/src/util/utilityLogic.ts +++ b/src/util/utilityLogic.ts @@ -5,6 +5,12 @@ import { differentOrigin } from './utils' export function createUtilityLogic(store, aclLogic, containerLogic) { + function isNotFoundError(err: any): boolean { + if (err?.response?.status === 404) return true + const text = `${err?.message || err || ''}` + return text.includes('404') || text.includes('Not Found') + } + async function recursiveDelete(containerNode: NamedNode) { try { if (containerLogic.isContainer(containerNode)) { @@ -58,6 +64,20 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { return response } + async function loadOrCreateWithContentOnCreate(doc: NamedNode, data: string): Promise { + try { + await store.fetcher.load(doc) + return false + } catch (err) { + if (!isNotFoundError(err)) throw err + } + + await loadOrCreateIfNotExists(doc) + await store.fetcher.webOperation('PUT', doc.uri, { data, contentType: 'text/turtle' }) + await store.fetcher.load(doc) + return true + } + /* Follow link from this doc to another thing, or else make a new link ** ** @returns existing object, or creates it if non existent @@ -150,7 +170,8 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { setSinglePeerAccess, createEmptyRdfDoc, followOrCreateLink, - loadOrCreateIfNotExists + loadOrCreateIfNotExists, + loadOrCreateWithContentOnCreate } } diff --git a/test/typeIndexLogic.test.ts b/test/typeIndexLogic.test.ts index 30edae8..1a80bd4 100644 --- a/test/typeIndexLogic.test.ts +++ b/test/typeIndexLogic.test.ts @@ -2,7 +2,7 @@ * @jest-environment jsdom * */ -import { Fetcher, Store, sym, UpdateManager } from 'rdflib' +import { Fetcher, NamedNode, Store, sym, UpdateManager } from 'rdflib' import { createAclLogic } from '../src/acl/aclLogic' import { createProfileLogic } from '../src/profile/profileLogic' import { createTypeIndexLogic} from '../src/typeIndex/typeIndexLogic' @@ -22,7 +22,7 @@ const Image = ns.schema('Image') //web = loadWebObject() const user = alice const profile = user.doc() -const web = {} +const web: Record = {} web[profile.uri] = AliceProfile web[AlicePreferencesFile.uri] = AlicePreferences web[AlicePrivateTypeIndex.uri] = AlicePrivateTypes @@ -36,10 +36,10 @@ web[ClubPrivateTypeIndex.uri] = ClubPrivateTypes web[ClubPublicTypeIndex.uri] = ClubPublicTypes let requests: Request[] = [] let statustoBeReturned = 200 -let typeIndexLogic +let typeIndexLogic: ReturnType describe('TypeIndex logic NEW', () => { - let store + let store: Store const authn = { currentUser: () => { return alice @@ -51,7 +51,7 @@ describe('TypeIndex logic NEW', () => { requests = [] statustoBeReturned = 200 - fetchMock.mockIf(/^https?.*$/, async req => { + fetchMock.mockIf(/^https?.*$/, async (req: Request) => { if (req.method !== 'GET') { requests.push(req) @@ -216,6 +216,20 @@ describe('TypeIndex logic NEW', () => { expect(byUrlAndMethod('https://bob.example.com/Settings/prefs.ttl', 'PATCH')).toEqual(true) expect(byUrlAndMethod('https://bob.example.com/Settings/privateTypeIndex.ttl', 'PUT')).toEqual(true) + const createdPublicTypeIndexBody = web['https://bob.example.com/profile/publicTypeIndex.ttl'] + expect(createdPublicTypeIndexBody).toBeDefined() + expect(createdPublicTypeIndexBody).toContain('@prefix solid: .') + expect(createdPublicTypeIndexBody).toContain('<>') + expect(createdPublicTypeIndexBody).toContain('a solid:TypeIndex ;') + expect(createdPublicTypeIndexBody).toContain('a solid:ListedDocument.') + + const createdPrivateTypeIndexBody = web['https://bob.example.com/Settings/privateTypeIndex.ttl'] + expect(createdPrivateTypeIndexBody).toBeDefined() + expect(createdPrivateTypeIndexBody).toContain('@prefix solid: .') + expect(createdPrivateTypeIndexBody).toContain('<>') + expect(createdPrivateTypeIndexBody).toContain('a solid:TypeIndex ;') + expect(createdPrivateTypeIndexBody).toContain('a solid:UnlistedDocument.') + // New ACL/setup behavior expect(byUrlAndMethod('https://bob.example.com/Settings/', 'PUT')).toEqual(true) expect(byUrlAndMethod('https://bob.example.com/Settings/.acl', 'PUT')).toEqual(true) diff --git a/test/utilityLogic.test.ts b/test/utilityLogic.test.ts index 2d51589..2c1773d 100644 --- a/test/utilityLogic.test.ts +++ b/test/utilityLogic.test.ts @@ -95,6 +95,33 @@ describe('utilityLogic', () => { }) }) + describe('loadOrCreateWithContentOnCreate', () => { + it('exists', () => { + expect(utilityLogic.loadOrCreateWithContentOnCreate).toBeInstanceOf(Function) + }) + it('creates and seeds content when missing', async () => { + const suggestion = 'https://bob.example.com/settings/new-index.ttl' + const body = [ + '@prefix solid: .', + '<>', + ' a solid:TypeIndex ;', + ' a solid:ListedDocument.' + ].join('\n') + const created = await utilityLogic.loadOrCreateWithContentOnCreate(sym(suggestion), body) + + expect(created).toEqual(true) + expect(web[suggestion]).toEqual(body) + }) + it('does not overwrite existing content', async () => { + const existing = AlicePrivateTypeIndex.uri + const before = web[existing] + const created = await utilityLogic.loadOrCreateWithContentOnCreate(sym(existing), 'NEW') + + expect(created).toEqual(false) + expect(web[existing]).toEqual(before) + }) + }) + describe('followOrCreateLink', () => { it('exists', () => { expect(utilityLogic.followOrCreateLink).toBeInstanceOf(Function) From 0f5801c68cefbb54ee79e354d3aded444f292869 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sat, 28 Mar 2026 20:52:11 +1100 Subject: [PATCH 16/30] refactor type index doc content --- src/profile/profileLogic.ts | 19 +------------------ src/typeIndex/typeIndexDocuments.ts | 17 +++++++++++++++++ src/typeIndex/typeIndexLogic.ts | 19 +------------------ 3 files changed, 19 insertions(+), 36 deletions(-) create mode 100644 src/typeIndex/typeIndexDocuments.ts diff --git a/src/profile/profileLogic.ts b/src/profile/profileLogic.ts index 185f6af..28981ab 100644 --- a/src/profile/profileLogic.ts +++ b/src/profile/profileLogic.ts @@ -3,6 +3,7 @@ import { ACL_LINK } from '../acl/aclLogic' import { CrossOriginForbiddenError, FetchError, NotEditableError, SameOriginForbiddenError, UnauthorizedError, WebOperationError } from '../logic/CustomError' import * as debug from '../util/debug' import { ns as namespace } from '../util/ns' +import { privateTypeIndexDocument, publicTypeIndexDocument } from '../typeIndex/typeIndexDocuments' import { differentOrigin, suggestPreferencesFile } from '../util/utils' import { ProfileLogic } from '../types' @@ -81,24 +82,6 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { ].join('\n') } - function publicTypeIndexDocument(): string { - return [ - '@prefix solid: .', - '<>', - ' a solid:TypeIndex ;', - ' a solid:ListedDocument.' - ].join('\n') - } - - function privateTypeIndexDocument(): string { - return [ - '@prefix solid: .', - '<>', - ' a solid:TypeIndex ;', - ' a solid:UnlistedDocument.' - ].join('\n') - } - async function ensureContainerExists(containerUri: string): Promise { const containerNode = sym(containerUri) try { diff --git a/src/typeIndex/typeIndexDocuments.ts b/src/typeIndex/typeIndexDocuments.ts new file mode 100644 index 0000000..de4112a --- /dev/null +++ b/src/typeIndex/typeIndexDocuments.ts @@ -0,0 +1,17 @@ +export function publicTypeIndexDocument(): string { + return [ + '@prefix solid: .', + '<>', + ' a solid:TypeIndex ;', + ' a solid:ListedDocument.' + ].join('\n') +} + +export function privateTypeIndexDocument(): string { + return [ + '@prefix solid: .', + '<>', + ' a solid:TypeIndex ;', + ' a solid:UnlistedDocument.' + ].join('\n') +} \ No newline at end of file diff --git a/src/typeIndex/typeIndexLogic.ts b/src/typeIndex/typeIndexLogic.ts index f857681..436c528 100644 --- a/src/typeIndex/typeIndexLogic.ts +++ b/src/typeIndex/typeIndexLogic.ts @@ -3,28 +3,11 @@ import { ScopedApp, TypeIndexLogic, TypeIndexScope } from '../types' import * as debug from '../util/debug' import { ns as namespace } from '../util/ns' import { newThing } from '../util/utils' +import { privateTypeIndexDocument, publicTypeIndexDocument } from './typeIndexDocuments' export function createTypeIndexLogic(store, authn, profileLogic, utilityLogic): TypeIndexLogic { const ns = namespace - function publicTypeIndexDocument(): string { - return [ - '@prefix solid: .', - '<>', - ' a solid:TypeIndex ;', - ' a solid:ListedDocument.' - ].join('\n') - } - - function privateTypeIndexDocument(): string { - return [ - '@prefix solid: .', - '<>', - ' a solid:TypeIndex ;', - ' a solid:UnlistedDocument.' - ].join('\n') - } - function isAbsoluteHttpUri(uri: string | null | undefined): boolean { return !!uri && (uri.startsWith('https://') || uri.startsWith('http://')) } From d1bb6d9ae833564c6a1e193011db0e2935a83d11 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sat, 28 Mar 2026 21:00:14 +1100 Subject: [PATCH 17/30] Update test/typeIndexLogic.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- test/typeIndexLogic.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/typeIndexLogic.test.ts b/test/typeIndexLogic.test.ts index 1a80bd4..41e7da0 100644 --- a/test/typeIndexLogic.test.ts +++ b/test/typeIndexLogic.test.ts @@ -2,7 +2,7 @@ * @jest-environment jsdom * */ -import { Fetcher, NamedNode, Store, sym, UpdateManager } from 'rdflib' +import { Fetcher, Store, sym, UpdateManager } from 'rdflib' import { createAclLogic } from '../src/acl/aclLogic' import { createProfileLogic } from '../src/profile/profileLogic' import { createTypeIndexLogic} from '../src/typeIndex/typeIndexLogic' From 8a912a735c1160a10e7a4c5189c2d1057dd953d8 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sat, 28 Mar 2026 21:02:14 +1100 Subject: [PATCH 18/30] Update src/util/utilityLogic.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/util/utilityLogic.ts | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/util/utilityLogic.ts b/src/util/utilityLogic.ts index 5417439..3bcead2 100644 --- a/src/util/utilityLogic.ts +++ b/src/util/utilityLogic.ts @@ -66,16 +66,33 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { async function loadOrCreateWithContentOnCreate(doc: NamedNode, data: string): Promise { try { + // If the document already exists, do not overwrite it; just report "not created". await store.fetcher.load(doc) return false - } catch (err) { + } catch (err: any) { if (!isNotFoundError(err)) throw err } - await loadOrCreateIfNotExists(doc) - await store.fetcher.webOperation('PUT', doc.uri, { data, contentType: 'text/turtle' }) - await store.fetcher.load(doc) - return true + // At this point, the document appears to be missing. Try to create it atomically + // with a conditional PUT so we don't overwrite a concurrently created resource. + try { + await store.fetcher.webOperation('PUT', doc, { + data, + contentType: 'text/turtle', + headers: { 'If-None-Match': '*' } + }) + await store.fetcher.load(doc) + return true + } catch (err: any) { + const status = err?.response?.status ?? err?.status + if (status === 412) { + // Another client created the resource between our initial 404 and this PUT. + // Treat it as pre-existing and do not overwrite their content. + await store.fetcher.load(doc) + return false + } + throw err + } } /* Follow link from this doc to another thing, or else make a new link From 78cccd17ab981ec4253346f01532f45aa15def2b Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sat, 28 Mar 2026 21:02:52 +1100 Subject: [PATCH 19/30] Update src/profile/profileLogic.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/profile/profileLogic.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/profile/profileLogic.ts b/src/profile/profileLogic.ts index 28981ab..4b21ac3 100644 --- a/src/profile/profileLogic.ts +++ b/src/profile/profileLogic.ts @@ -220,7 +220,7 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { await store.fetcher.load(preferencesFile) return false } catch (err) { - if (err.response?.status === 404) { + if (isNotFoundError(err)) { await utilityLogic.loadOrCreateIfNotExists(preferencesFile) return true } From 94483d251f542481d6cbb397d82a90f59cbb4eae Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sat, 28 Mar 2026 21:07:06 +1100 Subject: [PATCH 20/30] make sure unorphaned index --- src/profile/profileLogic.ts | 9 +++++++-- src/typeIndex/typeIndexLogic.ts | 18 +++++++++++++---- src/util/utilityLogic.ts | 34 +++++++++++++++++++++++++++++++++ test/utilityLogic.test.ts | 29 ++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/profile/profileLogic.ts b/src/profile/profileLogic.ts index 4b21ac3..d65084e 100644 --- a/src/profile/profileLogic.ts +++ b/src/profile/profileLogic.ts @@ -188,8 +188,13 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { // Keep discovery consistent with typeIndexLogic, which resolves publicTypeIndex from the profile doc. const createdProfilePublicTypeIndexLink = !profilePublicTypeIndex if (createdProfilePublicTypeIndexLink) { - await utilityLogic.loadOrCreateWithContentOnCreate(publicTypeIndex, publicTypeIndexDocument()) - await utilityLogic.followOrCreateLink(user, ns.solid('publicTypeIndex') as NamedNode, publicTypeIndex, profileDoc) + await utilityLogic.followOrCreateLinkWithContentOnCreate( + user, + ns.solid('publicTypeIndex') as NamedNode, + publicTypeIndex, + profileDoc, + publicTypeIndexDocument() + ) } const toInsert: any[] = [] diff --git a/src/typeIndex/typeIndexLogic.ts b/src/typeIndex/typeIndexLogic.ts index 436c528..3195deb 100644 --- a/src/typeIndex/typeIndexLogic.ts +++ b/src/typeIndex/typeIndexLogic.ts @@ -37,8 +37,13 @@ export function createTypeIndexLogic(store, authn, profileLogic, utilityLogic): if (existingPublicTypeIndex) { publicTypeIndex = existingPublicTypeIndex } else if (suggestion) { - await utilityLogic.loadOrCreateWithContentOnCreate(suggestion, publicTypeIndexDocument()) - publicTypeIndex = await utilityLogic.followOrCreateLink(user, ns.solid('publicTypeIndex') as NamedNode, suggestion, profile) + publicTypeIndex = await utilityLogic.followOrCreateLinkWithContentOnCreate( + user, + ns.solid('publicTypeIndex') as NamedNode, + suggestion, + profile, + publicTypeIndexDocument() + ) } else { publicTypeIndex = null } @@ -72,8 +77,13 @@ export function createTypeIndexLogic(store, authn, profileLogic, utilityLogic): if (existingPrivateTypeIndex) { privateTypeIndex = existingPrivateTypeIndex } else if (suggestedPrivateTypeIndex) { - await utilityLogic.loadOrCreateWithContentOnCreate(suggestedPrivateTypeIndex, privateTypeIndexDocument()) - privateTypeIndex = await utilityLogic.followOrCreateLink(user, ns.solid('privateTypeIndex') as NamedNode, suggestedPrivateTypeIndex, preferencesFile) + privateTypeIndex = await utilityLogic.followOrCreateLinkWithContentOnCreate( + user, + ns.solid('privateTypeIndex') as NamedNode, + suggestedPrivateTypeIndex, + preferencesFile, + privateTypeIndexDocument() + ) } else { privateTypeIndex = null } diff --git a/src/util/utilityLogic.ts b/src/util/utilityLogic.ts index 3bcead2..9e2ae88 100644 --- a/src/util/utilityLogic.ts +++ b/src/util/utilityLogic.ts @@ -129,6 +129,39 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { return object } + async function followOrCreateLinkWithContentOnCreate( + subject: NamedNode, + predicate: NamedNode, + object: NamedNode, + doc: NamedNode, + data: string + ): Promise { + await store.fetcher.load(doc) + const result = store.any(subject, predicate, null, doc) + + if (result) return result as NamedNode + if (!store.updater.editable(doc)) { + const msg = `followOrCreateLinkWithContentOnCreate: cannot edit ${doc.value}` + debug.warn(msg) + throw new NotEditableError(msg) + } + try { + await store.updater.update([], [st(subject, predicate, object, doc)]) + } catch (err) { + const msg = `followOrCreateLinkWithContentOnCreate: Error making link in ${doc} to ${object}: ${err}` + debug.warn(msg) + throw new WebOperationError(err) + } + + try { + await loadOrCreateWithContentOnCreate(object, data) + } catch (err) { + debug.warn(`followOrCreateLinkWithContentOnCreate: Error loading or saving new linked document: ${object}: ${err}`) + throw err + } + return object + } + // Copied from https://github.com/solidos/web-access-control-tests/blob/v3.0.0/test/surface/delete.test.ts#L5 async function setSinglePeerAccess(options: { ownerWebId: string, @@ -187,6 +220,7 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { setSinglePeerAccess, createEmptyRdfDoc, followOrCreateLink, + followOrCreateLinkWithContentOnCreate, loadOrCreateIfNotExists, loadOrCreateWithContentOnCreate } diff --git a/test/utilityLogic.test.ts b/test/utilityLogic.test.ts index 2c1773d..747094f 100644 --- a/test/utilityLogic.test.ts +++ b/test/utilityLogic.test.ts @@ -152,6 +152,35 @@ describe('utilityLogic', () => { }) }) + + describe('followOrCreateLinkWithContentOnCreate', () => { + it('exists', () => { + expect(utilityLogic.followOrCreateLinkWithContentOnCreate).toBeInstanceOf(Function) + }) + it('does not create target doc when link patch fails', async () => { + const suggestion = 'https://bob.example.com/settings/prefsSuggestion.ttl' + const body = [ + '@prefix solid: .', + '<>', + ' a solid:TypeIndex ;', + ' a solid:ListedDocument.' + ].join('\n') + + statustoBeReturned = 403 // Make PATCH fail + await expect( + utilityLogic.followOrCreateLinkWithContentOnCreate( + bob, + ns.space('preferencesFile'), + sym(suggestion), + bob.doc(), + body + ) + ).rejects.toThrow(WebOperationError) + + expect(web[suggestion]).toBeUndefined() + }) + }) + describe('setSinglePeerAccess', () => { beforeEach(() => { fetchMock.mockOnceIf( From 0fc3f841716605af99a8c7a0646268620c5e83eb Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sat, 28 Mar 2026 21:10:59 +1100 Subject: [PATCH 21/30] use createContainer --- src/profile/profileLogic.ts | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/profile/profileLogic.ts b/src/profile/profileLogic.ts index d65084e..b3f4edf 100644 --- a/src/profile/profileLogic.ts +++ b/src/profile/profileLogic.ts @@ -4,11 +4,13 @@ import { CrossOriginForbiddenError, FetchError, NotEditableError, SameOriginForb import * as debug from '../util/debug' import { ns as namespace } from '../util/ns' import { privateTypeIndexDocument, publicTypeIndexDocument } from '../typeIndex/typeIndexDocuments' +import { createContainerLogic } from '../util/containerLogic' import { differentOrigin, suggestPreferencesFile } from '../util/utils' import { ProfileLogic } from '../types' export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { const ns = namespace + const containerLogic = createContainerLogic(store) function isAbsoluteHttpUri(uri: string | null | undefined): boolean { return !!uri && (uri.startsWith('https://') || uri.startsWith('http://')) @@ -90,18 +92,7 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { } catch (err) { if (!isNotFoundError(err)) throw err } - const result = await store.fetcher._fetch(containerUri, { - method: 'PUT', - headers: { - 'Content-Type': 'text/turtle', - 'If-None-Match': '*', - Link: '; rel="type"', - }, - body: ' ' - }) - if (result.status.toString()[0] !== '2') { - throw new Error(`Not OK: got ${result.status} response while creating container at ${containerUri}`) - } + await containerLogic.createContainer(containerUri) } async function ensureOwnerOnlyAclForSettings(user: NamedNode, preferencesFile: NamedNode): Promise { From 0e956f4c4f12fb826c64a5fd750cbf6a1f317879 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sat, 28 Mar 2026 21:12:20 +1100 Subject: [PATCH 22/30] Update src/typeIndex/typeIndexLogic.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/typeIndex/typeIndexLogic.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/typeIndex/typeIndexLogic.ts b/src/typeIndex/typeIndexLogic.ts index 3195deb..b456342 100644 --- a/src/typeIndex/typeIndexLogic.ts +++ b/src/typeIndex/typeIndexLogic.ts @@ -87,7 +87,7 @@ export function createTypeIndexLogic(store, authn, profileLogic, utilityLogic): } else { privateTypeIndex = null } - } catch (err) { + } catch (err) { const message = `User ${user} has no pointer in preference file to privateTypeIndex file: ${err}` debug.warn(message) } From c20ca2b64232992aaf0ad8917366bb4a1fc7e314 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sun, 29 Mar 2026 13:53:10 +1100 Subject: [PATCH 23/30] prevent race conditions when creating a container --- src/util/containerLogic.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/util/containerLogic.ts b/src/util/containerLogic.ts index 00ce90b..33cafb5 100644 --- a/src/util/containerLogic.ts +++ b/src/util/containerLogic.ts @@ -35,6 +35,10 @@ export function createContainerLogic(store) { }, body: ' ', // work around https://github.com/michielbdejong/community-server/issues/4#issuecomment-776222863 }) + // Treat 409 as idempotent success: another process/request already created the container. + if (result.status === 409) { + return + } if (result.status.toString()[0] !== '2') { throw new Error(`Not OK: got ${result.status} response while creating container at ${url}`) } From 1e8752f225e8334d1246edd61404c4e2263ab683 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sun, 29 Mar 2026 14:23:53 +1100 Subject: [PATCH 24/30] idempotent container creation --- src/util/utilityLogic.ts | 144 +++++++++++++++++++-------------------- 1 file changed, 72 insertions(+), 72 deletions(-) diff --git a/src/util/utilityLogic.ts b/src/util/utilityLogic.ts index 9e2ae88..d08265f 100644 --- a/src/util/utilityLogic.ts +++ b/src/util/utilityLogic.ts @@ -1,106 +1,105 @@ import { NamedNode, st, sym } from 'rdflib' -import { CrossOriginForbiddenError, FetchError, NotEditableError, SameOriginForbiddenError, UnauthorizedError, WebOperationError } from '../logic/CustomError' +import { + CrossOriginForbiddenError, + FetchError, + NotEditableError, + SameOriginForbiddenError, + UnauthorizedError, + WebOperationError +} from '../logic/CustomError' import * as debug from '../util/debug' import { differentOrigin } from './utils' export function createUtilityLogic(store, aclLogic, containerLogic) { - - function isNotFoundError(err: any): boolean { - if (err?.response?.status === 404) return true - const text = `${err?.message || err || ''}` - return text.includes('404') || text.includes('Not Found') - } - async function recursiveDelete(containerNode: NamedNode) { - try { - if (containerLogic.isContainer(containerNode)) { - const aclDocUrl = await aclLogic.findAclDocUrl(containerNode) - await store.fetcher._fetch(aclDocUrl, { method: 'DELETE' }) - const containerMembers = await containerLogic.getContainerMembers(containerNode) - await Promise.all( - containerMembers.map((url) => recursiveDelete(url)) - ) - } - const nodeToStringHere = containerNode.value - return store.fetcher._fetch(nodeToStringHere, { method: 'DELETE' }) - } catch (e) { - debug.log(`Please manually remove ${containerNode.value} from your system.`, e) + try { + if (containerLogic.isContainer(containerNode)) { + const aclDocUrl = await aclLogic.findAclDocUrl(containerNode) + await store.fetcher._fetch(aclDocUrl, { method: 'DELETE' }) + const containerMembers = await containerLogic.getContainerMembers(containerNode) + await Promise.all(containerMembers.map((url) => recursiveDelete(url))) } + return store.fetcher._fetch(containerNode.value, { method: 'DELETE' }) + } catch (_e) { + } } /** - * Create a resource if it really does not exist - * Be absolutely sure something does not exist before creating a new empty file - * as otherwise existing could be deleted. - * @param doc {NamedNode} - The resource + * Create a resource if it really does not exist. + * Be absolutely sure something does not exist before creating a new empty file, + * as otherwise existing content could be deleted. */ async function loadOrCreateIfNotExists(doc: NamedNode) { - let response try { - response = await store.fetcher.load(doc) - } catch (err) { - if (err.response.status === 404) { + return await store.fetcher.load(doc) + } catch (err: any) { + if (err?.response?.status === 404) { try { - await store.fetcher.webOperation('PUT', doc, { data: '', contentType: 'text/turtle' }) - } catch (err) { - const msg = 'createIfNotExists: PUT FAILED: ' + doc + ': ' + err - throw new WebOperationError(msg) - } - await store.fetcher.load(doc) - } else { - if (err.response.status === 401) { - throw new UnauthorizedError() - } - if (err.response.status === 403) { - if (differentOrigin(doc)) { - throw new CrossOriginForbiddenError() + await store.fetcher.webOperation('PUT', doc.uri, { + data: '', + contentType: 'text/turtle', + headers: { 'If-None-Match': '*' } + }) + } catch (putErr: any) { + const status = putErr?.response?.status ?? putErr?.status + if (status !== 412) { + const msg = `createIfNotExists: PUT FAILED: ${doc}: ${putErr}` + throw new WebOperationError(msg) } - throw new SameOriginForbiddenError() } - const msg = 'createIfNotExists doc load error NOT 404: ' + doc + ': ' + err - throw new FetchError(err.status, err.message + msg) + return await store.fetcher.load(doc) + } + if (err?.response?.status === 401) { + throw new UnauthorizedError() } + if (err?.response?.status === 403) { + if (differentOrigin(doc)) { + throw new CrossOriginForbiddenError() + } + throw new SameOriginForbiddenError() + } + const msg = `createIfNotExists doc load error: ${doc}: ${err}` + throw new FetchError(err?.status, `${err?.message || ''}${msg}`) } - return response } async function loadOrCreateWithContentOnCreate(doc: NamedNode, data: string): Promise { try { - // If the document already exists, do not overwrite it; just report "not created". await store.fetcher.load(doc) return false } catch (err: any) { - if (!isNotFoundError(err)) throw err + const status = err?.response?.status ?? err?.status + if (status !== 404) { + throw err + } } - // At this point, the document appears to be missing. Try to create it atomically - // with a conditional PUT so we don't overwrite a concurrently created resource. try { - await store.fetcher.webOperation('PUT', doc, { + await store.fetcher.webOperation('PUT', doc.uri, { data, contentType: 'text/turtle', headers: { 'If-None-Match': '*' } }) - await store.fetcher.load(doc) return true } catch (err: any) { const status = err?.response?.status ?? err?.status if (status === 412) { - // Another client created the resource between our initial 404 and this PUT. - // Treat it as pre-existing and do not overwrite their content. - await store.fetcher.load(doc) + // Another client created the resource between our 404 check and PUT. return false } throw err } } - /* Follow link from this doc to another thing, or else make a new link - ** - ** @returns existing object, or creates it if non existent - */ - async function followOrCreateLink(subject: NamedNode, predicate: NamedNode, - object: NamedNode, doc: NamedNode + /* + * Follow link from this doc to another thing, or else make a new link. + * Returns existing object, or creates it if non-existent. + */ + async function followOrCreateLink( + subject: NamedNode, + predicate: NamedNode, + object: NamedNode, + doc: NamedNode ): Promise { await store.fetcher.load(doc) const result = store.any(subject, predicate, null, doc) @@ -111,6 +110,7 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { debug.warn(msg) throw new NotEditableError(msg) } + try { await store.updater.update([], [st(subject, predicate, object, doc)]) } catch (err) { @@ -121,7 +121,6 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { try { await loadOrCreateIfNotExists(object) - // store.fetcher.webOperation('PUT', object, { data: '', contentType: 'text/turtle'}) } catch (err) { debug.warn(`followOrCreateLink: Error loading or saving new linked document: ${object}: ${err}`) throw err @@ -145,6 +144,7 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { debug.warn(msg) throw new NotEditableError(msg) } + try { await store.updater.update([], [st(subject, predicate, object, doc)]) } catch (err) { @@ -162,7 +162,8 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { return object } - // Copied from https://github.com/solidos/web-access-control-tests/blob/v3.0.0/test/surface/delete.test.ts#L5 + // Copied from + // https://github.com/solidos/web-access-control-tests/blob/v3.0.0/test/surface/delete.test.ts#L5 async function setSinglePeerAccess(options: { ownerWebId: string, peerWebId: string, @@ -173,12 +174,13 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { let str = [ '@prefix acl: .', '', - `<#alice> a acl:Authorization;\n acl:agent <${options.ownerWebId}>;`, + '<#alice> a acl:Authorization;\n acl:agent <' + options.ownerWebId + '>;', ` acl:accessTo <${options.target}>;`, ` acl:default <${options.target}>;`, ' acl:mode acl:Read, acl:Write, acl:Control.', '' ].join('\n') + if (options.accessToModes) { str += [ '<#bobAccessTo> a acl:Authorization;', @@ -188,6 +190,7 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { '' ].join('\n') } + if (options.defaultModes) { str += [ '<#bobDefault> a acl:Authorization;', @@ -197,24 +200,22 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { '' ].join('\n') } + const aclDocUrl = await aclLogic.findAclDocUrl(sym(options.target)) return store.fetcher._fetch(aclDocUrl, { method: 'PUT', body: str, - headers: [ - ['Content-Type', 'text/turtle'] - ] + headers: [['Content-Type', 'text/turtle']] }) } async function createEmptyRdfDoc(doc: NamedNode, comment: string) { await store.fetcher.webOperation('PUT', doc.uri, { - data: `# ${new Date()} ${comment} - `, - contentType: 'text/turtle', + data: `# ${new Date()} ${comment}\n`, + contentType: 'text/turtle' }) } - + return { recursiveDelete, setSinglePeerAccess, @@ -225,4 +226,3 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { loadOrCreateWithContentOnCreate } } - From fea4638ea8a855960f7cf860c7ec0faf0fb5b7e5 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sun, 29 Mar 2026 14:24:31 +1100 Subject: [PATCH 25/30] public index plus race cond fix --- src/profile/profileLogic.ts | 85 +++++++++++++++++++++++++------------ 1 file changed, 57 insertions(+), 28 deletions(-) diff --git a/src/profile/profileLogic.ts b/src/profile/profileLogic.ts index b3f4edf..d4a4aa3 100644 --- a/src/profile/profileLogic.ts +++ b/src/profile/profileLogic.ts @@ -11,6 +11,8 @@ import { ProfileLogic } from '../types' export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { const ns = namespace const containerLogic = createContainerLogic(store) + const loadPreferencesInFlight = new Map>() + const cachedPreferencesFileByWebId = new Map() function isAbsoluteHttpUri(uri: string | null | undefined): boolean { return !!uri && (uri.startsWith('https://') || uri.startsWith('http://')) @@ -40,19 +42,6 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { return text.includes('404') || text.includes('Not Found') } - function ownerOnlyContainerAcl(webId: string): string { - return [ - '@prefix acl: .', - '', - '<#owner>', - 'a acl:Authorization;', - `acl:agent <${webId}>;`, - 'acl:accessTo <./>;', - 'acl:default <./>;', - 'acl:mode acl:Read, acl:Write, acl:Control.' - ].join('\n') - } - function publicTypeIndexAcl(webId: string, publicTypeIndex: NamedNode): string { const fileName = new URL(publicTypeIndex.uri).pathname.split('/').pop() || 'publicTypeIndex.ttl' return [ @@ -84,6 +73,19 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { ].join('\n') } + function ownerOnlyContainerAcl(webId: string): string { + return [ + '@prefix acl: .', + '', + '<#owner>', + 'a acl:Authorization;', + `acl:agent <${webId}>;`, + 'acl:accessTo <./>;', + 'acl:default <./>;', + 'acl:mode acl:Read, acl:Write, acl:Control.' + ].join('\n') + } + async function ensureContainerExists(containerUri: string): Promise { const containerNode = sym(containerUri) try { @@ -143,16 +145,15 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { const aclDoc = sym(aclDocUri) try { - await store.fetcher.load(aclDoc) - return - } catch (err) { - if (!isNotFoundError(err)) throw err + await store.fetcher.webOperation('PUT', aclDoc.uri, { + data: publicTypeIndexAcl(user.uri, publicTypeIndex), + contentType: 'text/turtle', + headers: { 'If-None-Match': '*' } + }) + } catch (err: any) { + const status = err?.response?.status ?? err?.status + if (status !== 412) throw err } - - await store.fetcher.webOperation('PUT', aclDoc.uri, { - data: publicTypeIndexAcl(user.uri, publicTypeIndex), - contentType: 'text/turtle' - }) } async function ensurePrivateTypeIndexOnCreate(privateTypeIndex: NamedNode): Promise { @@ -213,13 +214,12 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { async function ensurePreferencesDocExists(preferencesFile: NamedNode): Promise { try { - await store.fetcher.load(preferencesFile) - return false - } catch (err) { - if (isNotFoundError(err)) { - await utilityLogic.loadOrCreateIfNotExists(preferencesFile) + const created = await utilityLogic.loadOrCreateWithContentOnCreate(preferencesFile, '') + if (created) { return true } + return false + } catch (err) { if (err.response?.status === 401) { throw new UnauthorizedError() } @@ -254,6 +254,17 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { * @returns undefined if preferenceFile cannot be an Error or NamedNode if it can find it or create it */ async function loadPreferences (user: NamedNode): Promise { + const cachedPreferencesFile = cachedPreferencesFileByWebId.get(user.uri) + if (cachedPreferencesFile) { + return cachedPreferencesFile + } + + const inFlight = loadPreferencesInFlight.get(user.uri) + if (inFlight) { + return inFlight + } + + const run = (async (): Promise => { await loadProfile(user) const possiblePreferencesFile = suggestPreferencesFile(user) @@ -267,7 +278,6 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { } await ensureOwnerOnlyAclForSettings(user, preferencesFile as NamedNode) - await ensurePreferencesDocExists(preferencesFile as NamedNode) await initializePreferencesDefaults(user, preferencesFile as NamedNode) } catch (err) { @@ -287,6 +297,14 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { await store.fetcher.load(preferencesFile as NamedNode) } catch (err) { // Maybe a permission problem or origin problem const msg = `Unable to load preference of user ${user}: ${err}` + if (err.response?.status === 404) { + // Self-heal when a stale profile pointer references a missing preferences file. + await ensureOwnerOnlyAclForSettings(user, preferencesFile as NamedNode) + await ensurePreferencesDocExists(preferencesFile as NamedNode) + await initializePreferencesDefaults(user, preferencesFile as NamedNode) + await store.fetcher.load(preferencesFile as NamedNode) + return preferencesFile as NamedNode + } debug.warn(msg) if (err.response.status === 401) { throw new UnauthorizedError() @@ -302,7 +320,18 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { }*/ throw new Error(msg) } + cachedPreferencesFileByWebId.set(user.uri, preferencesFile as NamedNode) return preferencesFile as NamedNode + })() + + loadPreferencesInFlight.set(user.uri, run) + try { + return await run + } finally { + if (loadPreferencesInFlight.get(user.uri) === run) { + loadPreferencesInFlight.delete(user.uri) + } + } } async function loadProfile (user: NamedNode):Promise { From a23095a39f4a5e6f3ba78f63f43d25ba671286e1 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sun, 29 Mar 2026 18:12:36 +1100 Subject: [PATCH 26/30] Update src/util/utilityLogic.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/util/utilityLogic.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/util/utilityLogic.ts b/src/util/utilityLogic.ts index d08265f..252ef03 100644 --- a/src/util/utilityLogic.ts +++ b/src/util/utilityLogic.ts @@ -150,7 +150,7 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { } catch (err) { const msg = `followOrCreateLinkWithContentOnCreate: Error making link in ${doc} to ${object}: ${err}` debug.warn(msg) - throw new WebOperationError(err) + throw new WebOperationError(msg) } try { From 07d51fc98f549879bb7dacddd88aee23f62083be Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sun, 29 Mar 2026 18:17:24 +1100 Subject: [PATCH 27/30] logging delete errors --- src/util/utilityLogic.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/util/utilityLogic.ts b/src/util/utilityLogic.ts index d08265f..17dd8d0 100644 --- a/src/util/utilityLogic.ts +++ b/src/util/utilityLogic.ts @@ -20,7 +20,8 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { await Promise.all(containerMembers.map((url) => recursiveDelete(url))) } return store.fetcher._fetch(containerNode.value, { method: 'DELETE' }) - } catch (_e) { + } catch (e) { + debug.log(`Please manually remove ${containerNode.value} from your system.`, e) } } From ae93026c7256ec411a98dac9bee01fcf1edd6bd9 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sun, 29 Mar 2026 18:23:58 +1100 Subject: [PATCH 28/30] refactor pref doc --- src/profile/profileDocuments.ts | 9 +++++++++ src/profile/profileLogic.ts | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 src/profile/profileDocuments.ts diff --git a/src/profile/profileDocuments.ts b/src/profile/profileDocuments.ts new file mode 100644 index 0000000..e2f3fb6 --- /dev/null +++ b/src/profile/profileDocuments.ts @@ -0,0 +1,9 @@ +export function preferencesFileDocument(): string { + return [ + '@prefix dct: .', + '@prefix pim: .', + '<>', + ' a pim:ConfigurationFile ;', + ' dct:title "Preferences file".' + ].join('\n') +} diff --git a/src/profile/profileLogic.ts b/src/profile/profileLogic.ts index d4a4aa3..0060fcc 100644 --- a/src/profile/profileLogic.ts +++ b/src/profile/profileLogic.ts @@ -4,6 +4,7 @@ import { CrossOriginForbiddenError, FetchError, NotEditableError, SameOriginForb import * as debug from '../util/debug' import { ns as namespace } from '../util/ns' import { privateTypeIndexDocument, publicTypeIndexDocument } from '../typeIndex/typeIndexDocuments' +import { preferencesFileDocument } from './profileDocuments' import { createContainerLogic } from '../util/containerLogic' import { differentOrigin, suggestPreferencesFile } from '../util/utils' import { ProfileLogic } from '../types' @@ -214,7 +215,7 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { async function ensurePreferencesDocExists(preferencesFile: NamedNode): Promise { try { - const created = await utilityLogic.loadOrCreateWithContentOnCreate(preferencesFile, '') + const created = await utilityLogic.loadOrCreateWithContentOnCreate(preferencesFile, preferencesFileDocument()) if (created) { return true } From d798d052302a3c6cf52d1ce7b3bd27d6ec1e33a3 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sun, 29 Mar 2026 20:43:45 +1100 Subject: [PATCH 29/30] refactor acl doc --- src/profile/profileAclDocuments.ts | 12 +++++++ src/profile/profileLogic.ts | 50 +++----------------------- src/typeIndex/typeIndexAclDocuments.ts | 30 ++++++++++++++++ 3 files changed, 46 insertions(+), 46 deletions(-) create mode 100644 src/profile/profileAclDocuments.ts create mode 100644 src/typeIndex/typeIndexAclDocuments.ts diff --git a/src/profile/profileAclDocuments.ts b/src/profile/profileAclDocuments.ts new file mode 100644 index 0000000..0b23b99 --- /dev/null +++ b/src/profile/profileAclDocuments.ts @@ -0,0 +1,12 @@ +export function ownerOnlyContainerAclDocument(webId: string): string { + return [ + '@prefix acl: .', + '', + '<#owner>', + 'a acl:Authorization;', + `acl:agent <${webId}>;`, + 'acl:accessTo <./>;', + 'acl:default <./>;', + 'acl:mode acl:Read, acl:Write, acl:Control.' + ].join('\n') +} diff --git a/src/profile/profileLogic.ts b/src/profile/profileLogic.ts index 0060fcc..009dfda 100644 --- a/src/profile/profileLogic.ts +++ b/src/profile/profileLogic.ts @@ -4,7 +4,9 @@ import { CrossOriginForbiddenError, FetchError, NotEditableError, SameOriginForb import * as debug from '../util/debug' import { ns as namespace } from '../util/ns' import { privateTypeIndexDocument, publicTypeIndexDocument } from '../typeIndex/typeIndexDocuments' +import { publicTypeIndexAclDocument } from '../typeIndex/typeIndexAclDocuments' import { preferencesFileDocument } from './profileDocuments' +import { ownerOnlyContainerAclDocument } from './profileAclDocuments' import { createContainerLogic } from '../util/containerLogic' import { differentOrigin, suggestPreferencesFile } from '../util/utils' import { ProfileLogic } from '../types' @@ -43,50 +45,6 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { return text.includes('404') || text.includes('Not Found') } - function publicTypeIndexAcl(webId: string, publicTypeIndex: NamedNode): string { - const fileName = new URL(publicTypeIndex.uri).pathname.split('/').pop() || 'publicTypeIndex.ttl' - return [ - '# ACL resource for the Public Type Index', - '', - '@prefix acl: .', - '@prefix foaf: .', - '', - '<#owner>', - ' a acl:Authorization;', - '', - ' acl:agent', - ` <${webId}>;`, - '', - ` acl:accessTo <./${fileName}>;`, - '', - ' acl:mode', - ' acl:Read, acl:Write, acl:Control.', - '', - '# Public-readable', - '<#public>', - ' a acl:Authorization;', - '', - ' acl:agentClass foaf:Agent;', - '', - ` acl:accessTo <./${fileName}>;`, - '', - ' acl:mode acl:Read.' - ].join('\n') - } - - function ownerOnlyContainerAcl(webId: string): string { - return [ - '@prefix acl: .', - '', - '<#owner>', - 'a acl:Authorization;', - `acl:agent <${webId}>;`, - 'acl:accessTo <./>;', - 'acl:default <./>;', - 'acl:mode acl:Read, acl:Write, acl:Control.' - ].join('\n') - } - async function ensureContainerExists(containerUri: string): Promise { const containerNode = sym(containerUri) try { @@ -124,7 +82,7 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { } await store.fetcher.webOperation('PUT', aclDoc.uri, { - data: ownerOnlyContainerAcl(user.uri), + data: ownerOnlyContainerAclDocument(user.uri), contentType: 'text/turtle' }) } @@ -147,7 +105,7 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic { const aclDoc = sym(aclDocUri) try { await store.fetcher.webOperation('PUT', aclDoc.uri, { - data: publicTypeIndexAcl(user.uri, publicTypeIndex), + data: publicTypeIndexAclDocument(user.uri, publicTypeIndex.uri), contentType: 'text/turtle', headers: { 'If-None-Match': '*' } }) diff --git a/src/typeIndex/typeIndexAclDocuments.ts b/src/typeIndex/typeIndexAclDocuments.ts new file mode 100644 index 0000000..9ab7ea9 --- /dev/null +++ b/src/typeIndex/typeIndexAclDocuments.ts @@ -0,0 +1,30 @@ +export function publicTypeIndexAclDocument(webId: string, publicTypeIndexUri: string): string { + const fileName = new URL(publicTypeIndexUri).pathname.split('/').pop() || 'publicTypeIndex.ttl' + return [ + '# ACL resource for the Public Type Index', + '', + '@prefix acl: .', + '@prefix foaf: .', + '', + '<#owner>', + ' a acl:Authorization;', + '', + ' acl:agent', + ` <${webId}>;`, + '', + ` acl:accessTo <./${fileName}>;`, + '', + ' acl:mode', + ' acl:Read, acl:Write, acl:Control.', + '', + '# Public-readable', + '<#public>', + ' a acl:Authorization;', + '', + ' acl:agentClass foaf:Agent;', + '', + ` acl:accessTo <./${fileName}>;`, + '', + ' acl:mode acl:Read.' + ].join('\n') +} From 809edc2ad0b90efef69fbf00137fba8fc2ead5dc Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sun, 29 Mar 2026 20:50:48 +1100 Subject: [PATCH 30/30] add prefix for terms --- src/profile/profileDocuments.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/profile/profileDocuments.ts b/src/profile/profileDocuments.ts index e2f3fb6..dec0524 100644 --- a/src/profile/profileDocuments.ts +++ b/src/profile/profileDocuments.ts @@ -2,6 +2,7 @@ export function preferencesFileDocument(): string { return [ '@prefix dct: .', '@prefix pim: .', + '@prefix solid: .', '<>', ' a pim:ConfigurationFile ;', ' dct:title "Preferences file".'