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.
@@ -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
+ }