Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
7c794a4
createa populated pref file
SharonStrats Mar 26, 2026
c33a037
acl logic for Settings container
SharonStrats Mar 27, 2026
f190a72
add acl tests
SharonStrats Mar 27, 2026
e155bb3
publictypeindex acl
SharonStrats Mar 27, 2026
224bf77
profile test
SharonStrats Mar 27, 2026
be1cffc
fix type index test
SharonStrats Mar 27, 2026
86c551b
Apply suggestions from code review
SharonStrats Mar 27, 2026
5a148a6
fixed test
SharonStrats Mar 27, 2026
8a01cb3
fixed another test issue
SharonStrats Mar 27, 2026
43b58af
tests
SharonStrats Mar 27, 2026
1d8dee3
make sure publictype is added to webid as well
SharonStrats Mar 27, 2026
1473356
create privite index with data
SharonStrats Mar 28, 2026
aa37662
still call loadorcreate
SharonStrats Mar 28, 2026
baa13a7
change Preferences.ttl to prefs.ttl to match server creation
SharonStrats Mar 28, 2026
2bef435
clean up tests
SharonStrats Mar 28, 2026
0f5801c
refactor type index doc content
SharonStrats Mar 28, 2026
d1bb6d9
Update test/typeIndexLogic.test.ts
SharonStrats Mar 28, 2026
8a912a7
Update src/util/utilityLogic.ts
SharonStrats Mar 28, 2026
78cccd1
Update src/profile/profileLogic.ts
SharonStrats Mar 28, 2026
94483d2
make sure unorphaned index
SharonStrats Mar 28, 2026
0fc3f84
use createContainer
SharonStrats Mar 28, 2026
0e956f4
Update src/typeIndex/typeIndexLogic.ts
SharonStrats Mar 28, 2026
c20ca2b
prevent race conditions when creating a container
SharonStrats Mar 29, 2026
1e8752f
idempotent container creation
SharonStrats Mar 29, 2026
fea4638
public index plus race cond fix
SharonStrats Mar 29, 2026
a23095a
Update src/util/utilityLogic.ts
SharonStrats Mar 29, 2026
07d51fc
logging delete errors
SharonStrats Mar 29, 2026
0f43216
Merge branch 'feature1' of https://github.com/solidos/solid-logic int…
SharonStrats Mar 29, 2026
ae93026
refactor pref doc
SharonStrats Mar 29, 2026
d798d05
refactor acl doc
SharonStrats Mar 29, 2026
809edc2
add prefix for terms
SharonStrats Mar 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/profile/profileAclDocuments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export function ownerOnlyContainerAclDocument(webId: string): string {
return [
'@prefix acl: <http://www.w3.org/ns/auth/acl#>.',
'',
'<#owner>',
'a acl:Authorization;',
`acl:agent <${webId}>;`,
'acl:accessTo <./>;',
'acl:default <./>;',
'acl:mode acl:Read, acl:Write, acl:Control.'
].join('\n')
}
10 changes: 10 additions & 0 deletions src/profile/profileDocuments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export function preferencesFileDocument(): string {
return [
'@prefix dct: <http://purl.org/dc/terms/>.',
'@prefix pim: <http://www.w3.org/ns/pim/space#>.',
'@prefix solid: <http://www.w3.org/ns/solid/terms#>.',
'<>',
' a pim:ConfigurationFile ;',
' dct:title "Preferences file".'
].join('\n')
}
227 changes: 225 additions & 2 deletions src/profile/profileLogic.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,196 @@
import { NamedNode } from 'rdflib'
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'
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'

export function createProfileLogic(store, authn, utilityLogic): ProfileLogic {
const ns = namespace
const containerLogic = createContainerLogic(store)
const loadPreferencesInFlight = new Map<string, Promise<NamedNode>>()
const cachedPreferencesFileByWebId = new Map<string, NamedNode>()

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)
}

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 ensureContainerExists(containerUri: string): Promise<void> {
const containerNode = sym(containerUri)
try {
await store.fetcher.load(containerNode)
return
} catch (err) {
if (!isNotFoundError(err)) throw err
}
await containerLogic.createContainer(containerUri)
}

async function ensureOwnerOnlyAclForSettings(user: NamedNode, preferencesFile: NamedNode): Promise<void> {
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: ownerOnlyContainerAclDocument(user.uri),
contentType: 'text/turtle'
})
}

async function ensurePublicTypeIndexAclOnCreate(user: NamedNode, publicTypeIndex: NamedNode, ensureAcl = false): Promise<void> {
const created = await utilityLogic.loadOrCreateWithContentOnCreate(publicTypeIndex, publicTypeIndexDocument())
if (!created && !ensureAcl) 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.webOperation('PUT', aclDoc.uri, {
data: publicTypeIndexAclDocument(user.uri, publicTypeIndex.uri),
contentType: 'text/turtle',
headers: { 'If-None-Match': '*' }
})
} catch (err: any) {
const status = err?.response?.status ?? err?.status
if (status !== 412) throw err
}
}

async function ensurePrivateTypeIndexOnCreate(privateTypeIndex: NamedNode): Promise<void> {
await utilityLogic.loadOrCreateWithContentOnCreate(privateTypeIndex, privateTypeIndexDocument())
}

async function initializePreferencesDefaults(user: NamedNode, preferencesFile: NamedNode): Promise<void> {
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 =
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.followOrCreateLinkWithContentOnCreate(
user,
ns.solid('publicTypeIndex') as NamedNode,
publicTypeIndex,
profileDoc,
publicTypeIndexDocument()
)
}

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 ensurePublicTypeIndexAclOnCreate(user, publicTypeIndex, createdProfilePublicTypeIndexLink)
await ensurePrivateTypeIndexOnCreate(privateTypeIndex)
}

async function ensurePreferencesDocExists(preferencesFile: NamedNode): Promise<boolean> {
try {
const created = await utilityLogic.loadOrCreateWithContentOnCreate(preferencesFile, preferencesFileDocument())
if (created) {
return true
}
return false
} catch (err) {
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.
Expand All @@ -29,12 +213,32 @@ 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 <NamedNode> {
const cachedPreferencesFile = cachedPreferencesFileByWebId.get(user.uri)
if (cachedPreferencesFile) {
return cachedPreferencesFile
}

const inFlight = loadPreferencesInFlight.get(user.uri)
if (inFlight) {
return inFlight
}

const run = (async (): Promise<NamedNode> => {
await loadProfile(user)

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())
}

await ensureOwnerOnlyAclForSettings(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)
Expand All @@ -52,6 +256,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()
Expand All @@ -67,7 +279,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 <NamedNode> {
Expand Down
30 changes: 30 additions & 0 deletions src/typeIndex/typeIndexAclDocuments.ts
Original file line number Diff line number Diff line change
@@ -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: <http://www.w3.org/ns/auth/acl#>.',
'@prefix foaf: <http://xmlns.com/foaf/0.1/>.',
'',
'<#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')
}
17 changes: 17 additions & 0 deletions src/typeIndex/typeIndexDocuments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export function publicTypeIndexDocument(): string {
return [
'@prefix solid: <http://www.w3.org/ns/solid/terms#>.',
'<>',
' a solid:TypeIndex ;',
' a solid:ListedDocument.'
].join('\n')
}

export function privateTypeIndexDocument(): string {
return [
'@prefix solid: <http://www.w3.org/ns/solid/terms#>.',
'<>',
' a solid:TypeIndex ;',
' a solid:UnlistedDocument.'
].join('\n')
}
Loading
Loading