javascript-solid-server 0.0.3 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -3
- package/src/auth/middleware.js +99 -0
- package/src/auth/solid-oidc.js +260 -0
- package/src/auth/token.js +150 -0
- package/src/handlers/container.js +24 -4
- package/src/handlers/resource.js +19 -10
- package/src/ldp/headers.js +31 -6
- package/src/server.js +20 -0
- package/src/wac/checker.js +257 -0
- package/src/wac/parser.js +284 -0
- package/test/auth.test.js +175 -0
- package/test/helpers.js +38 -4
- package/test/ldp.test.js +61 -20
- package/test/pod.test.js +16 -23
- package/test/solid-oidc.test.js +211 -0
- package/test/wac.test.js +189 -0
- package/benchmark-report-2025-03-31T14-25-24.234Z.json +0 -44
package/src/ldp/headers.js
CHANGED
|
@@ -7,9 +7,10 @@ const LDP = 'http://www.w3.org/ns/ldp#';
|
|
|
7
7
|
/**
|
|
8
8
|
* Get Link headers for a resource
|
|
9
9
|
* @param {boolean} isContainer
|
|
10
|
+
* @param {string} aclUrl - URL to the ACL resource
|
|
10
11
|
* @returns {string}
|
|
11
12
|
*/
|
|
12
|
-
export function getLinkHeader(isContainer) {
|
|
13
|
+
export function getLinkHeader(isContainer, aclUrl = null) {
|
|
13
14
|
const links = [`<${LDP}Resource>; rel="type"`];
|
|
14
15
|
|
|
15
16
|
if (isContainer) {
|
|
@@ -17,18 +18,42 @@ export function getLinkHeader(isContainer) {
|
|
|
17
18
|
links.push(`<${LDP}BasicContainer>; rel="type"`);
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
// Add acl link for auxiliary resource discovery
|
|
22
|
+
if (aclUrl) {
|
|
23
|
+
links.push(`<${aclUrl}>; rel="acl"`);
|
|
24
|
+
}
|
|
25
|
+
|
|
20
26
|
return links.join(', ');
|
|
21
27
|
}
|
|
22
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Get the ACL URL for a resource
|
|
31
|
+
* @param {string} resourceUrl - Full URL of the resource
|
|
32
|
+
* @param {boolean} isContainer - Whether the resource is a container
|
|
33
|
+
* @returns {string} ACL URL
|
|
34
|
+
*/
|
|
35
|
+
export function getAclUrl(resourceUrl, isContainer) {
|
|
36
|
+
if (isContainer) {
|
|
37
|
+
// Container ACL: /path/.acl
|
|
38
|
+
const base = resourceUrl.endsWith('/') ? resourceUrl : resourceUrl + '/';
|
|
39
|
+
return base + '.acl';
|
|
40
|
+
}
|
|
41
|
+
// Resource ACL: /path/file.acl
|
|
42
|
+
return resourceUrl + '.acl';
|
|
43
|
+
}
|
|
44
|
+
|
|
23
45
|
/**
|
|
24
46
|
* Get standard LDP response headers
|
|
25
47
|
* @param {object} options
|
|
26
48
|
* @returns {object}
|
|
27
49
|
*/
|
|
28
|
-
export function getResponseHeaders({ isContainer = false, etag = null, contentType = null }) {
|
|
50
|
+
export function getResponseHeaders({ isContainer = false, etag = null, contentType = null, resourceUrl = null, wacAllow = null }) {
|
|
51
|
+
// Calculate ACL URL if resource URL provided
|
|
52
|
+
const aclUrl = resourceUrl ? getAclUrl(resourceUrl, isContainer) : null;
|
|
53
|
+
|
|
29
54
|
const headers = {
|
|
30
|
-
'Link': getLinkHeader(isContainer),
|
|
31
|
-
'WAC-Allow': 'user="read write append control", public="read write append"',
|
|
55
|
+
'Link': getLinkHeader(isContainer, aclUrl),
|
|
56
|
+
'WAC-Allow': wacAllow || 'user="read write append control", public="read write append"',
|
|
32
57
|
'Accept-Patch': 'application/sparql-update',
|
|
33
58
|
'Allow': 'GET, HEAD, PUT, DELETE, OPTIONS' + (isContainer ? ', POST' : ''),
|
|
34
59
|
'Vary': 'Accept, Authorization, Origin'
|
|
@@ -70,9 +95,9 @@ export function getCorsHeaders(origin) {
|
|
|
70
95
|
* @param {object} options
|
|
71
96
|
* @returns {object}
|
|
72
97
|
*/
|
|
73
|
-
export function getAllHeaders({ isContainer = false, etag = null, contentType = null, origin = null }) {
|
|
98
|
+
export function getAllHeaders({ isContainer = false, etag = null, contentType = null, origin = null, resourceUrl = null, wacAllow = null }) {
|
|
74
99
|
return {
|
|
75
|
-
...getResponseHeaders({ isContainer, etag, contentType }),
|
|
100
|
+
...getResponseHeaders({ isContainer, etag, contentType, resourceUrl, wacAllow }),
|
|
76
101
|
...getCorsHeaders(origin)
|
|
77
102
|
};
|
|
78
103
|
}
|
package/src/server.js
CHANGED
|
@@ -2,6 +2,7 @@ import Fastify from 'fastify';
|
|
|
2
2
|
import { handleGet, handleHead, handlePut, handleDelete, handleOptions } from './handlers/resource.js';
|
|
3
3
|
import { handlePost, handleCreatePod } from './handlers/container.js';
|
|
4
4
|
import { getCorsHeaders } from './ldp/headers.js';
|
|
5
|
+
import { authorize, handleUnauthorized } from './auth/middleware.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Create and configure Fastify server
|
|
@@ -34,6 +35,25 @@ export function createServer(options = {}) {
|
|
|
34
35
|
}
|
|
35
36
|
});
|
|
36
37
|
|
|
38
|
+
// Authorization hook - check WAC permissions
|
|
39
|
+
// Skip for pod creation endpoint (needs special handling)
|
|
40
|
+
fastify.addHook('preHandler', async (request, reply) => {
|
|
41
|
+
// Skip auth for pod creation and OPTIONS
|
|
42
|
+
if (request.url === '/.pods' || request.method === 'OPTIONS') {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { authorized, webId, wacAllow, authError } = await authorize(request, reply);
|
|
47
|
+
|
|
48
|
+
// Store webId and wacAllow on request for handlers to use
|
|
49
|
+
request.webId = webId;
|
|
50
|
+
request.wacAllow = wacAllow;
|
|
51
|
+
|
|
52
|
+
if (!authorized) {
|
|
53
|
+
return handleUnauthorized(reply, webId !== null, wacAllow, authError);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
37
57
|
// Pod creation endpoint
|
|
38
58
|
fastify.post('/.pods', handleCreatePod);
|
|
39
59
|
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAC (Web Access Control) Checker
|
|
3
|
+
* Checks if an agent has permission to access a resource
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as storage from '../storage/filesystem.js';
|
|
7
|
+
import { parseAcl, AccessMode, AgentClass } from './parser.js';
|
|
8
|
+
import { getAclUrl } from '../ldp/headers.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check if agent has required access mode for resource
|
|
12
|
+
* @param {object} options
|
|
13
|
+
* @param {string} options.resourceUrl - Full URL of the resource
|
|
14
|
+
* @param {string} options.resourcePath - Path portion of the resource URL
|
|
15
|
+
* @param {boolean} options.isContainer - Whether resource is a container
|
|
16
|
+
* @param {string|null} options.agentWebId - WebID of the agent (null for unauthenticated)
|
|
17
|
+
* @param {string} options.requiredMode - Required access mode (from AccessMode)
|
|
18
|
+
* @returns {Promise<{allowed: boolean, wacAllow: string}>}
|
|
19
|
+
*/
|
|
20
|
+
export async function checkAccess({
|
|
21
|
+
resourceUrl,
|
|
22
|
+
resourcePath,
|
|
23
|
+
isContainer,
|
|
24
|
+
agentWebId,
|
|
25
|
+
requiredMode
|
|
26
|
+
}) {
|
|
27
|
+
// Find applicable ACL
|
|
28
|
+
const aclResult = await findApplicableAcl(resourceUrl, resourcePath, isContainer);
|
|
29
|
+
|
|
30
|
+
if (!aclResult) {
|
|
31
|
+
// No ACL found - allow by default (permissive mode)
|
|
32
|
+
// This allows resources without ACLs to be publicly accessible
|
|
33
|
+
return { allowed: true, wacAllow: 'user="read write append control", public="read write append"' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { authorizations, isDefault, targetUrl } = aclResult;
|
|
37
|
+
|
|
38
|
+
// Check authorizations
|
|
39
|
+
const allowed = checkAuthorizations(
|
|
40
|
+
authorizations,
|
|
41
|
+
targetUrl,
|
|
42
|
+
agentWebId,
|
|
43
|
+
requiredMode,
|
|
44
|
+
isDefault
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// Calculate WAC-Allow header
|
|
48
|
+
const wacAllow = calculateWacAllow(authorizations, targetUrl, agentWebId, isDefault);
|
|
49
|
+
|
|
50
|
+
return { allowed, wacAllow };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Find the applicable ACL for a resource
|
|
55
|
+
* Walks up the path hierarchy looking for .acl files
|
|
56
|
+
*/
|
|
57
|
+
async function findApplicableAcl(resourceUrl, resourcePath, isContainer) {
|
|
58
|
+
// First check for resource-specific ACL
|
|
59
|
+
const resourceAclPath = isContainer
|
|
60
|
+
? (resourcePath.endsWith('/') ? resourcePath : resourcePath + '/') + '.acl'
|
|
61
|
+
: resourcePath + '.acl';
|
|
62
|
+
|
|
63
|
+
if (await storage.exists(resourceAclPath)) {
|
|
64
|
+
const content = await storage.read(resourceAclPath);
|
|
65
|
+
if (content) {
|
|
66
|
+
const aclUrl = getAclUrl(resourceUrl, isContainer);
|
|
67
|
+
const authorizations = parseAcl(content.toString(), aclUrl);
|
|
68
|
+
return { authorizations, isDefault: false, targetUrl: resourceUrl };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Walk up the hierarchy looking for default ACLs
|
|
73
|
+
let currentPath = resourcePath;
|
|
74
|
+
while (currentPath && currentPath !== '/') {
|
|
75
|
+
// Get parent container
|
|
76
|
+
const parentPath = getParentPath(currentPath);
|
|
77
|
+
const parentAclPath = parentPath + '.acl';
|
|
78
|
+
|
|
79
|
+
if (await storage.exists(parentAclPath)) {
|
|
80
|
+
const content = await storage.read(parentAclPath);
|
|
81
|
+
if (content) {
|
|
82
|
+
const parentUrl = resourceUrl.substring(0, resourceUrl.lastIndexOf(currentPath)) + parentPath;
|
|
83
|
+
const authorizations = parseAcl(content.toString(), parentAclPath);
|
|
84
|
+
return { authorizations, isDefault: true, targetUrl: parentUrl };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
currentPath = parentPath;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Check root ACL
|
|
92
|
+
if (await storage.exists('/.acl')) {
|
|
93
|
+
const content = await storage.read('/.acl');
|
|
94
|
+
if (content) {
|
|
95
|
+
const rootUrl = resourceUrl.substring(0, resourceUrl.indexOf('/', 8) + 1);
|
|
96
|
+
const authorizations = parseAcl(content.toString(), '/.acl');
|
|
97
|
+
return { authorizations, isDefault: true, targetUrl: rootUrl };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Get parent container path
|
|
106
|
+
*/
|
|
107
|
+
function getParentPath(path) {
|
|
108
|
+
// Remove trailing slash
|
|
109
|
+
const normalized = path.endsWith('/') ? path.slice(0, -1) : path;
|
|
110
|
+
const lastSlash = normalized.lastIndexOf('/');
|
|
111
|
+
if (lastSlash <= 0) return '/';
|
|
112
|
+
return normalized.substring(0, lastSlash + 1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Check if any authorization grants the required mode
|
|
117
|
+
*/
|
|
118
|
+
function checkAuthorizations(authorizations, targetUrl, agentWebId, requiredMode, isDefault) {
|
|
119
|
+
for (const auth of authorizations) {
|
|
120
|
+
// Check if this authorization applies to the resource
|
|
121
|
+
const appliesToResource = isDefault
|
|
122
|
+
? auth.default.some(d => urlMatches(d, targetUrl))
|
|
123
|
+
: auth.accessTo.some(a => urlMatches(a, targetUrl));
|
|
124
|
+
|
|
125
|
+
if (!appliesToResource && !isDefault) continue;
|
|
126
|
+
if (isDefault && auth.default.length === 0) continue;
|
|
127
|
+
|
|
128
|
+
// Check if agent is authorized
|
|
129
|
+
const agentAuthorized = isAgentAuthorized(auth, agentWebId);
|
|
130
|
+
if (!agentAuthorized) continue;
|
|
131
|
+
|
|
132
|
+
// Check if mode is granted
|
|
133
|
+
if (auth.modes.includes(requiredMode)) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Write implies Append
|
|
138
|
+
if (requiredMode === AccessMode.APPEND && auth.modes.includes(AccessMode.WRITE)) {
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Check if the agent is authorized by an authorization rule
|
|
148
|
+
*/
|
|
149
|
+
function isAgentAuthorized(auth, agentWebId) {
|
|
150
|
+
// Check specific agent
|
|
151
|
+
if (agentWebId && auth.agents.includes(agentWebId)) {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Check agent classes
|
|
156
|
+
for (const agentClass of auth.agentClasses) {
|
|
157
|
+
// foaf:Agent - everyone (including unauthenticated)
|
|
158
|
+
if (agentClass === AgentClass.AGENT || agentClass === 'foaf:Agent') {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// acl:AuthenticatedAgent - any authenticated user
|
|
163
|
+
if (agentWebId && (agentClass === AgentClass.AUTHENTICATED || agentClass === 'acl:AuthenticatedAgent')) {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// TODO: Check agent groups (requires fetching and parsing group documents)
|
|
169
|
+
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Check if URLs match (handles trailing slashes)
|
|
175
|
+
*/
|
|
176
|
+
function urlMatches(pattern, url) {
|
|
177
|
+
const normalizedPattern = pattern.replace(/\/$/, '');
|
|
178
|
+
const normalizedUrl = url.replace(/\/$/, '');
|
|
179
|
+
return normalizedPattern === normalizedUrl;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Calculate WAC-Allow header value
|
|
184
|
+
*/
|
|
185
|
+
function calculateWacAllow(authorizations, targetUrl, agentWebId, isDefault) {
|
|
186
|
+
const userModes = new Set();
|
|
187
|
+
const publicModes = new Set();
|
|
188
|
+
|
|
189
|
+
for (const auth of authorizations) {
|
|
190
|
+
// Check if applies to resource
|
|
191
|
+
const applies = isDefault
|
|
192
|
+
? auth.default.length > 0
|
|
193
|
+
: auth.accessTo.some(a => urlMatches(a, targetUrl));
|
|
194
|
+
|
|
195
|
+
if (!applies && !isDefault) continue;
|
|
196
|
+
|
|
197
|
+
// Check what modes this grants
|
|
198
|
+
const modes = auth.modes.map(m => {
|
|
199
|
+
if (m === AccessMode.READ || m === 'acl:Read') return 'read';
|
|
200
|
+
if (m === AccessMode.WRITE || m === 'acl:Write') return 'write';
|
|
201
|
+
if (m === AccessMode.APPEND || m === 'acl:Append') return 'append';
|
|
202
|
+
if (m === AccessMode.CONTROL || m === 'acl:Control') return 'control';
|
|
203
|
+
return null;
|
|
204
|
+
}).filter(Boolean);
|
|
205
|
+
|
|
206
|
+
// Check if public
|
|
207
|
+
const isPublic = auth.agentClasses.some(c =>
|
|
208
|
+
c === AgentClass.AGENT || c === 'foaf:Agent'
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
if (isPublic) {
|
|
212
|
+
modes.forEach(m => publicModes.add(m));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Check if user-specific
|
|
216
|
+
if (agentWebId && auth.agents.includes(agentWebId)) {
|
|
217
|
+
modes.forEach(m => userModes.add(m));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Check authenticated class
|
|
221
|
+
if (agentWebId && auth.agentClasses.some(c =>
|
|
222
|
+
c === AgentClass.AUTHENTICATED || c === 'acl:AuthenticatedAgent'
|
|
223
|
+
)) {
|
|
224
|
+
modes.forEach(m => userModes.add(m));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// User also gets public modes
|
|
229
|
+
publicModes.forEach(m => userModes.add(m));
|
|
230
|
+
|
|
231
|
+
const userStr = Array.from(userModes).join(' ');
|
|
232
|
+
const publicStr = Array.from(publicModes).join(' ');
|
|
233
|
+
|
|
234
|
+
return `user="${userStr}", public="${publicStr}"`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Get the required access mode for an HTTP method
|
|
239
|
+
* @param {string} method - HTTP method
|
|
240
|
+
* @returns {string} Access mode
|
|
241
|
+
*/
|
|
242
|
+
export function getRequiredMode(method) {
|
|
243
|
+
switch (method.toUpperCase()) {
|
|
244
|
+
case 'GET':
|
|
245
|
+
case 'HEAD':
|
|
246
|
+
case 'OPTIONS':
|
|
247
|
+
return AccessMode.READ;
|
|
248
|
+
case 'POST':
|
|
249
|
+
return AccessMode.APPEND;
|
|
250
|
+
case 'PUT':
|
|
251
|
+
case 'PATCH':
|
|
252
|
+
case 'DELETE':
|
|
253
|
+
return AccessMode.WRITE;
|
|
254
|
+
default:
|
|
255
|
+
return AccessMode.READ;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAC (Web Access Control) Parser
|
|
3
|
+
* Parses JSON-LD .acl files into authorization rules
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const ACL = 'http://www.w3.org/ns/auth/acl#';
|
|
7
|
+
const FOAF = 'http://xmlns.com/foaf/0.1/';
|
|
8
|
+
|
|
9
|
+
// Access modes
|
|
10
|
+
export const AccessMode = {
|
|
11
|
+
READ: `${ACL}Read`,
|
|
12
|
+
WRITE: `${ACL}Write`,
|
|
13
|
+
APPEND: `${ACL}Append`,
|
|
14
|
+
CONTROL: `${ACL}Control`
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Agent classes
|
|
18
|
+
export const AgentClass = {
|
|
19
|
+
AGENT: `${FOAF}Agent`, // Everyone (public)
|
|
20
|
+
AUTHENTICATED: `${ACL}AuthenticatedAgent` // Any authenticated user
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse a JSON-LD ACL document
|
|
25
|
+
* @param {string|object} content - JSON-LD content (string or parsed object)
|
|
26
|
+
* @param {string} aclUrl - URL of the ACL document
|
|
27
|
+
* @returns {Array<Authorization>} List of authorization rules
|
|
28
|
+
*/
|
|
29
|
+
export function parseAcl(content, aclUrl) {
|
|
30
|
+
let doc;
|
|
31
|
+
try {
|
|
32
|
+
doc = typeof content === 'string' ? JSON.parse(content) : content;
|
|
33
|
+
} catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const authorizations = [];
|
|
38
|
+
|
|
39
|
+
// Handle @graph array or single object
|
|
40
|
+
const nodes = doc['@graph'] || [doc];
|
|
41
|
+
|
|
42
|
+
for (const node of nodes) {
|
|
43
|
+
if (isAuthorization(node)) {
|
|
44
|
+
const auth = parseAuthorization(node, aclUrl);
|
|
45
|
+
if (auth) {
|
|
46
|
+
authorizations.push(auth);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return authorizations;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if node is an Authorization
|
|
56
|
+
*/
|
|
57
|
+
function isAuthorization(node) {
|
|
58
|
+
const type = node['@type'];
|
|
59
|
+
if (!type) return false;
|
|
60
|
+
|
|
61
|
+
const types = Array.isArray(type) ? type : [type];
|
|
62
|
+
return types.some(t =>
|
|
63
|
+
t === 'acl:Authorization' ||
|
|
64
|
+
t === `${ACL}Authorization` ||
|
|
65
|
+
t === 'Authorization'
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse a single Authorization node
|
|
71
|
+
*/
|
|
72
|
+
function parseAuthorization(node, aclUrl) {
|
|
73
|
+
const auth = {
|
|
74
|
+
id: node['@id'],
|
|
75
|
+
accessTo: [], // Resources this applies to
|
|
76
|
+
default: [], // Default for contained resources
|
|
77
|
+
agents: [], // Specific WebIDs
|
|
78
|
+
agentClasses: [], // Agent classes (public, authenticated)
|
|
79
|
+
agentGroups: [], // Groups
|
|
80
|
+
modes: [] // Access modes
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Parse accessTo
|
|
84
|
+
auth.accessTo = parseUriArray(node['acl:accessTo'] || node['accessTo']);
|
|
85
|
+
|
|
86
|
+
// Parse default (for containers)
|
|
87
|
+
auth.default = parseUriArray(node['acl:default'] || node['default']);
|
|
88
|
+
|
|
89
|
+
// Parse agents
|
|
90
|
+
auth.agents = parseUriArray(node['acl:agent'] || node['agent']);
|
|
91
|
+
|
|
92
|
+
// Parse agentClass
|
|
93
|
+
auth.agentClasses = parseUriArray(node['acl:agentClass'] || node['agentClass']);
|
|
94
|
+
|
|
95
|
+
// Parse agentGroup
|
|
96
|
+
auth.agentGroups = parseUriArray(node['acl:agentGroup'] || node['agentGroup']);
|
|
97
|
+
|
|
98
|
+
// Parse modes
|
|
99
|
+
auth.modes = parseUriArray(node['acl:mode'] || node['mode']).map(normalizeMode);
|
|
100
|
+
|
|
101
|
+
return auth;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Parse a value that could be a URI, @id object, or array of either
|
|
106
|
+
*/
|
|
107
|
+
function parseUriArray(value) {
|
|
108
|
+
if (!value) return [];
|
|
109
|
+
|
|
110
|
+
const values = Array.isArray(value) ? value : [value];
|
|
111
|
+
|
|
112
|
+
return values.map(v => {
|
|
113
|
+
if (typeof v === 'string') return v;
|
|
114
|
+
if (v && typeof v === 'object' && v['@id']) return v['@id'];
|
|
115
|
+
return null;
|
|
116
|
+
}).filter(Boolean);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Normalize mode URIs to full form
|
|
121
|
+
*/
|
|
122
|
+
function normalizeMode(mode) {
|
|
123
|
+
const modeMap = {
|
|
124
|
+
'Read': AccessMode.READ,
|
|
125
|
+
'Write': AccessMode.WRITE,
|
|
126
|
+
'Append': AccessMode.APPEND,
|
|
127
|
+
'Control': AccessMode.CONTROL,
|
|
128
|
+
'acl:Read': AccessMode.READ,
|
|
129
|
+
'acl:Write': AccessMode.WRITE,
|
|
130
|
+
'acl:Append': AccessMode.APPEND,
|
|
131
|
+
'acl:Control': AccessMode.CONTROL
|
|
132
|
+
};
|
|
133
|
+
return modeMap[mode] || mode;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Generate a default public read ACL
|
|
138
|
+
* @param {string} resourceUrl - URL of the resource
|
|
139
|
+
* @returns {object} JSON-LD ACL document
|
|
140
|
+
*/
|
|
141
|
+
export function generatePublicReadAcl(resourceUrl) {
|
|
142
|
+
return {
|
|
143
|
+
'@context': {
|
|
144
|
+
'acl': ACL,
|
|
145
|
+
'foaf': FOAF
|
|
146
|
+
},
|
|
147
|
+
'@graph': [
|
|
148
|
+
{
|
|
149
|
+
'@id': '#public',
|
|
150
|
+
'@type': 'acl:Authorization',
|
|
151
|
+
'acl:agentClass': { '@id': 'foaf:Agent' },
|
|
152
|
+
'acl:accessTo': { '@id': resourceUrl },
|
|
153
|
+
'acl:mode': [
|
|
154
|
+
{ '@id': 'acl:Read' }
|
|
155
|
+
]
|
|
156
|
+
}
|
|
157
|
+
]
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Generate a full owner ACL (owner has full control, public read)
|
|
163
|
+
* @param {string} resourceUrl - URL of the resource
|
|
164
|
+
* @param {string} ownerWebId - WebID of the owner
|
|
165
|
+
* @param {boolean} isContainer - Whether this is a container
|
|
166
|
+
* @returns {object} JSON-LD ACL document
|
|
167
|
+
*/
|
|
168
|
+
export function generateOwnerAcl(resourceUrl, ownerWebId, isContainer = false) {
|
|
169
|
+
const graph = [
|
|
170
|
+
{
|
|
171
|
+
'@id': '#owner',
|
|
172
|
+
'@type': 'acl:Authorization',
|
|
173
|
+
'acl:agent': { '@id': ownerWebId },
|
|
174
|
+
'acl:accessTo': { '@id': resourceUrl },
|
|
175
|
+
'acl:mode': [
|
|
176
|
+
{ '@id': 'acl:Read' },
|
|
177
|
+
{ '@id': 'acl:Write' },
|
|
178
|
+
{ '@id': 'acl:Control' }
|
|
179
|
+
]
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
'@id': '#public',
|
|
183
|
+
'@type': 'acl:Authorization',
|
|
184
|
+
'acl:agentClass': { '@id': 'foaf:Agent' },
|
|
185
|
+
'acl:accessTo': { '@id': resourceUrl },
|
|
186
|
+
'acl:mode': [
|
|
187
|
+
{ '@id': 'acl:Read' }
|
|
188
|
+
]
|
|
189
|
+
}
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
// Add default rules for containers
|
|
193
|
+
if (isContainer) {
|
|
194
|
+
graph[0]['acl:default'] = { '@id': resourceUrl };
|
|
195
|
+
graph[1]['acl:default'] = { '@id': resourceUrl };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
'@context': {
|
|
200
|
+
'acl': ACL,
|
|
201
|
+
'foaf': FOAF
|
|
202
|
+
},
|
|
203
|
+
'@graph': graph
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Generate a private ACL (owner only, no public access)
|
|
209
|
+
* @param {string} resourceUrl - URL of the resource
|
|
210
|
+
* @param {string} ownerWebId - WebID of the owner
|
|
211
|
+
* @param {boolean} isContainer - Whether this is a container
|
|
212
|
+
* @returns {object} JSON-LD ACL document
|
|
213
|
+
*/
|
|
214
|
+
export function generatePrivateAcl(resourceUrl, ownerWebId, isContainer = true) {
|
|
215
|
+
const auth = {
|
|
216
|
+
'@id': '#owner',
|
|
217
|
+
'@type': 'acl:Authorization',
|
|
218
|
+
'acl:agent': { '@id': ownerWebId },
|
|
219
|
+
'acl:accessTo': { '@id': resourceUrl },
|
|
220
|
+
'acl:mode': [
|
|
221
|
+
{ '@id': 'acl:Read' },
|
|
222
|
+
{ '@id': 'acl:Write' },
|
|
223
|
+
{ '@id': 'acl:Control' }
|
|
224
|
+
]
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
if (isContainer) {
|
|
228
|
+
auth['acl:default'] = { '@id': resourceUrl };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
'@context': {
|
|
233
|
+
'acl': ACL,
|
|
234
|
+
'foaf': FOAF
|
|
235
|
+
},
|
|
236
|
+
'@graph': [auth]
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Generate an inbox ACL (owner full control, public append)
|
|
242
|
+
* @param {string} resourceUrl - URL of the inbox
|
|
243
|
+
* @param {string} ownerWebId - WebID of the owner
|
|
244
|
+
* @returns {object} JSON-LD ACL document
|
|
245
|
+
*/
|
|
246
|
+
export function generateInboxAcl(resourceUrl, ownerWebId) {
|
|
247
|
+
return {
|
|
248
|
+
'@context': {
|
|
249
|
+
'acl': ACL,
|
|
250
|
+
'foaf': FOAF
|
|
251
|
+
},
|
|
252
|
+
'@graph': [
|
|
253
|
+
{
|
|
254
|
+
'@id': '#owner',
|
|
255
|
+
'@type': 'acl:Authorization',
|
|
256
|
+
'acl:agent': { '@id': ownerWebId },
|
|
257
|
+
'acl:accessTo': { '@id': resourceUrl },
|
|
258
|
+
'acl:default': { '@id': resourceUrl },
|
|
259
|
+
'acl:mode': [
|
|
260
|
+
{ '@id': 'acl:Read' },
|
|
261
|
+
{ '@id': 'acl:Write' },
|
|
262
|
+
{ '@id': 'acl:Control' }
|
|
263
|
+
]
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
'@id': '#public',
|
|
267
|
+
'@type': 'acl:Authorization',
|
|
268
|
+
'acl:agentClass': { '@id': 'foaf:Agent' },
|
|
269
|
+
'acl:accessTo': { '@id': resourceUrl },
|
|
270
|
+
'acl:default': { '@id': resourceUrl },
|
|
271
|
+
'acl:mode': [
|
|
272
|
+
{ '@id': 'acl:Append' }
|
|
273
|
+
]
|
|
274
|
+
}
|
|
275
|
+
]
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Serialize ACL to JSON string
|
|
281
|
+
*/
|
|
282
|
+
export function serializeAcl(acl) {
|
|
283
|
+
return JSON.stringify(acl, null, 2);
|
|
284
|
+
}
|