javascript-solid-server 0.0.93 → 0.0.95
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/.claude/settings.local.json +4 -2
- package/README.md +49 -0
- package/bin/jss.js +8 -0
- package/package.json +4 -1
- package/src/auth/middleware.js +35 -8
- package/src/config.js +9 -0
- package/src/db/index.js +303 -0
- package/src/db/store.js +154 -0
- package/src/idp/interactions.js +3 -3
- package/src/server.js +11 -0
- package/test/auth.test.js +63 -0
|
@@ -141,7 +141,6 @@
|
|
|
141
141
|
"Bash(DATA_ROOT=/tmp/jss-git-test/data node:*)",
|
|
142
142
|
"Bash(git remote set-url:*)",
|
|
143
143
|
"Bash(for:*)",
|
|
144
|
-
"Bash(^/**\" | head -1 | sed ''''s/.*\\* //'''')\")",
|
|
145
144
|
"Bash(if [ ! -d \"node-solid-server\" ])",
|
|
146
145
|
"Bash(then git clone --depth 1 https://github.com/nodeSolidServer/node-solid-server.git)",
|
|
147
146
|
"Bash(node test-local-nss2.js:*)",
|
|
@@ -325,7 +324,10 @@
|
|
|
325
324
|
"Bash(npm link:*)",
|
|
326
325
|
"Bash(git push)",
|
|
327
326
|
"Bash(ulimit:*)",
|
|
328
|
-
"Bash(gh label:*)"
|
|
327
|
+
"Bash(gh label:*)",
|
|
328
|
+
"Bash(mongosh --eval \"db.runCommand\\({ ping: 1 }\\)\" 2>&1 | head -5)",
|
|
329
|
+
"Bash(which jss && jss --version 2>&1)",
|
|
330
|
+
"Bash(jss start --help 2>&1 | grep -i mongo)"
|
|
329
331
|
]
|
|
330
332
|
}
|
|
331
333
|
}
|
package/README.md
CHANGED
|
@@ -148,6 +148,9 @@ jss --help # Show help
|
|
|
148
148
|
| `--public` | Allow unauthenticated access (skip WAC) | false |
|
|
149
149
|
| `--read-only` | Disable PUT/DELETE/PATCH methods | false |
|
|
150
150
|
| `--live-reload` | Auto-refresh browser on file changes | false |
|
|
151
|
+
| `--mongo` | Enable MongoDB-backed /db/ route | false |
|
|
152
|
+
| `--mongo-url <url>` | MongoDB connection URL | mongodb://localhost:27017 |
|
|
153
|
+
| `--mongo-database <name>` | MongoDB database name | solid |
|
|
151
154
|
| `-q, --quiet` | Suppress logs | false |
|
|
152
155
|
|
|
153
156
|
### Environment Variables
|
|
@@ -173,6 +176,9 @@ export JSS_PUBLIC=true
|
|
|
173
176
|
export JSS_READ_ONLY=true
|
|
174
177
|
export JSS_LIVE_RELOAD=true
|
|
175
178
|
export JSS_SOLIDOS_UI=true
|
|
179
|
+
export JSS_MONGO=true
|
|
180
|
+
export JSS_MONGO_URL=mongodb://localhost:27017
|
|
181
|
+
export JSS_MONGO_DATABASE=solid
|
|
176
182
|
jss start
|
|
177
183
|
```
|
|
178
184
|
|
|
@@ -647,6 +653,49 @@ jss quota show alice
|
|
|
647
653
|
jss quota reconcile alice
|
|
648
654
|
```
|
|
649
655
|
|
|
656
|
+
## MongoDB Storage (`/db/` Route)
|
|
657
|
+
|
|
658
|
+
Optional MongoDB-backed route for JSON-LD documents that need scale (social feeds, posts, follows). All other routes continue using the filesystem unchanged.
|
|
659
|
+
|
|
660
|
+
```bash
|
|
661
|
+
# Install the optional MongoDB driver
|
|
662
|
+
npm install mongodb
|
|
663
|
+
|
|
664
|
+
# Start with MongoDB enabled
|
|
665
|
+
jss start --mongo --mongo-url mongodb://localhost:27017 --mongo-database solid
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
### Operations
|
|
669
|
+
|
|
670
|
+
```bash
|
|
671
|
+
# Store a document
|
|
672
|
+
curl -X PUT http://localhost:3000/db/alice/notes/1 \
|
|
673
|
+
-H "Content-Type: application/ld+json" \
|
|
674
|
+
-H "Authorization: Bearer <token>" \
|
|
675
|
+
-d '{"@context": "https://schema.org/", "@type": "Note", "text": "Hello"}'
|
|
676
|
+
|
|
677
|
+
# Read it back
|
|
678
|
+
curl http://localhost:3000/db/alice/notes/1
|
|
679
|
+
|
|
680
|
+
# List container (derived from URI prefixes)
|
|
681
|
+
curl http://localhost:3000/db/alice/
|
|
682
|
+
|
|
683
|
+
# Delete
|
|
684
|
+
curl -X DELETE http://localhost:3000/db/alice/notes/1 \
|
|
685
|
+
-H "Authorization: Bearer <token>"
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
### How It Works
|
|
689
|
+
|
|
690
|
+
- `GET /db/:path` — retrieve a document by URI, or list a virtual container
|
|
691
|
+
- `PUT /db/:path` — create or update (upsert) a JSON-LD document
|
|
692
|
+
- `DELETE /db/:path` — remove a document
|
|
693
|
+
- Returns standard LDP headers (Link, ETag, WAC-Allow, CORS)
|
|
694
|
+
- Supports conditional requests (If-Match, If-None-Match)
|
|
695
|
+
- Container listings are computed from URI prefix queries — no directory management needed
|
|
696
|
+
- Auth: pod owner can write (`/db/{podName}/...`), reads are public
|
|
697
|
+
- MongoDB is an optional dependency — the server runs without it
|
|
698
|
+
|
|
650
699
|
### How It Works
|
|
651
700
|
|
|
652
701
|
- Quotas are tracked incrementally on PUT, POST, and DELETE operations
|
package/bin/jss.js
CHANGED
|
@@ -80,6 +80,10 @@ program
|
|
|
80
80
|
.option('--public', 'Allow unauthenticated access (skip WAC, open read/write)')
|
|
81
81
|
.option('--read-only', 'Disable PUT/DELETE/PATCH methods (read-only mode)')
|
|
82
82
|
.option('--live-reload', 'Inject live reload script into HTML (auto-refresh on changes)')
|
|
83
|
+
.option('--mongo', 'Enable MongoDB-backed /db/ route')
|
|
84
|
+
.option('--no-mongo', 'Disable MongoDB-backed /db/ route')
|
|
85
|
+
.option('--mongo-url <url>', 'MongoDB connection URL (default: mongodb://localhost:27017)')
|
|
86
|
+
.option('--mongo-database <name>', 'MongoDB database name (default: solid)')
|
|
83
87
|
.option('-q, --quiet', 'Suppress log output')
|
|
84
88
|
.option('--log-level <level>', 'Log level: error, warn, info, debug (default: info)')
|
|
85
89
|
.option('--print-config', 'Print configuration and exit')
|
|
@@ -142,6 +146,9 @@ program
|
|
|
142
146
|
public: config.public,
|
|
143
147
|
readOnly: config.readOnly,
|
|
144
148
|
liveReload: config.liveReload,
|
|
149
|
+
mongo: config.mongo,
|
|
150
|
+
mongoUrl: config.mongoUrl,
|
|
151
|
+
mongoDatabase: config.mongoDatabase,
|
|
145
152
|
});
|
|
146
153
|
|
|
147
154
|
await server.listen({ port: config.port, host: config.host });
|
|
@@ -177,6 +184,7 @@ program
|
|
|
177
184
|
}
|
|
178
185
|
console.log(' Do not expose to the internet!');
|
|
179
186
|
}
|
|
187
|
+
if (config.mongo) console.log(` MongoDB: ${config.mongoUrl} (${config.mongoDatabase})`);
|
|
180
188
|
if (config.readOnly) console.log(' Read-only: enabled (PUT/DELETE/PATCH disabled)');
|
|
181
189
|
console.log('\n Press Ctrl+C to stop\n');
|
|
182
190
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "javascript-solid-server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.95",
|
|
4
4
|
"description": "A minimal, fast Solid server",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -51,6 +51,9 @@
|
|
|
51
51
|
"nostr"
|
|
52
52
|
],
|
|
53
53
|
"license": "AGPL-3.0-only",
|
|
54
|
+
"optionalDependencies": {
|
|
55
|
+
"mongodb": "^6.21.0"
|
|
56
|
+
},
|
|
54
57
|
"devDependencies": {
|
|
55
58
|
"autocannon": "^8.0.0"
|
|
56
59
|
}
|
package/src/auth/middleware.js
CHANGED
|
@@ -9,7 +9,32 @@ import { checkAccess, getRequiredMode } from '../wac/checker.js';
|
|
|
9
9
|
import { AccessMode } from '../wac/parser.js';
|
|
10
10
|
import * as storage from '../storage/filesystem.js';
|
|
11
11
|
import { getEffectiveUrlPath } from '../utils/url.js';
|
|
12
|
-
import { generateDatabrowserHtml, generateSolidosUiHtml } from '../mashlib/index.js';
|
|
12
|
+
import { generateDatabrowserHtml, generateModuleDatabrowserHtml, generateSolidosUiHtml } from '../mashlib/index.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build a resource URL for WAC checking, normalizing path-based pod access
|
|
16
|
+
* to subdomain form so URLs match ACL entries.
|
|
17
|
+
*
|
|
18
|
+
* In subdomain mode, ACLs reference subdomain URLs (e.g. https://alice.example.com/public/).
|
|
19
|
+
* Path-based access on the main domain (e.g. https://example.com/alice/public/) must be
|
|
20
|
+
* normalized to match.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} request - Fastify request
|
|
23
|
+
* @param {string} urlPath - URL path (e.g. /alice/public/file.ttl)
|
|
24
|
+
* @returns {string} Normalized resource URL
|
|
25
|
+
*/
|
|
26
|
+
function buildResourceUrl(request, urlPath) {
|
|
27
|
+
if (request.subdomainsEnabled && request.baseDomain &&
|
|
28
|
+
request.hostname === request.baseDomain && !request.podName) {
|
|
29
|
+
const pathMatch = urlPath.match(/^\/([^/]+)(\/.*)?$/);
|
|
30
|
+
if (pathMatch && !pathMatch[1].startsWith('.')) {
|
|
31
|
+
const podName = pathMatch[1];
|
|
32
|
+
const remainder = pathMatch[2] || '/';
|
|
33
|
+
return `${request.protocol}://${podName}.${request.baseDomain}${remainder}`;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return `${request.protocol}://${request.hostname}${urlPath}`;
|
|
37
|
+
}
|
|
13
38
|
|
|
14
39
|
/**
|
|
15
40
|
* Check if request is authorized
|
|
@@ -55,8 +80,8 @@ export async function authorize(request, reply, options = {}) {
|
|
|
55
80
|
const resourceExists = stats !== null;
|
|
56
81
|
const isContainer = stats?.isDirectory || urlPath.endsWith('/');
|
|
57
82
|
|
|
58
|
-
// Build resource URL
|
|
59
|
-
const resourceUrl =
|
|
83
|
+
// Build resource URL, normalizing path-based pod access to subdomain form for WAC
|
|
84
|
+
const resourceUrl = buildResourceUrl(request, urlPath);
|
|
60
85
|
|
|
61
86
|
// Get required access mode - use override if provided, otherwise derive from method
|
|
62
87
|
const requiredMode = options.requiredMode || getRequiredMode(method);
|
|
@@ -70,9 +95,9 @@ export async function authorize(request, reply, options = {}) {
|
|
|
70
95
|
// Check write permission on parent container
|
|
71
96
|
const parentPath = getParentPath(storagePath);
|
|
72
97
|
checkPath = parentPath;
|
|
73
|
-
// For URL, also need to get parent
|
|
98
|
+
// For URL, also need to get parent (normalized for subdomain WAC matching)
|
|
74
99
|
const parentUrlPath = getParentPath(urlPath);
|
|
75
|
-
checkUrl =
|
|
100
|
+
checkUrl = buildResourceUrl(request, parentUrlPath);
|
|
76
101
|
checkIsContainer = true;
|
|
77
102
|
}
|
|
78
103
|
|
|
@@ -123,10 +148,12 @@ export function handleUnauthorized(request, reply, isAuthenticated, wacAllow, au
|
|
|
123
148
|
// If mashlib is enabled, serve mashlib instead of static error page
|
|
124
149
|
// Mashlib has built-in login functionality via panes.runDataBrowser()
|
|
125
150
|
if (request.mashlibEnabled) {
|
|
126
|
-
// Use SolidOS UI if enabled,
|
|
151
|
+
// Use SolidOS UI if enabled, ES module if configured, otherwise classic mashlib
|
|
127
152
|
const html = request.solidosUiEnabled
|
|
128
153
|
? generateSolidosUiHtml()
|
|
129
|
-
:
|
|
154
|
+
: request.mashlibModule
|
|
155
|
+
? generateModuleDatabrowserHtml(request.mashlibModule)
|
|
156
|
+
: generateDatabrowserHtml(request.url, request.mashlibCdn ? request.mashlibVersion : null);
|
|
130
157
|
return reply.code(statusCode).type('text/html').send(html);
|
|
131
158
|
}
|
|
132
159
|
return reply.code(statusCode).type('text/html').send(getErrorPage(statusCode, isAuthenticated, request));
|
|
@@ -379,7 +406,7 @@ async function authorizeAclAccess(request, urlPath, method, webId, authError) {
|
|
|
379
406
|
// /foo/bar.acl protects /foo/bar (resource)
|
|
380
407
|
const protectedPath = urlPath.replace(/\.acl$/, '');
|
|
381
408
|
const isProtectedContainer = protectedPath.endsWith('/');
|
|
382
|
-
const protectedUrl =
|
|
409
|
+
const protectedUrl = buildResourceUrl(request, protectedPath);
|
|
383
410
|
|
|
384
411
|
// Get storage path for the protected resource
|
|
385
412
|
const storagePath = getEffectiveUrlPath(request).replace(/\.acl$/, '');
|
package/src/config.js
CHANGED
|
@@ -83,6 +83,11 @@ export const defaults = {
|
|
|
83
83
|
// Live reload - inject script to auto-refresh browser on file changes
|
|
84
84
|
liveReload: false,
|
|
85
85
|
|
|
86
|
+
// MongoDB-backed /db/ route
|
|
87
|
+
mongo: false,
|
|
88
|
+
mongoUrl: 'mongodb://localhost:27017',
|
|
89
|
+
mongoDatabase: 'solid',
|
|
90
|
+
|
|
86
91
|
// Logging
|
|
87
92
|
logger: true,
|
|
88
93
|
quiet: false,
|
|
@@ -133,6 +138,9 @@ const envMap = {
|
|
|
133
138
|
JSS_PUBLIC: 'public',
|
|
134
139
|
JSS_READ_ONLY: 'readOnly',
|
|
135
140
|
JSS_LIVE_RELOAD: 'liveReload',
|
|
141
|
+
JSS_MONGO: 'mongo',
|
|
142
|
+
JSS_MONGO_URL: 'mongoUrl',
|
|
143
|
+
JSS_MONGO_DATABASE: 'mongoDatabase',
|
|
136
144
|
};
|
|
137
145
|
|
|
138
146
|
/**
|
|
@@ -297,5 +305,6 @@ export function printConfig(config) {
|
|
|
297
305
|
console.log(` Subdomains: ${config.subdomains ? (config.baseDomain || 'enabled') : 'disabled'}`);
|
|
298
306
|
console.log(` Mashlib: ${config.mashlibModule ? `module (${config.mashlibModule})` : config.mashlibCdn ? `CDN v${config.mashlibVersion}` : config.mashlib ? 'local' : 'disabled'}`);
|
|
299
307
|
console.log(` SolidOS UI: ${config.solidosUi ? 'enabled' : 'disabled'}`);
|
|
308
|
+
if (config.mongo) console.log(` MongoDB: ${config.mongoUrl} (${config.mongoDatabase})`);
|
|
300
309
|
console.log('─'.repeat(40));
|
|
301
310
|
}
|
package/src/db/index.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MongoDB Database Route Plugin for JSS
|
|
3
|
+
*
|
|
4
|
+
* Adds /db/* routes backed by MongoDB.
|
|
5
|
+
* Documents are stored as JSON-LD and keyed by URI.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { connect, disconnect, findOne, upsertOne, deleteOne, listByPrefix } from './store.js';
|
|
9
|
+
import { getAllHeaders, getNotFoundHeaders } from '../ldp/headers.js';
|
|
10
|
+
import { generateContainerJsonLd, serializeJsonLd } from '../ldp/container.js';
|
|
11
|
+
import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from '../utils/conditional.js';
|
|
12
|
+
import { emitChange } from '../notifications/events.js';
|
|
13
|
+
import { getWebIdFromRequestAsync } from '../auth/token.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Database route Fastify plugin
|
|
17
|
+
* @param {FastifyInstance} fastify
|
|
18
|
+
* @param {object} options
|
|
19
|
+
*/
|
|
20
|
+
export async function dbPlugin(fastify, options) {
|
|
21
|
+
await connect({
|
|
22
|
+
url: options.mongoUrl,
|
|
23
|
+
database: options.mongoDatabase || 'solid'
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
fastify.addHook('onClose', async () => {
|
|
27
|
+
await disconnect();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Auth hook for /db/* routes
|
|
31
|
+
// WAC doesn't apply here — uses WebID-based ownership
|
|
32
|
+
fastify.addHook('preHandler', async (request, reply) => {
|
|
33
|
+
if (request.method === 'OPTIONS') return;
|
|
34
|
+
|
|
35
|
+
// Public mode — skip auth
|
|
36
|
+
if (request.config?.public) {
|
|
37
|
+
request.webId = null;
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const { webId } = await getWebIdFromRequestAsync(request);
|
|
42
|
+
request.webId = webId;
|
|
43
|
+
|
|
44
|
+
// Read is public
|
|
45
|
+
if (request.method === 'GET' || request.method === 'HEAD') return;
|
|
46
|
+
|
|
47
|
+
// Write requires authentication
|
|
48
|
+
if (!webId) {
|
|
49
|
+
return reply.code(401).send({ error: 'Unauthorized', message: 'Authentication required' });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Ownership check: only pod owner can write to /db/{podName}/...
|
|
53
|
+
const urlPath = request.url.split('?')[0];
|
|
54
|
+
const relative = urlPath.replace(/^\/db\//, '');
|
|
55
|
+
const podName = relative.split('/')[0];
|
|
56
|
+
if (podName) {
|
|
57
|
+
// Build expected WebID for both path and subdomain modes
|
|
58
|
+
const expectedWebId = request.subdomainsEnabled && request.baseDomain
|
|
59
|
+
? `${request.protocol}://${podName}.${request.baseDomain}/profile/card#me`
|
|
60
|
+
: `${request.protocol}://${request.hostname}/${podName}/profile/card#me`;
|
|
61
|
+
if (webId !== expectedWebId) {
|
|
62
|
+
return reply.code(403).send({ error: 'Forbidden', message: 'You can only write to your own /db/ space' });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Routes
|
|
68
|
+
fastify.get('/db', handleDbGet);
|
|
69
|
+
fastify.get('/db/*', handleDbGet);
|
|
70
|
+
fastify.head('/db', handleDbHead);
|
|
71
|
+
fastify.head('/db/*', handleDbHead);
|
|
72
|
+
fastify.put('/db/*', handleDbPut);
|
|
73
|
+
fastify.delete('/db/*', handleDbDelete);
|
|
74
|
+
fastify.options('/db', handleDbOptions);
|
|
75
|
+
fastify.options('/db/*', handleDbOptions);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build the full resource URL for a /db/ request
|
|
80
|
+
*/
|
|
81
|
+
function getResourceUrl(request) {
|
|
82
|
+
const urlPath = request.url.split('?')[0];
|
|
83
|
+
return `${request.protocol}://${request.hostname}${urlPath}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* GET /db/* — read resource or container listing
|
|
88
|
+
*/
|
|
89
|
+
async function handleDbGet(request, reply) {
|
|
90
|
+
const urlPath = request.url.split('?')[0];
|
|
91
|
+
const resourceUrl = getResourceUrl(request);
|
|
92
|
+
const origin = request.headers.origin;
|
|
93
|
+
const connegEnabled = request.connegEnabled || false;
|
|
94
|
+
|
|
95
|
+
// Container request (treat /db as root container)
|
|
96
|
+
if (urlPath === '/db' || urlPath.endsWith('/')) {
|
|
97
|
+
const entries = await listByPrefix(resourceUrl);
|
|
98
|
+
const jsonLd = generateContainerJsonLd(resourceUrl, entries);
|
|
99
|
+
const content = serializeJsonLd(jsonLd);
|
|
100
|
+
|
|
101
|
+
const etag = `"container-${entries.length}"`;
|
|
102
|
+
|
|
103
|
+
const ifNoneMatch = request.headers['if-none-match'];
|
|
104
|
+
if (ifNoneMatch) {
|
|
105
|
+
const check = checkIfNoneMatchForGet(ifNoneMatch, etag);
|
|
106
|
+
if (!check.ok && check.notModified) {
|
|
107
|
+
return reply.code(304).send();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const headers = getAllHeaders({
|
|
112
|
+
isContainer: true, etag,
|
|
113
|
+
contentType: 'application/ld+json',
|
|
114
|
+
origin, resourceUrl, connegEnabled
|
|
115
|
+
});
|
|
116
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
117
|
+
return reply.send(content);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Resource request
|
|
121
|
+
const doc = await findOne(resourceUrl);
|
|
122
|
+
if (!doc) {
|
|
123
|
+
const headers = getNotFoundHeaders({ resourceUrl, origin, connegEnabled });
|
|
124
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
125
|
+
return reply.code(404).send({ error: 'Not Found' });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const ifNoneMatch = request.headers['if-none-match'];
|
|
129
|
+
if (ifNoneMatch) {
|
|
130
|
+
const check = checkIfNoneMatchForGet(ifNoneMatch, doc.etag);
|
|
131
|
+
if (!check.ok && check.notModified) {
|
|
132
|
+
return reply.code(304).send();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const headers = getAllHeaders({
|
|
137
|
+
isContainer: false, etag: doc.etag,
|
|
138
|
+
contentType: doc.contentType,
|
|
139
|
+
origin, resourceUrl, connegEnabled
|
|
140
|
+
});
|
|
141
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
142
|
+
return reply.send(JSON.stringify(doc.data, null, 2));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* HEAD /db/* — same as GET but no body
|
|
147
|
+
*/
|
|
148
|
+
async function handleDbHead(request, reply) {
|
|
149
|
+
const urlPath = request.url.split('?')[0];
|
|
150
|
+
const resourceUrl = getResourceUrl(request);
|
|
151
|
+
const origin = request.headers.origin;
|
|
152
|
+
const connegEnabled = request.connegEnabled || false;
|
|
153
|
+
|
|
154
|
+
if (urlPath === '/db' || urlPath.endsWith('/')) {
|
|
155
|
+
const entries = await listByPrefix(resourceUrl);
|
|
156
|
+
const etag = `"container-${entries.length}"`;
|
|
157
|
+
const headers = getAllHeaders({
|
|
158
|
+
isContainer: true, etag,
|
|
159
|
+
contentType: 'application/ld+json',
|
|
160
|
+
origin, resourceUrl, connegEnabled
|
|
161
|
+
});
|
|
162
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
163
|
+
return reply.code(200).send();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const doc = await findOne(resourceUrl);
|
|
167
|
+
if (!doc) {
|
|
168
|
+
const headers = getNotFoundHeaders({ resourceUrl, origin, connegEnabled });
|
|
169
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
170
|
+
return reply.code(404).send();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const headers = getAllHeaders({
|
|
174
|
+
isContainer: false, etag: doc.etag,
|
|
175
|
+
contentType: doc.contentType,
|
|
176
|
+
origin, resourceUrl, connegEnabled
|
|
177
|
+
});
|
|
178
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
179
|
+
return reply.code(200).send();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* PUT /db/* — create or update resource
|
|
184
|
+
*/
|
|
185
|
+
async function handleDbPut(request, reply) {
|
|
186
|
+
if (request.config?.readOnly) {
|
|
187
|
+
return reply.code(405).send({ error: 'Method Not Allowed', message: 'Server is in read-only mode' });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const urlPath = request.url.split('?')[0];
|
|
191
|
+
const resourceUrl = getResourceUrl(request);
|
|
192
|
+
|
|
193
|
+
if (urlPath.endsWith('/')) {
|
|
194
|
+
return reply.code(409).send({ error: 'Conflict', message: 'Cannot PUT to a container' });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Only accept JSON content types — stored as JSON-LD
|
|
198
|
+
const incomingType = (request.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
|
|
199
|
+
if (incomingType && incomingType !== 'application/ld+json' && incomingType !== 'application/json') {
|
|
200
|
+
return reply.code(415).send({ error: 'Unsupported Media Type', message: 'Only application/ld+json and application/json are accepted' });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Parse body
|
|
204
|
+
let data;
|
|
205
|
+
let body = request.body;
|
|
206
|
+
if (Buffer.isBuffer(body)) body = body.toString();
|
|
207
|
+
if (typeof body === 'string') {
|
|
208
|
+
try { data = JSON.parse(body); }
|
|
209
|
+
catch { return reply.code(400).send({ error: 'Bad Request', message: 'Invalid JSON' }); }
|
|
210
|
+
} else if (typeof body === 'object' && body !== null) {
|
|
211
|
+
data = body;
|
|
212
|
+
} else {
|
|
213
|
+
return reply.code(400).send({ error: 'Bad Request', message: 'Request body required' });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Conditional headers
|
|
217
|
+
const existing = await findOne(resourceUrl);
|
|
218
|
+
const currentEtag = existing?.etag || null;
|
|
219
|
+
|
|
220
|
+
const ifMatch = request.headers['if-match'];
|
|
221
|
+
if (ifMatch) {
|
|
222
|
+
const check = checkIfMatch(ifMatch, currentEtag);
|
|
223
|
+
if (!check.ok) return reply.code(check.status).send({ error: check.error });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const ifNoneMatch = request.headers['if-none-match'];
|
|
227
|
+
if (ifNoneMatch) {
|
|
228
|
+
const check = checkIfNoneMatchForWrite(ifNoneMatch, currentEtag);
|
|
229
|
+
if (!check.ok) return reply.code(check.status).send({ error: check.error });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const { created, etag } = await upsertOne(resourceUrl, data, 'application/ld+json');
|
|
233
|
+
|
|
234
|
+
const origin = request.headers.origin;
|
|
235
|
+
const headers = getAllHeaders({
|
|
236
|
+
isContainer: false, etag,
|
|
237
|
+
origin, resourceUrl,
|
|
238
|
+
connegEnabled: request.connegEnabled || false
|
|
239
|
+
});
|
|
240
|
+
headers['Location'] = resourceUrl;
|
|
241
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
242
|
+
|
|
243
|
+
if (request.notificationsEnabled) {
|
|
244
|
+
emitChange(resourceUrl);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return reply.code(created ? 201 : 204).send();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* DELETE /db/* — delete resource
|
|
252
|
+
*/
|
|
253
|
+
async function handleDbDelete(request, reply) {
|
|
254
|
+
if (request.config?.readOnly) {
|
|
255
|
+
return reply.code(405).send({ error: 'Method Not Allowed', message: 'Server is in read-only mode' });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const resourceUrl = getResourceUrl(request);
|
|
259
|
+
const origin = request.headers.origin;
|
|
260
|
+
|
|
261
|
+
const existing = await findOne(resourceUrl);
|
|
262
|
+
if (!existing) {
|
|
263
|
+
const headers = getNotFoundHeaders({ resourceUrl, origin, connegEnabled: request.connegEnabled || false });
|
|
264
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
265
|
+
return reply.code(404).send({ error: 'Not Found' });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const ifMatch = request.headers['if-match'];
|
|
269
|
+
if (ifMatch) {
|
|
270
|
+
const check = checkIfMatch(ifMatch, existing.etag);
|
|
271
|
+
if (!check.ok) return reply.code(check.status).send({ error: check.error });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
await deleteOne(resourceUrl);
|
|
275
|
+
|
|
276
|
+
const headers = getAllHeaders({
|
|
277
|
+
isContainer: false, origin, resourceUrl
|
|
278
|
+
});
|
|
279
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
280
|
+
|
|
281
|
+
if (request.notificationsEnabled) {
|
|
282
|
+
emitChange(resourceUrl);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return reply.code(204).send();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* OPTIONS /db/* — return allowed methods
|
|
290
|
+
*/
|
|
291
|
+
async function handleDbOptions(request, reply) {
|
|
292
|
+
const resourceUrl = getResourceUrl(request);
|
|
293
|
+
const origin = request.headers.origin;
|
|
294
|
+
const headers = getAllHeaders({
|
|
295
|
+
isContainer: request.url.split('?')[0] === '/db' || request.url.split('?')[0].endsWith('/'),
|
|
296
|
+
origin, resourceUrl,
|
|
297
|
+
connegEnabled: request.connegEnabled || false
|
|
298
|
+
});
|
|
299
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
300
|
+
return reply.code(204).send();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export default dbPlugin;
|
package/src/db/store.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MongoDB Storage Layer for /db/ route
|
|
3
|
+
*
|
|
4
|
+
* Optional dependency — dynamically imports 'mongodb'.
|
|
5
|
+
* Provides document-level CRUD keyed by URI.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import crypto from 'crypto';
|
|
9
|
+
|
|
10
|
+
let client = null;
|
|
11
|
+
let db = null;
|
|
12
|
+
let col = null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Connect to MongoDB
|
|
16
|
+
* @param {object} options
|
|
17
|
+
* @param {string} options.url - MongoDB connection URL
|
|
18
|
+
* @param {string} options.database - Database name
|
|
19
|
+
* @returns {Promise<void>}
|
|
20
|
+
*/
|
|
21
|
+
export async function connect({ url, database }) {
|
|
22
|
+
let MongoClient;
|
|
23
|
+
try {
|
|
24
|
+
({ MongoClient } = await import('mongodb'));
|
|
25
|
+
} catch {
|
|
26
|
+
throw new Error(
|
|
27
|
+
'MongoDB driver not installed. Install it with: npm install mongodb\n' +
|
|
28
|
+
'The mongodb package is optional and only needed when using --mongo.'
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
client = new MongoClient(url);
|
|
33
|
+
await client.connect();
|
|
34
|
+
db = client.db(database);
|
|
35
|
+
col = db.collection('resources');
|
|
36
|
+
|
|
37
|
+
// Create unique index on URI for fast lookups
|
|
38
|
+
await col.createIndex({ uri: 1 }, { unique: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Disconnect from MongoDB
|
|
43
|
+
* @returns {Promise<void>}
|
|
44
|
+
*/
|
|
45
|
+
export async function disconnect() {
|
|
46
|
+
if (client) {
|
|
47
|
+
await client.close();
|
|
48
|
+
client = null;
|
|
49
|
+
db = null;
|
|
50
|
+
col = null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Generate ETag from data
|
|
56
|
+
*/
|
|
57
|
+
function generateEtag(data) {
|
|
58
|
+
const hash = crypto.createHash('md5').update(JSON.stringify(data)).digest('hex');
|
|
59
|
+
return `"${hash}"`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Find a single document by URI
|
|
64
|
+
* @param {string} uri - The resource URI
|
|
65
|
+
* @returns {Promise<{data: object, contentType: string, etag: string, modified: Date} | null>}
|
|
66
|
+
*/
|
|
67
|
+
export async function findOne(uri) {
|
|
68
|
+
const doc = await col.findOne({ uri });
|
|
69
|
+
if (!doc) return null;
|
|
70
|
+
return {
|
|
71
|
+
data: doc.data,
|
|
72
|
+
contentType: doc.contentType,
|
|
73
|
+
etag: doc.etag,
|
|
74
|
+
modified: doc.modified
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Upsert a document by URI
|
|
80
|
+
* @param {string} uri - The resource URI
|
|
81
|
+
* @param {object} data - The document data (JSON-LD)
|
|
82
|
+
* @param {string} contentType - The content type
|
|
83
|
+
* @returns {Promise<{created: boolean, etag: string}>}
|
|
84
|
+
*/
|
|
85
|
+
export async function upsertOne(uri, data, contentType) {
|
|
86
|
+
const etag = generateEtag(data);
|
|
87
|
+
const now = new Date();
|
|
88
|
+
|
|
89
|
+
const result = await col.updateOne(
|
|
90
|
+
{ uri },
|
|
91
|
+
{
|
|
92
|
+
$set: { data, contentType, etag, modified: now },
|
|
93
|
+
$setOnInsert: { created: now }
|
|
94
|
+
},
|
|
95
|
+
{ upsert: true }
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
created: result.upsertedCount > 0,
|
|
100
|
+
etag
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Delete a document by URI
|
|
106
|
+
* @param {string} uri - The resource URI
|
|
107
|
+
* @returns {Promise<boolean>} - true if deleted, false if not found
|
|
108
|
+
*/
|
|
109
|
+
export async function deleteOne(uri) {
|
|
110
|
+
const result = await col.deleteOne({ uri });
|
|
111
|
+
return result.deletedCount > 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Escape special regex characters in a string
|
|
116
|
+
*/
|
|
117
|
+
function escapeRegex(str) {
|
|
118
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* List immediate children whose URI starts with a prefix (for container listings)
|
|
123
|
+
* @param {string} prefix - URI prefix (must end with '/')
|
|
124
|
+
* @returns {Promise<Array<{name: string, isDirectory: boolean}>>}
|
|
125
|
+
*/
|
|
126
|
+
export async function listByPrefix(prefix) {
|
|
127
|
+
const regex = new RegExp('^' + escapeRegex(prefix));
|
|
128
|
+
const docs = await col.find({ uri: regex }, { projection: { uri: 1 } }).toArray();
|
|
129
|
+
|
|
130
|
+
const children = new Map();
|
|
131
|
+
for (const doc of docs) {
|
|
132
|
+
const remainder = doc.uri.slice(prefix.length);
|
|
133
|
+
if (!remainder) continue;
|
|
134
|
+
const slashIndex = remainder.indexOf('/');
|
|
135
|
+
if (slashIndex === -1) {
|
|
136
|
+
// Direct child resource
|
|
137
|
+
children.set(remainder, false);
|
|
138
|
+
} else {
|
|
139
|
+
// Nested under a sub-container
|
|
140
|
+
const containerName = remainder.slice(0, slashIndex);
|
|
141
|
+
children.set(containerName, true);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return Array.from(children.entries()).map(([name, isDirectory]) => ({ name, isDirectory }));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Check if MongoDB is connected
|
|
150
|
+
* @returns {boolean}
|
|
151
|
+
*/
|
|
152
|
+
export function isConnected() {
|
|
153
|
+
return client !== null;
|
|
154
|
+
}
|
package/src/idp/interactions.js
CHANGED
|
@@ -140,9 +140,9 @@ export async function handleLogin(request, reply, provider) {
|
|
|
140
140
|
// Show passkey registration prompt before completing login
|
|
141
141
|
// Store the pending login in the interaction
|
|
142
142
|
interaction.result = {
|
|
143
|
+
passkeyPromptPending: true,
|
|
143
144
|
login: { accountId: account.id, remember: true }
|
|
144
145
|
};
|
|
145
|
-
interaction.passkeyPromptPending = true;
|
|
146
146
|
await interaction.save(interaction.exp - Math.floor(Date.now() / 1000));
|
|
147
147
|
return reply.type('text/html').send(passkeyPromptPage(uid, account.id));
|
|
148
148
|
}
|
|
@@ -473,7 +473,7 @@ export async function handlePasskeyComplete(request, reply, provider) {
|
|
|
473
473
|
|
|
474
474
|
// If this is a post-login passkey registration flow, validate accountId matches
|
|
475
475
|
// the already-authenticated user to prevent account takeover
|
|
476
|
-
if (interaction.passkeyPromptPending && interaction.result?.login?.accountId) {
|
|
476
|
+
if (interaction.result?.passkeyPromptPending && interaction.result?.login?.accountId) {
|
|
477
477
|
if (interaction.result.login.accountId !== accountId) {
|
|
478
478
|
request.log.warn({ expected: interaction.result.login.accountId, provided: accountId }, 'AccountId mismatch in passkey complete');
|
|
479
479
|
return reply.code(403).type('text/html').send(errorPage('Access denied', 'Account mismatch.'));
|
|
@@ -520,7 +520,7 @@ export async function handlePasskeySkip(request, reply, provider) {
|
|
|
520
520
|
}
|
|
521
521
|
|
|
522
522
|
// Validate the interaction is in the passkey prompt state
|
|
523
|
-
if (!interaction.passkeyPromptPending) {
|
|
523
|
+
if (!interaction.result?.passkeyPromptPending) {
|
|
524
524
|
return reply.code(400).type('text/html').send(errorPage('Invalid state', 'Not in passkey prompt flow.'));
|
|
525
525
|
}
|
|
526
526
|
|
package/src/server.js
CHANGED
|
@@ -15,6 +15,7 @@ import { isGitRequest, isGitWriteOperation, handleGit } from './handlers/git.js'
|
|
|
15
15
|
import { AccessMode } from './wac/parser.js';
|
|
16
16
|
import { registerNostrRelay } from './nostr/relay.js';
|
|
17
17
|
import { activityPubPlugin, getActorHandler } from './ap/index.js';
|
|
18
|
+
import { dbPlugin } from './db/index.js';
|
|
18
19
|
|
|
19
20
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
21
|
|
|
@@ -84,6 +85,10 @@ export function createServer(options = {}) {
|
|
|
84
85
|
const webidTlsEnabled = options.webidTls ?? false;
|
|
85
86
|
// Live reload - injects script to auto-refresh browser on file changes
|
|
86
87
|
const liveReloadEnabled = options.liveReload ?? false;
|
|
88
|
+
// MongoDB-backed /db/ route is OFF by default
|
|
89
|
+
const mongoEnabled = options.mongo ?? false;
|
|
90
|
+
const mongoUrl = options.mongoUrl ?? 'mongodb://localhost:27017';
|
|
91
|
+
const mongoDatabase = options.mongoDatabase ?? 'solid';
|
|
87
92
|
|
|
88
93
|
// Set data root via environment variable if provided
|
|
89
94
|
if (options.root) {
|
|
@@ -229,6 +234,11 @@ export function createServer(options = {}) {
|
|
|
229
234
|
});
|
|
230
235
|
}
|
|
231
236
|
|
|
237
|
+
// Register MongoDB /db/ route if enabled
|
|
238
|
+
if (mongoEnabled) {
|
|
239
|
+
fastify.register(dbPlugin, { mongoUrl, mongoDatabase });
|
|
240
|
+
}
|
|
241
|
+
|
|
232
242
|
// Register rate limiting plugin
|
|
233
243
|
// Protects against brute force attacks and resource exhaustion
|
|
234
244
|
fastify.register(rateLimit, {
|
|
@@ -358,6 +368,7 @@ export function createServer(options = {}) {
|
|
|
358
368
|
(gitEnabled && isGitRequest(request.url)) ||
|
|
359
369
|
(activitypubEnabled && apPaths.some(p => request.url === p || request.url.startsWith(p + '?'))) ||
|
|
360
370
|
isProfileAP ||
|
|
371
|
+
(mongoEnabled && (request.url === '/db' || request.url.startsWith('/db/'))) ||
|
|
361
372
|
mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) {
|
|
362
373
|
return;
|
|
363
374
|
}
|
package/test/auth.test.js
CHANGED
|
@@ -149,6 +149,69 @@ describe('Authentication', () => {
|
|
|
149
149
|
const res = await request('/inboxread/inbox/');
|
|
150
150
|
assertStatus(res, 401);
|
|
151
151
|
});
|
|
152
|
+
|
|
153
|
+
it('should allow any authenticated user with acl:AuthenticatedAgent', async () => {
|
|
154
|
+
await createTestPod('authuser1');
|
|
155
|
+
await createTestPod('authuser2');
|
|
156
|
+
|
|
157
|
+
// Create a test resource with acl:AuthenticatedAgent ACL
|
|
158
|
+
const baseUrl = getBaseUrl();
|
|
159
|
+
|
|
160
|
+
// First, create a resource (this will create parent containers)
|
|
161
|
+
await request('/authuser1/authenticated-only/test.txt', {
|
|
162
|
+
method: 'PUT',
|
|
163
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
164
|
+
body: 'authenticated content',
|
|
165
|
+
auth: 'authuser1'
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Now create a custom ACL for the container with acl:AuthenticatedAgent
|
|
169
|
+
// Include owner with Control so they can manage the ACL
|
|
170
|
+
const acl = {
|
|
171
|
+
'@context': { 'acl': 'http://www.w3.org/ns/auth/acl#' },
|
|
172
|
+
'@graph': [
|
|
173
|
+
{
|
|
174
|
+
'@id': '#owner',
|
|
175
|
+
'@type': 'acl:Authorization',
|
|
176
|
+
'acl:agent': { '@id': `${baseUrl}/authuser1/profile/card#me` },
|
|
177
|
+
'acl:accessTo': { '@id': `${baseUrl}/authuser1/authenticated-only/` },
|
|
178
|
+
'acl:default': { '@id': `${baseUrl}/authuser1/authenticated-only/` },
|
|
179
|
+
'acl:mode': [
|
|
180
|
+
{ '@id': 'acl:Read' },
|
|
181
|
+
{ '@id': 'acl:Write' },
|
|
182
|
+
{ '@id': 'acl:Control' }
|
|
183
|
+
]
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
'@id': '#authenticated',
|
|
187
|
+
'@type': 'acl:Authorization',
|
|
188
|
+
'acl:agentClass': { '@id': 'acl:AuthenticatedAgent' },
|
|
189
|
+
'acl:accessTo': { '@id': `${baseUrl}/authuser1/authenticated-only/` },
|
|
190
|
+
'acl:default': { '@id': `${baseUrl}/authuser1/authenticated-only/` },
|
|
191
|
+
'acl:mode': [{ '@id': 'acl:Read' }]
|
|
192
|
+
}
|
|
193
|
+
]
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
await request('/authuser1/authenticated-only/.acl', {
|
|
197
|
+
method: 'PUT',
|
|
198
|
+
headers: { 'Content-Type': 'application/json' },
|
|
199
|
+
body: JSON.stringify(acl),
|
|
200
|
+
auth: 'authuser1'
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Test 1: Anonymous access should be denied
|
|
204
|
+
const res1 = await request('/authuser1/authenticated-only/test.txt');
|
|
205
|
+
assertStatus(res1, 401);
|
|
206
|
+
|
|
207
|
+
// Test 2: Owner should have access
|
|
208
|
+
const res2 = await request('/authuser1/authenticated-only/test.txt', { auth: 'authuser1' });
|
|
209
|
+
assertStatus(res2, 200);
|
|
210
|
+
|
|
211
|
+
// Test 3: Different authenticated user should also have access (key test!)
|
|
212
|
+
const res3 = await request('/authuser1/authenticated-only/test.txt', { auth: 'authuser2' });
|
|
213
|
+
assertStatus(res3, 200);
|
|
214
|
+
});
|
|
152
215
|
});
|
|
153
216
|
|
|
154
217
|
describe('WAC-Allow Header', () => {
|