/**
* WAC (Web Access Control) Parser
* Parses ACL files (JSON-LD or Turtle) into authorization rules
*/
import { turtleToJsonLd } from '../rdf/turtle.js';
import { safeJsonParse } from '../utils/url.js';
const ACL = 'http://www.w3.org/ns/auth/acl#';
const FOAF = 'http://xmlns.com/foaf/0.1/';
// Access modes
export const AccessMode = {
READ: `${ACL}Read`,
WRITE: `${ACL}Write`,
APPEND: `${ACL}Append`,
CONTROL: `${ACL}Control`
};
// Agent classes
export const AgentClass = {
AGENT: `${FOAF}Agent`, // Everyone (public)
AUTHENTICATED: `${ACL}AuthenticatedAgent` // Any authenticated user
};
/**
* Parse an ACL document (JSON-LD or Turtle)
* @param {string|object} content - ACL content (JSON-LD string/object or Turtle string)
* @param {string} aclUrl - URL of the ACL document
* @returns {Promise} List of authorization rules
*/
export async function parseAcl(content, aclUrl) {
let doc;
// If already an object, use it directly
if (typeof content === 'object' && content !== null) {
doc = content;
} else if (typeof content === 'string') {
// Try JSON-LD first (with size limit for DoS protection)
try {
doc = safeJsonParse(content);
} catch {
// Not JSON, try Turtle
try {
doc = await turtleToJsonLd(content, aclUrl);
} catch (turtleError) {
// Neither JSON-LD nor valid Turtle
return [];
}
}
} else {
return [];
}
const authorizations = [];
// Handle @graph array or single object
const nodes = Array.isArray(doc) ? doc : (doc['@graph'] || [doc]);
for (const node of nodes) {
if (isAuthorization(node)) {
const auth = parseAuthorization(node, aclUrl);
if (auth) {
authorizations.push(auth);
}
}
}
return authorizations;
}
/**
* Check if node is an Authorization
*/
function isAuthorization(node) {
const type = node['@type'];
if (!type) return false;
const types = Array.isArray(type) ? type : [type];
return types.some(t =>
t === 'acl:Authorization' ||
t === `${ACL}Authorization` ||
t === 'Authorization'
);
}
/**
* Get base URL from ACL URL (the container the ACL applies to)
* e.g., https://example.com/foo/.acl -> https://example.com/foo/
* https://example.com/foo/bar.acl -> https://example.com/foo/
*/
function getBaseUrl(aclUrl) {
if (!aclUrl) return null;
// Remove .acl suffix and get the directory
const withoutAcl = aclUrl.replace(/\.acl$/, '');
// If it was a container ACL (ended with /.acl), withoutAcl ends with /
// If it was a resource ACL (foo.acl), we need the parent directory
if (withoutAcl.endsWith('/')) {
return withoutAcl;
}
// Get parent directory
const lastSlash = withoutAcl.lastIndexOf('/');
return lastSlash > 0 ? withoutAcl.substring(0, lastSlash + 1) : withoutAcl;
}
/**
* Resolve a URI against a base URL
*/
function resolveUri(uri, baseUrl) {
if (!uri || !baseUrl) return uri;
// Already absolute
if (uri.startsWith('http://') || uri.startsWith('https://')) return uri;
// Fragment-only (like #owner) - not a resource URL
if (uri.startsWith('#')) return uri;
// Resolve relative URL
try {
return new URL(uri, baseUrl).href;
} catch {
return uri;
}
}
/**
* Parse a single Authorization node
*/
function parseAuthorization(node, aclUrl) {
const baseUrl = getBaseUrl(aclUrl);
const auth = {
id: node['@id'],
accessTo: [], // Resources this applies to
default: [], // Default for contained resources
agents: [], // Specific WebIDs
agentClasses: [], // Agent classes (public, authenticated)
agentGroups: [], // Groups
modes: [], // Access modes
conditions: [] // Access conditions (e.g. PaymentCondition)
};
// Parse accessTo - resolve relative URLs
auth.accessTo = parseUriArray(node['acl:accessTo'] || node['accessTo'])
.map(uri => resolveUri(uri, baseUrl));
// Parse default (for containers) - resolve relative URLs
auth.default = parseUriArray(node['acl:default'] || node['default'])
.map(uri => resolveUri(uri, baseUrl));
// Parse agents (WebIDs can be relative too) - resolve against ACL URL
auth.agents = parseUriArray(node['acl:agent'] || node['agent'])
.map(uri => resolveUri(uri, aclUrl));
// Parse agentClass
auth.agentClasses = parseUriArray(node['acl:agentClass'] || node['agentClass']);
// Parse agentGroup
auth.agentGroups = parseUriArray(node['acl:agentGroup'] || node['agentGroup']);
// Parse modes
auth.modes = parseUriArray(node['acl:mode'] || node['mode']).map(normalizeMode);
// Parse conditions
auth.conditions = parseConditions(node['acl:condition'] || node['condition']);
return auth;
}
/**
* Parse conditions from an authorization node
*/
function parseConditions(value) {
if (!value) return [];
const values = Array.isArray(value) ? value : [value];
return values.map(v => {
if (typeof v !== 'object' || v === null) return null;
const type = v['@type'] || v.type;
if (!type) return null;
return { ...v, type };
}).filter(Boolean);
}
/**
* Parse a value that could be a URI, @id object, or array of either
*/
function parseUriArray(value) {
if (!value) return [];
const values = Array.isArray(value) ? value : [value];
return values.map(v => {
if (typeof v === 'string') return v;
if (v && typeof v === 'object' && v['@id']) return v['@id'];
return null;
}).filter(Boolean);
}
/**
* Normalize mode URIs to full form
*/
function normalizeMode(mode) {
const modeMap = {
'Read': AccessMode.READ,
'Write': AccessMode.WRITE,
'Append': AccessMode.APPEND,
'Control': AccessMode.CONTROL,
'acl:Read': AccessMode.READ,
'acl:Write': AccessMode.WRITE,
'acl:Append': AccessMode.APPEND,
'acl:Control': AccessMode.CONTROL
};
return modeMap[mode] || mode;
}
/**
* Generate a default public read ACL
* @param {string} resourceUrl - URL of the resource
* @returns {object} JSON-LD ACL document
*/
export function generatePublicReadAcl(resourceUrl) {
return {
'@context': {
'acl': ACL,
'foaf': FOAF
},
'@graph': [
{
'@id': '#public',
'@type': 'acl:Authorization',
'acl:agentClass': { '@id': 'foaf:Agent' },
'acl:accessTo': { '@id': resourceUrl },
'acl:mode': [
{ '@id': 'acl:Read' }
]
}
]
};
}
/**
* Generate a full owner ACL (owner has full control, public read)
* @param {string} resourceUrl - URL of the resource
* @param {string} ownerWebId - WebID of the owner
* @param {boolean} isContainer - Whether this is a container
* @returns {object} JSON-LD ACL document
*/
export function generateOwnerAcl(resourceUrl, ownerWebId, isContainer = false) {
const graph = [
{
'@id': '#owner',
'@type': 'acl:Authorization',
'acl:agent': { '@id': ownerWebId },
'acl:accessTo': { '@id': resourceUrl },
'acl:mode': [
{ '@id': 'acl:Read' },
{ '@id': 'acl:Write' },
{ '@id': 'acl:Control' }
]
},
{
'@id': '#public',
'@type': 'acl:Authorization',
'acl:agentClass': { '@id': 'foaf:Agent' },
'acl:accessTo': { '@id': resourceUrl },
'acl:mode': [
{ '@id': 'acl:Read' }
]
}
];
// Add default rules for containers
// Only owner gets default - children don't inherit public read
if (isContainer) {
graph[0]['acl:default'] = { '@id': resourceUrl };
// Note: intentionally not adding default to #public
// so child resources require authentication by default
}
return {
'@context': {
'acl': ACL,
'foaf': FOAF
},
'@graph': graph
};
}
/**
* Generate a private ACL (owner only, no public access)
* @param {string} resourceUrl - URL of the resource
* @param {string} ownerWebId - WebID of the owner
* @param {boolean} isContainer - Whether this is a container
* @returns {object} JSON-LD ACL document
*/
export function generatePrivateAcl(resourceUrl, ownerWebId, isContainer = true) {
const auth = {
'@id': '#owner',
'@type': 'acl:Authorization',
'acl:agent': { '@id': ownerWebId },
'acl:accessTo': { '@id': resourceUrl },
'acl:mode': [
{ '@id': 'acl:Read' },
{ '@id': 'acl:Write' },
{ '@id': 'acl:Control' }
]
};
if (isContainer) {
auth['acl:default'] = { '@id': resourceUrl };
}
return {
'@context': {
'acl': ACL,
'foaf': FOAF
},
'@graph': [auth]
};
}
/**
* Generate an inbox ACL (owner full control, public append)
* @param {string} resourceUrl - URL of the inbox
* @param {string} ownerWebId - WebID of the owner
* @returns {object} JSON-LD ACL document
*/
export function generateInboxAcl(resourceUrl, ownerWebId) {
return {
'@context': {
'acl': ACL,
'foaf': FOAF
},
'@graph': [
{
'@id': '#owner',
'@type': 'acl:Authorization',
'acl:agent': { '@id': ownerWebId },
'acl:accessTo': { '@id': resourceUrl },
'acl:default': { '@id': resourceUrl },
'acl:mode': [
{ '@id': 'acl:Read' },
{ '@id': 'acl:Write' },
{ '@id': 'acl:Control' }
]
},
{
'@id': '#public',
'@type': 'acl:Authorization',
'acl:agentClass': { '@id': 'foaf:Agent' },
'acl:accessTo': { '@id': resourceUrl },
'acl:default': { '@id': resourceUrl },
'acl:mode': [
{ '@id': 'acl:Append' }
]
}
]
};
}
/**
* Generate a public folder ACL (owner full control, public read with inheritance)
* Used for /public/ folders where content should be publicly readable
* @param {string} resourceUrl - URL of the folder
* @param {string} ownerWebId - WebID of the owner
* @returns {object} JSON-LD ACL document
*/
export function generatePublicFolderAcl(resourceUrl, ownerWebId) {
return {
'@context': {
'acl': ACL,
'foaf': FOAF
},
'@graph': [
{
'@id': '#owner',
'@type': 'acl:Authorization',
'acl:agent': { '@id': ownerWebId },
'acl:accessTo': { '@id': resourceUrl },
'acl:default': { '@id': resourceUrl },
'acl:mode': [
{ '@id': 'acl:Read' },
{ '@id': 'acl:Write' },
{ '@id': 'acl:Control' }
]
},
{
'@id': '#public',
'@type': 'acl:Authorization',
'acl:agentClass': { '@id': 'foaf:Agent' },
'acl:accessTo': { '@id': resourceUrl },
'acl:default': { '@id': resourceUrl },
'acl:mode': [
{ '@id': 'acl:Read' }
]
}
]
};
}
/**
* Serialize ACL to JSON string
*/
export function serializeAcl(acl) {
return JSON.stringify(acl, null, 2);
}