diff --git a/src/acl/accessControlSubjects.ts b/src/acl/accessControlSubjects.ts new file mode 100644 index 0000000..c326aeb --- /dev/null +++ b/src/acl/accessControlSubjects.ts @@ -0,0 +1,87 @@ +import { LiveStore, sym } from 'rdflib' +import { ns } from '../util/ns' + +export type AccessControlSubjectKind = 'agent' | 'agentGroup' | 'agentClass' | 'origin' + +export type AccessControlSubject = { + kind: AccessControlSubjectKind + subjectValue: string +} + +const ACCESS_CONTROL_SUBJECT_GROUPS = { + agentClass: [ns.foaf('Agent'), ns.acl('AuthenticatedAgent'), ns.rdf('Resource'), ns.owl('Thing')], + agent: [ns.vcard('WebID'), ns.vcard('Individual'), ns.foaf('Person'), ns.foaf('Agent')], + group: [ns.vcard('Group')], + origin: [ns.solid('AppProvider'), ns.solid('AppProviderClass')] +} as const + +function hasKnownTypes (types: Record): boolean { + return Object.keys(types).length > 0 +} + +function isHttpUrl (value: string): boolean { + return value.startsWith('http://') || value.startsWith('https://') +} + +function isBareOriginUrl (value: string): boolean { + try { + const parsed = new URL(value) + return parsed.pathname === '/' && !parsed.hash + } catch (_error) { + return false + } +} + +function normalizeOriginUrl (value: string): string { + try { + const parsed = new URL(value) + if (parsed.pathname === '/' && !parsed.hash) { + return `${parsed.protocol}//${parsed.host}` + } + } catch (_error) { + // Return the original value below when parsing fails. + } + + return value +} + +export async function classifyAccessControlSubject (store: LiveStore, principle: string): Promise { + const subject = sym(principle) + let types = store.findTypeURIs(subject) + + if (isBareOriginUrl(principle)) { + return { kind: 'origin', subjectValue: normalizeOriginUrl(principle) } + } + + if (!hasKnownTypes(types) && isHttpUrl(principle)) { + try { + await store.fetcher.load(subject.doc()) + } catch (error) { + console.error(`Failed to load access target ${principle}`, error) + } + + types = store.findTypeURIs(subject) + } + + if (ACCESS_CONTROL_SUBJECT_GROUPS.origin.some(term => term.uri in types)) { + return { kind: 'origin', subjectValue: principle } + } + + if (ACCESS_CONTROL_SUBJECT_GROUPS.agent.some(term => term.uri in types)) { + const preferredURI = store.any(subject, ns.foaf('preferredURI')) + return { + kind: 'agent', + subjectValue: preferredURI?.value ?? principle + } + } + + if (ACCESS_CONTROL_SUBJECT_GROUPS.group.some(term => term.uri in types)) { + return { kind: 'agentGroup', subjectValue: principle } + } + + if (ACCESS_CONTROL_SUBJECT_GROUPS.agentClass.some(term => subject.sameTerm(term))) { + return { kind: 'agentClass', subjectValue: principle } + } + + return undefined +} diff --git a/src/acl/aclLogic.ts b/src/acl/aclLogic.ts index 9d69419..90b7390 100644 --- a/src/acl/aclLogic.ts +++ b/src/acl/aclLogic.ts @@ -2,6 +2,7 @@ import { applyPlan as applyAuthorizationPlan, findEffectiveACL, planGrant as pla import { graph, NamedNode, Namespace, serialize, sym } from 'rdflib' import type { AclLogic } from '../types' import { ns as namespace } from '../util/ns' +import * as accessControlSubjects from './accessControlSubjects' // Helpers available from @dokieli/web-access-control: // - Discovery: findEffectiveACL, parentContainer @@ -70,6 +71,10 @@ export function createAclLogic(store): AclLogic { async function applyPlan(plan: PatchPlan): Promise { return applyAuthorizationPlan(plan, { fetch: getFetch() }) } + + async function classifyAccessControlSubject(principle: string) { + return accessControlSubjects.classifyAccessControlSubject(store, principle) + } /** * Simple Access Control * @@ -206,6 +211,7 @@ export function createAclLogic(store): AclLogic { planRevoke, planPublicRead, applyPlan, + classifyAccessControlSubject, setACLUserPublic, genACLText } diff --git a/src/directory/catalogDirectory.ts b/src/directory/catalogDirectory.ts new file mode 100644 index 0000000..058d070 --- /dev/null +++ b/src/directory/catalogDirectory.ts @@ -0,0 +1,89 @@ +import { type DirectoryEntry } from '../types' +import { graph, type LiveStore, NamedNode, parse } from 'rdflib' +import { ns } from '../util/ns' +import * as debug from '../util/debug' +import { isHttpUri } from './directoryHelpers' + +const CATALOG_VOCAB = 'http://example.org#' + +export function createCatalogDirectoryDiscovery () { + const catalogEntriesCache = new Map>() + + async function discoverCatalogEntries ( + catalogUrl: string, + onEntry: (entry: DirectoryEntry) => void | Promise + ): Promise { + const entries = await fetchCatalogEntries(catalogUrl) + for (const entry of entries) { + await onEntry(entry) + } + } + + async function fetchCatalogEntries (catalogUrl: string): Promise { + const cached = catalogEntriesCache.get(catalogUrl) + if (cached) { + return cached + } + + const promise = (async () => { + if (typeof fetch !== 'function') { + return [] + } + + try { + const response = await fetch(catalogUrl, { + headers: { accept: 'text/turtle' } + }) + + if (!response.ok) { + debug.warn(`[Directory] Failed to fetch ${catalogUrl}: ${response.status}`) + return [] + } + + const turtle = await response.text() + const catalogStore = graph() as LiveStore + parse(turtle, catalogStore, catalogUrl, 'text/turtle') + + const personType = new NamedNode(`${CATALOG_VOCAB}Person`) + const webIdPredicate = new NamedNode(`${CATALOG_VOCAB}webid`) + const namePredicate = new NamedNode(`${CATALOG_VOCAB}name`) + const catalogPeople = new Map() + + const statements = catalogStore.statementsMatching(undefined, ns.rdf('type'), personType) + for (const statement of statements) { + const subject = statement.subject + const webIdNode = catalogStore.any(subject, webIdPredicate) + if (webIdNode && webIdNode.termType === 'NamedNode') { + const webId = webIdNode.value + const personName = catalogStore.anyValue(subject, namePredicate) + if (isHttpUri(webId) && personName) { + catalogPeople.set(webId, { + kind: 'person', + uri: webId, + label: personName, + subjectType: 'agent', + relationshipLabel: 'People', + sources: ['catalog'] + }) + } + } + } + + return Array.from(catalogPeople.values()) + } catch (error) { + debug.warn('[Directory] Error fetching directory catalog:', error) + return [] + } + })().catch(error => { + catalogEntriesCache.delete(catalogUrl) + throw error + }) + + catalogEntriesCache.set(catalogUrl, promise) + return promise + } + + return { + discoverCatalogEntries + } +} diff --git a/src/directory/contactsDirectory.ts b/src/directory/contactsDirectory.ts new file mode 100644 index 0000000..c101c92 --- /dev/null +++ b/src/directory/contactsDirectory.ts @@ -0,0 +1,148 @@ +import { type DirectoryEntry, type DirectorySource, type TypeIndexLogic } from '../types' +import { type LiveStore, NamedNode } from 'rdflib' +import { ns } from '../util/ns' +import { isHttpUri, labelForNode, loadDocumentSilently, uniqueNamedNodes } from './directoryHelpers' + +const CONTACT_CARD_CONCURRENCY = 8 + +export function createContactsDirectoryDiscovery ( + store: LiveStore, + typeIndexLogic: TypeIndexLogic +) { + const personalAddressBooksCache = new Map>() + const contactWebIdCache = new Map>() + + async function loadPersonalAddressBooks (user: NamedNode): Promise { + const cached = personalAddressBooksCache.get(user.value) + if (cached) { + return cached + } + + const promise = (async () => { + const scopes = await typeIndexLogic.loadTypeIndexesFor(user) + let scopedApps = [] as Awaited> + + for (const scope of scopes) { + const apps = await typeIndexLogic.getScopedAppsFromIndex(scope, ns.vcard('AddressBook')) + scopedApps = scopedApps.concat(apps) + } + + return uniqueNamedNodes(scopedApps.map(app => app.instance)) + })().catch(error => { + personalAddressBooksCache.delete(user.value) + throw error + }) + + personalAddressBooksCache.set(user.value, promise) + return promise + } + + async function webIdForAddressBookContact (contact: NamedNode): Promise { + const cached = contactWebIdCache.get(contact.value) + if (cached) { + return cached + } + + const promise = (async () => { + if (!await loadDocumentSilently(store, contact)) { + return null + } + + const urlNodes = store.each(contact, ns.vcard('url'), null, contact.doc()) as NamedNode[] + for (const urlNode of urlNodes) { + const valueNode = store.any(urlNode, ns.vcard('value'), null, contact.doc()) + const webId = valueNode?.value || '' + if (isHttpUri(webId)) { + return webId + } + } + + return null + })().catch(error => { + contactWebIdCache.delete(contact.value) + throw error + }) + + contactWebIdCache.set(contact.value, promise) + return promise + } + + async function discoverAddressBookContacts ( + user: NamedNode, + onEntry: (entry: DirectoryEntry) => void | Promise + ): Promise { + const books = await loadPersonalAddressBooks(user) + + for (const book of books) { + if (await loadDocumentSilently(store, book)) { + const nameEmailIndex = store.any(book, ns.vcard('nameEmailIndex'), null, book.doc()) as NamedNode | null + if (nameEmailIndex) { + if (await loadDocumentSilently(store, nameEmailIndex)) { + const contacts = store.each(undefined, ns.vcard('inAddressBook'), book, nameEmailIndex) as NamedNode[] + for (let index = 0; index < contacts.length; index += CONTACT_CARD_CONCURRENCY) { + const batch = contacts.slice(index, index + CONTACT_CARD_CONCURRENCY) + const entries = await Promise.all(batch.map(async contact => { + const webId = await webIdForAddressBookContact(contact) + if (!webId) { + return null + } + + return { + kind: 'person' as const, + uri: webId, + label: store.anyValue(contact, ns.vcard('fn'), null, nameEmailIndex) || labelForNode(store, contact), + subjectType: 'agent' as const, + relationshipLabel: 'Contact' as const, + sources: ['contacts'] as DirectorySource[] + } + })) + + for (const entry of entries.filter((entry): entry is NonNullable => Boolean(entry))) { + await onEntry(entry) + } + } + } + } + } + } + } + + async function discoverAddressBookGroups ( + user: NamedNode, + onEntry: (entry: DirectoryEntry) => void | Promise + ): Promise { + const books = await loadPersonalAddressBooks(user) + + for (const book of books) { + if (await loadDocumentSilently(store, book)) { + const groupIndex = store.any(book, ns.vcard('groupIndex'), null, book.doc()) as NamedNode | null + if (groupIndex) { + if (await loadDocumentSilently(store, groupIndex)) { + const groups = uniqueNamedNodes([ + ...(store.each(book, ns.vcard('includesGroup'), null, groupIndex) as NamedNode[]), + ...(store.each(book, ns.vcard('includesGroup'), null, book.doc()) as NamedNode[]) + ]) + + for (const group of groups) { + await loadDocumentSilently(store, group) + + await onEntry({ + kind: 'group', + uri: group.value, + label: labelForNode(store, group), + subjectType: 'agentGroup', + relationshipLabel: 'Group', + sources: ['groups'] + }) + } + } + } + } + } + } + + return { + discoverAddressBookContacts, + discoverAddressBookGroups + } +} diff --git a/src/directory/directoryHelpers.ts b/src/directory/directoryHelpers.ts new file mode 100644 index 0000000..e632f39 --- /dev/null +++ b/src/directory/directoryHelpers.ts @@ -0,0 +1,109 @@ +import type { LiveStore, NamedNode } from 'rdflib' +import type { DirectoryEntry, DirectoryRelationshipLabel } from '../types' +import { ns } from '../util/ns' + +export function tokenizeQuery (query: string): string[] { + return query + .toLowerCase() + .trim() + .split(/\s+/) + .filter(Boolean) +} + +export function matchesQuery (label: string, query: string): boolean { + const queryWords = tokenizeQuery(query) + if (queryWords.length === 0) return true + + const labelWords = tokenizeQuery(label) + const fallbackHaystack = label.toLowerCase() + return queryWords.every(word => + labelWords.some(labelWord => labelWord.includes(word)) || fallbackHaystack.includes(word) + ) +} + +export function sortEntries (entries: Iterable): DirectoryEntry[] { + return Array.from(entries) + .sort((left, right) => left.label.localeCompare(right.label, undefined, { sensitivity: 'base' })) +} + +export function mergeDirectoryEntry ( + discoveredEntries: Map, + entry: DirectoryEntry +): DirectoryEntry { + const key = directoryEntryKey(entry) + const existing = discoveredEntries.get(key) + if (existing) { + const merged = { + ...existing, + label: existing.label || entry.label, + relationshipLabel: bestRelationshipLabel(existing.relationshipLabel, entry.relationshipLabel), + sources: uniqueStrings([...existing.sources, ...entry.sources]) + } + discoveredEntries.set(key, merged) + return merged + } + + discoveredEntries.set(key, entry) + return entry +} + +export function bestRelationshipLabel ( + current: DirectoryRelationshipLabel | undefined, + incoming: DirectoryRelationshipLabel +): DirectoryRelationshipLabel { + if (current === 'Contact' || incoming === 'Contact') return 'Contact' + if (current === 'Friend' || incoming === 'Friend') return 'Friend' + if (current === 'Group' || incoming === 'Group') return 'Group' + return 'People' +} + +export function directoryEntryKey (entry: DirectoryEntry): string { + return `${entry.kind}:${entry.uri}` +} + +export function uniqueNamedNodes (nodes: NamedNode[]): NamedNode[] { + const seen = new Set() + return nodes.filter(node => { + if (seen.has(node.value)) return false + seen.add(node.value) + return true + }) +} + +export function uniqueStrings (values: T[]): T[] { + return [...new Set(values)] +} + +export function isHttpUri (value: string | null | undefined): boolean { + return !!value && (value.startsWith('https://') || value.startsWith('http://')) +} + +export function fallbackLabel (uri: string): string { + const withoutFragment = uri.split('#')[0] + const fragment = uri.includes('#') ? uri.slice(uri.indexOf('#') + 1) : '' + const base = fragment && fragment !== 'this' && fragment !== 'me' + ? fragment + : withoutFragment.slice(withoutFragment.lastIndexOf('/') + 1) + return decodeURIComponent(base || uri).replace(/[_-]+/g, ' ') +} + +export function storedLabelForNode (store: LiveStore, node: NamedNode): string | null { + return store.anyValue(node, ns.vcard('fn')) || + store.anyValue(node, ns.foaf('name')) || + store.anyValue(node, ns.schema('name')) || + store.anyValue(node, ns.rdfs('label')) || + null +} + +export function labelForNode (store: LiveStore, node: NamedNode): string { + return storedLabelForNode(store, node) || fallbackLabel(node.value) +} + +export async function loadDocumentSilently (store: LiveStore, node: NamedNode): Promise { + try { + await store.fetcher.load(node.doc()) + return true + } catch (_error) { + return false + } +} diff --git a/src/directory/directoryLogic.ts b/src/directory/directoryLogic.ts new file mode 100644 index 0000000..49ffb4f --- /dev/null +++ b/src/directory/directoryLogic.ts @@ -0,0 +1,82 @@ +import { type AuthnLogic, type DirectoryEntry, type DirectoryLogic, type DirectorySearchOptions, type DirectorySource, type TypeIndexLogic } from '../types' +import { type LiveStore } from 'rdflib' +import { createContactsDirectoryDiscovery } from './contactsDirectory' +import { createFriendsDirectoryDiscovery } from './friendsDirectory' +import { createCatalogDirectoryDiscovery } from './catalogDirectory' +import { matchesQuery, mergeDirectoryEntry, sortEntries } from './directoryHelpers' + +const DEFAULT_FOAF_DISTANCE = 3 + +export const DEFAULT_DIRECTORY_CATALOG_URL = 'https://raw.githubusercontent.com/solid/catalog/refs/heads/main/catalog-data.ttl' +export const DEFAULT_DIRECTORY_SOURCES: DirectorySource[] = ['contacts', 'friends', 'catalog', 'groups'] + +export function createDirectoryLogic ( + store: LiveStore, + authn: AuthnLogic, + typeIndexLogic: TypeIndexLogic +): DirectoryLogic { + const contactsDiscovery = createContactsDirectoryDiscovery(store, typeIndexLogic) + const friendsDiscovery = createFriendsDirectoryDiscovery(store) + const catalogDiscovery = createCatalogDirectoryDiscovery() + + async function discoverWithNormalizedOptions ( + normalized: ReturnType + ): Promise { + const discoveredEntries = new Map() + const discoveryTasks: Promise[] = [] + + const mergeEntry = (entry: DirectoryEntry): void => { + mergeDirectoryEntry(discoveredEntries, entry) + } + + if (normalized.sources.includes('catalog')) { + discoveryTasks.push(catalogDiscovery.discoverCatalogEntries(normalized.catalogUrl, mergeEntry)) + } + + if (normalized.user) { + if (normalized.sources.includes('contacts')) { + discoveryTasks.push(contactsDiscovery.discoverAddressBookContacts(normalized.user, mergeEntry)) + } + + if (normalized.sources.includes('groups')) { + discoveryTasks.push(contactsDiscovery.discoverAddressBookGroups(normalized.user, mergeEntry)) + } + + if (normalized.sources.includes('friends')) { + discoveryTasks.push(friendsDiscovery.discoverFoafPeople(normalized.user, normalized.maxFoafDistance, mergeEntry)) + } + } + + if (!discoveryTasks.length) { + return [] + } + + const results = await Promise.allSettled(discoveryTasks) + if (results.every(result => result.status === 'rejected')) { + throw new Error('Unable to load directory entries.') + } + + return sortEntries(discoveredEntries.values()) + } + + async function search (options: DirectorySearchOptions = {}): Promise { + const normalized = normalizeOptions(options) + const entries = await discoverWithNormalizedOptions(normalized) + return entries.filter(entry => matchesQuery(entry.label, normalized.query)) + } + + return { + search + } + + function normalizeOptions (options: DirectorySearchOptions = {}) { + const sources = options.sources?.length ? [...options.sources] : [...DEFAULT_DIRECTORY_SOURCES] + return { + query: options.query ?? '', + sources, + catalogUrl: options.catalogUrl ?? DEFAULT_DIRECTORY_CATALOG_URL, + user: options.user ?? authn.currentUser(), + maxFoafDistance: options.maxFoafDistance ?? DEFAULT_FOAF_DISTANCE + } + } +} diff --git a/src/directory/friendsDirectory.ts b/src/directory/friendsDirectory.ts new file mode 100644 index 0000000..b5115b4 --- /dev/null +++ b/src/directory/friendsDirectory.ts @@ -0,0 +1,103 @@ +import { type DirectoryEntry } from '../types' +import { type LiveStore, NamedNode } from 'rdflib' +import { ns } from '../util/ns' +import { loadDocumentSilently, storedLabelForNode } from './directoryHelpers' + +const PEOPLE_SEARCH_CONCURRENCY = 6 + +export function createFriendsDirectoryDiscovery (store: LiveStore) { + function relationshipLabelForDepth (depth: number) { + return depth === 1 ? 'Friend' : 'People' + } + + async function emitPersonEntry ( + person: NamedNode, + depth: number, + onEntry: (entry: DirectoryEntry) => void | Promise + ): Promise { + const personName = storedLabelForNode(store, person) + if (!personName) return + + await onEntry({ + kind: 'person', + uri: person.value, + label: personName, + subjectType: 'agent', + relationshipLabel: relationshipLabelForDepth(depth), + sources: ['friends'] + }) + } + + async function discoverFoafPeople ( + user: NamedNode, + maxFoafDistance: number, + onEntry: (entry: DirectoryEntry) => void | Promise + ): Promise { + const visited = new Set([user.value]) + const emitted = new Set() + const loadedDocs = new Set() + let queue: Array<{ person: NamedNode, depth: number }> = [{ person: user, depth: 0 }] + + const processPerson = async (currentEntry: { person: NamedNode, depth: number }) => { + const { person: current, depth } = currentEntry + const currentDoc = current.doc().value + if (!loadedDocs.has(currentDoc)) { + loadedDocs.add(currentDoc) + await loadDocumentSilently(store, current) + } + + if (current.value !== user.value) { + if (!emitted.has(current.value)) { + emitted.add(current.value) + await emitPersonEntry(current, depth, onEntry) + } + } + + if (depth >= maxFoafDistance) { + return [] as Array<{ person: NamedNode, depth: number }> + } + + const nextPeople: Array<{ person: NamedNode, depth: number }> = [] + const contacts = store.each(current, ns.foaf('knows')) as NamedNode[] + for (const contact of contacts) { + if (contact.termType === 'NamedNode') { + const contactName = storedLabelForNode(store, contact) + if (contact.value !== user.value && contactName && !emitted.has(contact.value)) { + emitted.add(contact.value) + await onEntry({ + kind: 'person', + uri: contact.value, + label: contactName, + subjectType: 'agent', + relationshipLabel: depth === 0 ? 'Friend' : 'People', + sources: ['friends'] + }) + } + + if (!visited.has(contact.value)) { + visited.add(contact.value) + nextPeople.push({ person: contact, depth: depth + 1 }) + } + } + } + + return nextPeople + } + + while (queue.length > 0) { + const nextQueue: Array<{ person: NamedNode, depth: number }> = [] + + for (let index = 0; index < queue.length; index += PEOPLE_SEARCH_CONCURRENCY) { + const batch = queue.slice(index, index + PEOPLE_SEARCH_CONCURRENCY) + const nested = await Promise.all(batch.map(processPerson)) + nested.forEach(entries => nextQueue.push(...entries)) + } + + queue = nextQueue + } + } + + return { + discoverFoafPeople + } +} diff --git a/src/index.ts b/src/index.ts index c38a985..57e8c32 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,9 @@ export { performServerSideLogout } from './authn/serverLogout' export { reloadOnIdentityReplaced } from './authSession/identityState' export { getSuggestedIssuers } from './issuer/issuerLogic' export { createTypeIndexLogic } from './typeIndex/typeIndexLogic' -export type { AppDetails, SolidNamespace, AuthenticationContext, SolidLogic, ChatLogic } from './types' +export { createDirectoryLogic, DEFAULT_DIRECTORY_CATALOG_URL, DEFAULT_DIRECTORY_SOURCES } from './directory/directoryLogic' +export type { AccessControlSubjectKind, AccessControlSubject } from './acl/accessControlSubjects' +export type { AppDetails, SolidNamespace, AuthenticationContext, SolidLogic, ChatLogic, DirectoryLogic, DirectoryEntry, DirectorySearchOptions, DirectoryRelationshipLabel, DirectorySource } from './types' export { UnauthorizedError, CrossOriginForbiddenError, SameOriginForbiddenError, NotFoundError, FetchError, NotEditableError, WebOperationError } from './logic/CustomError' export { @@ -21,4 +23,3 @@ export { authn, authSession } - diff --git a/src/logic/solidLogic.ts b/src/logic/solidLogic.ts index fd45df5..f7110c4 100644 --- a/src/logic/solidLogic.ts +++ b/src/logic/solidLogic.ts @@ -9,6 +9,7 @@ import { createInboxLogic } from '../inbox/inboxLogic' import { createResourceLogic } from '../resource/resourceLogic' import { createProfileLogic } from '../profile/profileLogic' import { createTypeIndexLogic } from '../typeIndex/typeIndexLogic' +import { createDirectoryLogic } from '../directory/directoryLogic' import { createContainerLogic } from '../util/containerLogic' import { createUtilityLogic } from '../util/utilityLogic' import type { AuthnLogic, SolidLogic } from '../types' @@ -50,6 +51,7 @@ export function createSolidLogic(specialFetch: { fetch: (url: any, requestInit: const chat = createChatLogic(store, profile) const inbox = createInboxLogic(store, profile, utilityLogic, containerLogic, acl) const typeIndex = createTypeIndexLogic(store, authn, profile, utilityLogic) + const directory = createDirectoryLogic(store, authn, typeIndex) const resource = createResourceLogic(store, acl, containerLogic, typeIndex) debug.log('SolidAuthnLogic initialized') @@ -86,6 +88,7 @@ export function createSolidLogic(specialFetch: { fetch: (url: any, requestInit: chat, profile, typeIndex, + directory, load, updatePromise, clearStore diff --git a/src/types.ts b/src/types.ts index 4589307..1027710 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,7 @@ import type { SessionWithLegacyEvents } from './authSession/authSession' import type { ACLContext, AccessMode, AccessSubject, Authorization, PatchPlan } from '@dokieli/web-access-control' import { LiveStore, NamedNode, Statement } from 'rdflib' +import type { AccessControlSubject } from './acl/accessControlSubjects' export type AppDetails = { noun: string @@ -82,6 +83,7 @@ export interface AclLogic { findAclDocUrl: (url: NamedNode) => Promise, findEffectiveAcl: (resourceURL: string | NamedNode) => Promise, findAccessGrants: (resourceURL: string | NamedNode) => Promise, + classifyAccessControlSubject: (principle: string) => Promise, planGrant: (resourceURL: string | NamedNode, subject: AccessSubject, modes: AccessMode[]) => Promise, planRevoke: (resourceURL: string | NamedNode, subject: AccessSubject) => Promise, planPublicRead: (resourceURL: string | NamedNode, enabled: boolean) => Promise, @@ -160,6 +162,31 @@ export interface TypeIndexLogic { getScopedAppsFromIndex: (scope: TypeIndexScope, theClass: NamedNode | null) => Promise, } +export type DirectorySource = 'contacts' | 'friends' | 'catalog' | 'groups' + +export type DirectoryRelationshipLabel = 'Friend' | 'People' | 'Contact' | 'Group' + +export type DirectoryEntry = { + kind: 'person' | 'group' + uri: string + label: string + subjectType: 'agent' | 'agentGroup' + relationshipLabel: DirectoryRelationshipLabel + sources: DirectorySource[] +} + +export type DirectorySearchOptions = { + query?: string + sources?: DirectorySource[] + catalogUrl?: string + user?: NamedNode | null + maxFoafDistance?: number +} + +export interface DirectoryLogic { + search: (options?: DirectorySearchOptions) => Promise +} + export interface SolidLogic { store: LiveStore, authn: AuthnLogic, @@ -169,6 +196,7 @@ export interface SolidLogic { inbox: InboxLogic, typeIndex: TypeIndexLogic, chat: ChatLogic, + directory: DirectoryLogic, load: (doc: NamedNode | NamedNode[] | string) => void, updatePromise: (del: Array, ins: Array) => Promise, clearStore: () => void diff --git a/test/directoryLogic.test.ts b/test/directoryLogic.test.ts new file mode 100644 index 0000000..f2e61b5 --- /dev/null +++ b/test/directoryLogic.test.ts @@ -0,0 +1,231 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { Fetcher, Store, UpdateManager, sym } from 'rdflib' +import { createAclLogic } from '../src/acl/aclLogic' +import { createDirectoryLogic, DEFAULT_DIRECTORY_CATALOG_URL } from '../src/directory/directoryLogic' +import { createProfileLogic } from '../src/profile/profileLogic' +import { createTypeIndexLogic } from '../src/typeIndex/typeIndexLogic' +import { createContainerLogic } from '../src/util/containerLogic' +import { createUtilityLogic } from '../src/util/utilityLogic' +import type { DirectoryLogic } from '../src/types' + +const alice = sym('https://alice.example.com/profile/card.ttl#me') + +window.$SolidTestEnvironment = { username: alice.uri } + +describe('Directory logic', () => { + let directoryLogic: DirectoryLogic + let web: Record + + beforeEach(() => { + web = { + 'https://alice.example.com/profile/card.ttl': ` + @prefix foaf: . + @prefix solid: . + @prefix space: . + @prefix vcard: . + + <#me> + a vcard:Individual; + foaf:name "Alice"; + foaf:knows ; + space:preferencesFile ; + solid:publicTypeIndex . + `, + 'https://alice.example.com/settings/prefs.ttl': ` + @prefix solid: . + + solid:privateTypeIndex . + `, + 'https://alice.example.com/profile/publicTypeIndex.ttl': ` + @prefix solid: . + <> a solid:TypeIndex, solid:ListedDocument. + `, + 'https://alice.example.com/settings/privateTypeIndex.ttl': ` + @prefix solid: . + @prefix rdf: . + @prefix vcard: . + + <> a solid:TypeIndex, solid:UnlistedDocument. + + <#address-book> + rdf:type solid:TypeRegistration; + solid:forClass vcard:AddressBook; + solid:instance . + `, + 'https://alice.example.com/contacts/index.ttl': ` + @prefix dc: . + @prefix vcard: . + + <#this> + a vcard:AddressBook; + dc:title "Address Book"; + vcard:nameEmailIndex ; + vcard:groupIndex . + `, + 'https://alice.example.com/contacts/people.ttl': ` + @prefix vcard: . + + + vcard:inAddressBook ; + vcard:fn "Bobby Contact". + `, + 'https://alice.example.com/contacts/Person/bob/index.ttl': ` + @prefix rdf: . + @prefix vcard: . + + <#this> + a vcard:Individual; + vcard:fn "Bobby Contact"; + vcard:url [ + rdf:type vcard:WebID; + vcard:value "https://bob.example.com/profile/card.ttl#me" + ]. + `, + 'https://alice.example.com/contacts/groups.ttl': ` + @prefix rdf: . + @prefix vcard: . + + + vcard:includesGroup . + + + rdf:type vcard:Group; + vcard:fn "Team Access". + `, + 'https://alice.example.com/contacts/Group/team.ttl': ` + @prefix rdf: . + @prefix vcard: . + + <#this> + rdf:type vcard:Group; + vcard:fn "Team Access". + `, + 'https://bob.example.com/profile/card.ttl': ` + @prefix foaf: . + @prefix vcard: . + + <#me> + a vcard:Individual; + foaf:name "Bob Example"; + foaf:knows . + `, + 'https://charlie.example.com/profile/card.ttl': ` + @prefix foaf: . + @prefix vcard: . + + <#me> + a vcard:Individual; + foaf:name "Charlie Example". + ` + } + + fetchMock.resetMocks() + fetchMock.mockIf(/^https?.*$/, async (req: Request) => { + if (req.method !== 'GET') { + return { status: 200, body: '' } + } + + if (req.url === DEFAULT_DIRECTORY_CATALOG_URL) { + return { + status: 200, + body: ` + @prefix cat: . + @prefix rdf: . + + <#zoe> + rdf:type cat:Person; + cat:webid ; + cat:name "Zoe Catalog". + `, + headers: { 'Content-Type': 'text/turtle' } + } + } + + const contents = web[req.url] + if (contents !== undefined) { + return { + status: 200, + body: contents, + headers: { + 'Content-Type': 'text/turtle', + 'WAC-Allow': 'user="write", public="read"', + 'Accept-Patch': 'application/sparql-update' + } + } + } + + return { status: 404, body: 'Not Found' } + }) + + const store = new Store() + store.fetcher = new Fetcher(store, { fetch }) + store.updater = new UpdateManager(store) + const authn = { + currentUser: () => alice + } + const utilityLogic = createUtilityLogic(store, createAclLogic(store), createContainerLogic(store)) + const profileLogic = createProfileLogic(store, authn, utilityLogic) + const typeIndexLogic = createTypeIndexLogic(store, authn, profileLogic, utilityLogic) + directoryLogic = createDirectoryLogic(store as any, authn as any, typeIndexLogic) + }) + + it('returns contact entries when searching contacts only', async () => { + const entries = await directoryLogic.search({ query: 'bobby', sources: ['contacts'] }) + + expect(entries).toEqual([ + expect.objectContaining({ + kind: 'person', + uri: 'https://bob.example.com/profile/card.ttl#me', + label: 'Bobby Contact', + subjectType: 'agent', + relationshipLabel: 'Contact', + sources: ['contacts'] + }) + ]) + }) + + it('returns group entries when searching groups only', async () => { + const entries = await directoryLogic.search({ query: 'team', sources: ['groups'] }) + + expect(entries).toEqual([ + expect.objectContaining({ + kind: 'group', + uri: 'https://alice.example.com/contacts/Group/team.ttl#this', + label: 'Team Access', + subjectType: 'agentGroup', + relationshipLabel: 'Group', + sources: ['groups'] + }) + ]) + }) + + it('returns foaf friends when searching friends only', async () => { + const entries = await directoryLogic.search({ query: 'bob', sources: ['friends'] }) + + expect(entries).toEqual([ + expect.objectContaining({ + kind: 'person', + uri: 'https://bob.example.com/profile/card.ttl#me', + label: 'Bob Example', + subjectType: 'agent', + relationshipLabel: 'Friend', + sources: ['friends'] + }) + ]) + }) + + it('returns catalog people when searching catalog only', async () => { + const entries = await directoryLogic.search({ query: 'zoe', sources: ['catalog'] }) + + expect(entries).toEqual([ + expect.objectContaining({ + kind: 'person', + uri: 'https://zoe.example.com/profile/card.ttl#me', + label: 'Zoe Catalog', + subjectType: 'agent', + relationshipLabel: 'People', + sources: ['catalog'] + }) + ]) + }) +})