javascript-solid-server 0.0.5 → 0.0.7
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 +10 -8
- package/src/auth/solid-oidc.js +260 -0
- package/src/auth/token.js +43 -5
- package/src/handlers/resource.js +97 -0
- package/src/patch/n3-patch.js +522 -0
- package/src/server.js +4 -3
- package/test/patch.test.js +295 -0
- package/test/solid-oidc.test.js +211 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "javascript-solid-server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.7",
|
|
4
4
|
"description": "A minimal, fast Solid server",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -11,11 +11,17 @@
|
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"fastify": "^4.25.2",
|
|
14
|
-
"fs-extra": "^11.2.0"
|
|
14
|
+
"fs-extra": "^11.2.0",
|
|
15
|
+
"jose": "^6.1.3"
|
|
15
16
|
},
|
|
16
17
|
"engines": {
|
|
17
18
|
"node": ">=18.0.0"
|
|
18
19
|
},
|
|
19
|
-
"keywords": [
|
|
20
|
+
"keywords": [
|
|
21
|
+
"solid",
|
|
22
|
+
"ldp",
|
|
23
|
+
"linked-data",
|
|
24
|
+
"decentralized"
|
|
25
|
+
],
|
|
20
26
|
"license": "MIT"
|
|
21
27
|
}
|
package/src/auth/middleware.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Authorization middleware
|
|
3
3
|
* Combines authentication (token verification) with WAC checking
|
|
4
|
+
* Supports both simple Bearer tokens and Solid-OIDC DPoP tokens
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
|
-
import {
|
|
7
|
+
import { getWebIdFromRequestAsync } from './token.js';
|
|
7
8
|
import { checkAccess, getRequiredMode } from '../wac/checker.js';
|
|
8
9
|
import * as storage from '../storage/filesystem.js';
|
|
9
10
|
|
|
@@ -11,7 +12,7 @@ import * as storage from '../storage/filesystem.js';
|
|
|
11
12
|
* Check if request is authorized
|
|
12
13
|
* @param {object} request - Fastify request
|
|
13
14
|
* @param {object} reply - Fastify reply
|
|
14
|
-
* @returns {Promise<{authorized: boolean, webId: string|null, wacAllow: string}>}
|
|
15
|
+
* @returns {Promise<{authorized: boolean, webId: string|null, wacAllow: string, authError: string|null}>}
|
|
15
16
|
*/
|
|
16
17
|
export async function authorize(request, reply) {
|
|
17
18
|
const urlPath = request.url.split('?')[0];
|
|
@@ -20,11 +21,11 @@ export async function authorize(request, reply) {
|
|
|
20
21
|
// Skip auth for .acl files (they need special handling)
|
|
21
22
|
// and for OPTIONS (CORS preflight)
|
|
22
23
|
if (urlPath.endsWith('.acl') || method === 'OPTIONS') {
|
|
23
|
-
return { authorized: true, webId: null, wacAllow: 'user="read write append control", public="read write append"' };
|
|
24
|
+
return { authorized: true, webId: null, wacAllow: 'user="read write append control", public="read write append"', authError: null };
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
// Get WebID from token (
|
|
27
|
-
const webId =
|
|
27
|
+
// Get WebID from token (supports both simple and Solid-OIDC tokens)
|
|
28
|
+
const { webId, error: authError } = await getWebIdFromRequestAsync(request);
|
|
28
29
|
|
|
29
30
|
// Get resource info
|
|
30
31
|
const stats = await storage.stat(urlPath);
|
|
@@ -59,7 +60,7 @@ export async function authorize(request, reply) {
|
|
|
59
60
|
requiredMode
|
|
60
61
|
});
|
|
61
62
|
|
|
62
|
-
return { authorized: allowed, webId, wacAllow };
|
|
63
|
+
return { authorized: allowed, webId, wacAllow, authError };
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
/**
|
|
@@ -77,15 +78,16 @@ function getParentPath(path) {
|
|
|
77
78
|
* @param {object} reply - Fastify reply
|
|
78
79
|
* @param {boolean} isAuthenticated - Whether user is authenticated
|
|
79
80
|
* @param {string} wacAllow - WAC-Allow header value
|
|
81
|
+
* @param {string|null} authError - Authentication error message (for DPoP failures)
|
|
80
82
|
*/
|
|
81
|
-
export function handleUnauthorized(reply, isAuthenticated, wacAllow) {
|
|
83
|
+
export function handleUnauthorized(reply, isAuthenticated, wacAllow, authError = null) {
|
|
82
84
|
reply.header('WAC-Allow', wacAllow);
|
|
83
85
|
|
|
84
86
|
if (!isAuthenticated) {
|
|
85
87
|
// Not authenticated - return 401
|
|
86
88
|
return reply.code(401).send({
|
|
87
89
|
error: 'Unauthorized',
|
|
88
|
-
message: 'Authentication required'
|
|
90
|
+
message: authError || 'Authentication required'
|
|
89
91
|
});
|
|
90
92
|
} else {
|
|
91
93
|
// Authenticated but not authorized - return 403
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Solid-OIDC Resource Server
|
|
3
|
+
* Verifies DPoP-bound access tokens from external Identity Providers
|
|
4
|
+
*
|
|
5
|
+
* Flow:
|
|
6
|
+
* 1. User authenticates at external IdP (e.g., solidcommunity.net)
|
|
7
|
+
* 2. User gets DPoP-bound access token
|
|
8
|
+
* 3. User sends request with Authorization: DPoP <token> and DPoP: <proof>
|
|
9
|
+
* 4. We verify the token and DPoP proof
|
|
10
|
+
* 5. Extract WebID from token
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as jose from 'jose';
|
|
14
|
+
|
|
15
|
+
// Cache for OIDC configurations and JWKS
|
|
16
|
+
const oidcConfigCache = new Map();
|
|
17
|
+
const jwksCache = new Map();
|
|
18
|
+
|
|
19
|
+
// Cache TTL (15 minutes)
|
|
20
|
+
const CACHE_TTL = 15 * 60 * 1000;
|
|
21
|
+
|
|
22
|
+
// DPoP proof max age (5 minutes)
|
|
23
|
+
const DPOP_MAX_AGE = 5 * 60;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Verify a Solid-OIDC request and extract WebID
|
|
27
|
+
* @param {object} request - Fastify request object
|
|
28
|
+
* @returns {Promise<{webId: string|null, error: string|null}>}
|
|
29
|
+
*/
|
|
30
|
+
export async function verifySolidOidc(request) {
|
|
31
|
+
const authHeader = request.headers.authorization;
|
|
32
|
+
const dpopHeader = request.headers.dpop;
|
|
33
|
+
|
|
34
|
+
// Check for DPoP authorization scheme
|
|
35
|
+
if (!authHeader || !authHeader.startsWith('DPoP ')) {
|
|
36
|
+
return { webId: null, error: null }; // Not a Solid-OIDC request
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!dpopHeader) {
|
|
40
|
+
return { webId: null, error: 'Missing DPoP proof header' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const accessToken = authHeader.slice(5); // Remove 'DPoP ' prefix
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
// Step 1: Decode access token (without verification) to get issuer
|
|
47
|
+
const tokenPayload = jose.decodeJwt(accessToken);
|
|
48
|
+
const issuer = tokenPayload.iss;
|
|
49
|
+
|
|
50
|
+
if (!issuer) {
|
|
51
|
+
return { webId: null, error: 'Access token missing issuer' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Step 2: Verify DPoP proof
|
|
55
|
+
const dpopResult = await verifyDpopProof(dpopHeader, request, accessToken);
|
|
56
|
+
if (dpopResult.error) {
|
|
57
|
+
return { webId: null, error: dpopResult.error };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Step 3: Fetch JWKS and verify access token
|
|
61
|
+
const jwks = await getJwks(issuer);
|
|
62
|
+
const { payload } = await jose.jwtVerify(accessToken, jwks, {
|
|
63
|
+
issuer,
|
|
64
|
+
clockTolerance: 30 // 30 seconds clock skew tolerance
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Step 4: Verify DPoP binding (cnf.jkt must match DPoP key thumbprint)
|
|
68
|
+
if (payload.cnf?.jkt) {
|
|
69
|
+
if (payload.cnf.jkt !== dpopResult.thumbprint) {
|
|
70
|
+
return { webId: null, error: 'DPoP key does not match token binding' };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Step 5: Extract WebID
|
|
75
|
+
const webId = payload.webid || payload.sub;
|
|
76
|
+
if (!webId) {
|
|
77
|
+
return { webId: null, error: 'Token missing WebID claim' };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Validate WebID is a valid URL
|
|
81
|
+
try {
|
|
82
|
+
new URL(webId);
|
|
83
|
+
} catch {
|
|
84
|
+
return { webId: null, error: 'Invalid WebID URL' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { webId, error: null };
|
|
88
|
+
|
|
89
|
+
} catch (err) {
|
|
90
|
+
// Handle specific JWT errors
|
|
91
|
+
if (err.code === 'ERR_JWT_EXPIRED') {
|
|
92
|
+
return { webId: null, error: 'Access token expired' };
|
|
93
|
+
}
|
|
94
|
+
if (err.code === 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') {
|
|
95
|
+
return { webId: null, error: 'Invalid token signature' };
|
|
96
|
+
}
|
|
97
|
+
if (err.code === 'ERR_JWKS_NO_MATCHING_KEY') {
|
|
98
|
+
return { webId: null, error: 'No matching key found in JWKS' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
console.error('Solid-OIDC verification error:', err.message);
|
|
102
|
+
return { webId: null, error: 'Token verification failed' };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Verify DPoP proof JWT
|
|
108
|
+
* @param {string} dpopProof - The DPoP proof JWT
|
|
109
|
+
* @param {object} request - Fastify request
|
|
110
|
+
* @param {string} accessToken - The access token (for ath claim verification)
|
|
111
|
+
* @returns {Promise<{thumbprint: string|null, error: string|null}>}
|
|
112
|
+
*/
|
|
113
|
+
async function verifyDpopProof(dpopProof, request, accessToken) {
|
|
114
|
+
try {
|
|
115
|
+
// Decode header to get the public key
|
|
116
|
+
const protectedHeader = jose.decodeProtectedHeader(dpopProof);
|
|
117
|
+
|
|
118
|
+
if (protectedHeader.typ !== 'dpop+jwt') {
|
|
119
|
+
return { thumbprint: null, error: 'Invalid DPoP proof type' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!protectedHeader.jwk) {
|
|
123
|
+
return { thumbprint: null, error: 'DPoP proof missing jwk header' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Import the public key from the JWK in the header
|
|
127
|
+
const publicKey = await jose.importJWK(protectedHeader.jwk, protectedHeader.alg);
|
|
128
|
+
|
|
129
|
+
// Verify the DPoP proof signature
|
|
130
|
+
const { payload } = await jose.jwtVerify(dpopProof, publicKey, {
|
|
131
|
+
clockTolerance: 30
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Verify required claims
|
|
135
|
+
// htm: HTTP method
|
|
136
|
+
const expectedMethod = request.method.toUpperCase();
|
|
137
|
+
if (payload.htm !== expectedMethod) {
|
|
138
|
+
return { thumbprint: null, error: `DPoP htm mismatch: expected ${expectedMethod}` };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// htu: HTTP URI (without query string and fragment)
|
|
142
|
+
const requestUrl = `${request.protocol}://${request.hostname}${request.url.split('?')[0]}`;
|
|
143
|
+
// Normalize both URLs for comparison
|
|
144
|
+
const payloadHtu = payload.htu?.replace(/\/$/, '');
|
|
145
|
+
const expectedHtu = requestUrl.replace(/\/$/, '');
|
|
146
|
+
|
|
147
|
+
if (payloadHtu !== expectedHtu) {
|
|
148
|
+
// Also try without port for localhost
|
|
149
|
+
const altHtu = requestUrl.replace(/:(\d+)/, '').replace(/\/$/, '');
|
|
150
|
+
if (payloadHtu !== altHtu) {
|
|
151
|
+
return { thumbprint: null, error: `DPoP htu mismatch: expected ${expectedHtu}` };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// iat: Issued at (must be recent)
|
|
156
|
+
const now = Math.floor(Date.now() / 1000);
|
|
157
|
+
if (!payload.iat || Math.abs(now - payload.iat) > DPOP_MAX_AGE) {
|
|
158
|
+
return { thumbprint: null, error: 'DPoP proof expired or invalid iat' };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// jti: Unique identifier (we should track these to prevent replay, but skip for now)
|
|
162
|
+
if (!payload.jti) {
|
|
163
|
+
return { thumbprint: null, error: 'DPoP proof missing jti' };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ath: Access token hash (optional but recommended)
|
|
167
|
+
if (payload.ath) {
|
|
168
|
+
const expectedAth = await calculateAth(accessToken);
|
|
169
|
+
if (payload.ath !== expectedAth) {
|
|
170
|
+
return { thumbprint: null, error: 'DPoP ath mismatch' };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Calculate JWK thumbprint for binding verification
|
|
175
|
+
const thumbprint = await jose.calculateJwkThumbprint(protectedHeader.jwk, 'sha256');
|
|
176
|
+
|
|
177
|
+
return { thumbprint, error: null };
|
|
178
|
+
|
|
179
|
+
} catch (err) {
|
|
180
|
+
console.error('DPoP verification error:', err.message);
|
|
181
|
+
return { thumbprint: null, error: 'Invalid DPoP proof' };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Calculate access token hash (ath) for DPoP binding
|
|
187
|
+
*/
|
|
188
|
+
async function calculateAth(accessToken) {
|
|
189
|
+
const encoder = new TextEncoder();
|
|
190
|
+
const data = encoder.encode(accessToken);
|
|
191
|
+
const hash = await crypto.subtle.digest('SHA-256', data);
|
|
192
|
+
return jose.base64url.encode(new Uint8Array(hash));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Fetch and cache OIDC configuration
|
|
197
|
+
*/
|
|
198
|
+
async function getOidcConfig(issuer) {
|
|
199
|
+
const cached = oidcConfigCache.get(issuer);
|
|
200
|
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
201
|
+
return cached.config;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const configUrl = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const response = await fetch(configUrl);
|
|
208
|
+
if (!response.ok) {
|
|
209
|
+
throw new Error(`Failed to fetch OIDC config: ${response.status}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const config = await response.json();
|
|
213
|
+
oidcConfigCache.set(issuer, { config, timestamp: Date.now() });
|
|
214
|
+
return config;
|
|
215
|
+
|
|
216
|
+
} catch (err) {
|
|
217
|
+
console.error(`Failed to fetch OIDC config from ${issuer}:`, err.message);
|
|
218
|
+
throw err;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Get JWKS for an issuer (with caching)
|
|
224
|
+
*/
|
|
225
|
+
async function getJwks(issuer) {
|
|
226
|
+
const cached = jwksCache.get(issuer);
|
|
227
|
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
228
|
+
return cached.jwks;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Get OIDC config to find JWKS URI
|
|
232
|
+
const config = await getOidcConfig(issuer);
|
|
233
|
+
const jwksUri = config.jwks_uri;
|
|
234
|
+
|
|
235
|
+
if (!jwksUri) {
|
|
236
|
+
throw new Error('OIDC config missing jwks_uri');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Create a remote JWKS set
|
|
240
|
+
const jwks = jose.createRemoteJWKSet(new URL(jwksUri));
|
|
241
|
+
|
|
242
|
+
jwksCache.set(issuer, { jwks, timestamp: Date.now() });
|
|
243
|
+
return jwks;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Clear caches (useful for testing)
|
|
248
|
+
*/
|
|
249
|
+
export function clearCaches() {
|
|
250
|
+
oidcConfigCache.clear();
|
|
251
|
+
jwksCache.clear();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Check if request has Solid-OIDC authorization
|
|
256
|
+
*/
|
|
257
|
+
export function hasSolidOidcAuth(request) {
|
|
258
|
+
const authHeader = request.headers.authorization;
|
|
259
|
+
return authHeader && authHeader.startsWith('DPoP ');
|
|
260
|
+
}
|
package/src/auth/token.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Token-based authentication
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* -
|
|
4
|
+
* Supports two modes:
|
|
5
|
+
* 1. Simple tokens (for local/dev use): base64(JSON({webId, iat, exp})) + HMAC signature
|
|
6
|
+
* 2. Solid-OIDC DPoP tokens (for federation): verified via external IdP JWKS
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import crypto from 'crypto';
|
|
10
|
+
import { verifySolidOidc, hasSolidOidcAuth } from './solid-oidc.js';
|
|
10
11
|
|
|
11
12
|
// Secret for signing tokens (in production, use env var)
|
|
12
13
|
const SECRET = process.env.TOKEN_SECRET || 'dev-secret-change-in-production';
|
|
@@ -95,12 +96,18 @@ export function extractToken(authHeader) {
|
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
/**
|
|
98
|
-
* Extract WebID from request
|
|
99
|
+
* Extract WebID from request (sync version for simple tokens only)
|
|
99
100
|
* @param {object} request - Fastify request object
|
|
100
101
|
* @returns {string | null} WebID or null if not authenticated
|
|
101
102
|
*/
|
|
102
103
|
export function getWebIdFromRequest(request) {
|
|
103
104
|
const authHeader = request.headers.authorization;
|
|
105
|
+
|
|
106
|
+
// Skip DPoP tokens - use async version for those
|
|
107
|
+
if (authHeader && authHeader.startsWith('DPoP ')) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
104
111
|
const token = extractToken(authHeader);
|
|
105
112
|
|
|
106
113
|
if (!token) {
|
|
@@ -110,3 +117,34 @@ export function getWebIdFromRequest(request) {
|
|
|
110
117
|
const payload = verifyToken(token);
|
|
111
118
|
return payload?.webId || null;
|
|
112
119
|
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Extract WebID from request (async version supporting Solid-OIDC)
|
|
123
|
+
* @param {object} request - Fastify request object
|
|
124
|
+
* @returns {Promise<{webId: string|null, error: string|null}>}
|
|
125
|
+
*/
|
|
126
|
+
export async function getWebIdFromRequestAsync(request) {
|
|
127
|
+
const authHeader = request.headers.authorization;
|
|
128
|
+
|
|
129
|
+
if (!authHeader) {
|
|
130
|
+
return { webId: null, error: null };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Try Solid-OIDC first (DPoP tokens)
|
|
134
|
+
if (hasSolidOidcAuth(request)) {
|
|
135
|
+
return verifySolidOidc(request);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Fall back to simple Bearer tokens
|
|
139
|
+
const token = extractToken(authHeader);
|
|
140
|
+
if (!token) {
|
|
141
|
+
return { webId: null, error: null };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const payload = verifyToken(token);
|
|
145
|
+
if (payload?.webId) {
|
|
146
|
+
return { webId: payload.webId, error: null };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { webId: null, error: 'Invalid token' };
|
|
150
|
+
}
|
package/src/handlers/resource.js
CHANGED
|
@@ -2,6 +2,7 @@ import * as storage from '../storage/filesystem.js';
|
|
|
2
2
|
import { getAllHeaders } from '../ldp/headers.js';
|
|
3
3
|
import { generateContainerJsonLd, serializeJsonLd } from '../ldp/container.js';
|
|
4
4
|
import { isContainer, getContentType, isRdfContentType } from '../utils/url.js';
|
|
5
|
+
import { parseN3Patch, applyN3Patch, validatePatch } from '../patch/n3-patch.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Handle GET request
|
|
@@ -190,3 +191,99 @@ export async function handleOptions(request, reply) {
|
|
|
190
191
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
191
192
|
return reply.code(204).send();
|
|
192
193
|
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Handle PATCH request
|
|
197
|
+
* Supports N3 Patch format (text/n3) for updating RDF resources
|
|
198
|
+
*/
|
|
199
|
+
export async function handlePatch(request, reply) {
|
|
200
|
+
const urlPath = request.url.split('?')[0];
|
|
201
|
+
|
|
202
|
+
// Don't allow PATCH to containers
|
|
203
|
+
if (isContainer(urlPath)) {
|
|
204
|
+
return reply.code(409).send({ error: 'Cannot PATCH containers' });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Check content type
|
|
208
|
+
const contentType = request.headers['content-type'] || '';
|
|
209
|
+
const isN3Patch = contentType.includes('text/n3') ||
|
|
210
|
+
contentType.includes('application/n3') ||
|
|
211
|
+
contentType.includes('application/sparql-update');
|
|
212
|
+
|
|
213
|
+
if (!isN3Patch) {
|
|
214
|
+
return reply.code(415).send({
|
|
215
|
+
error: 'Unsupported Media Type',
|
|
216
|
+
message: 'PATCH requires Content-Type: text/n3 for N3 Patch format'
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Check if resource exists
|
|
221
|
+
const stats = await storage.stat(urlPath);
|
|
222
|
+
if (!stats) {
|
|
223
|
+
return reply.code(404).send({ error: 'Not Found' });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Read existing content
|
|
227
|
+
const existingContent = await storage.read(urlPath);
|
|
228
|
+
if (existingContent === null) {
|
|
229
|
+
return reply.code(500).send({ error: 'Read error' });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Parse existing document as JSON-LD
|
|
233
|
+
let document;
|
|
234
|
+
try {
|
|
235
|
+
document = JSON.parse(existingContent.toString());
|
|
236
|
+
} catch (e) {
|
|
237
|
+
return reply.code(409).send({
|
|
238
|
+
error: 'Conflict',
|
|
239
|
+
message: 'Resource is not valid JSON-LD and cannot be patched'
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Parse the patch
|
|
244
|
+
const patchContent = Buffer.isBuffer(request.body)
|
|
245
|
+
? request.body.toString()
|
|
246
|
+
: request.body;
|
|
247
|
+
|
|
248
|
+
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
249
|
+
let patch;
|
|
250
|
+
try {
|
|
251
|
+
patch = parseN3Patch(patchContent, resourceUrl);
|
|
252
|
+
} catch (e) {
|
|
253
|
+
return reply.code(400).send({
|
|
254
|
+
error: 'Bad Request',
|
|
255
|
+
message: 'Invalid N3 Patch format: ' + e.message
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Validate that deletes exist (optional strict mode)
|
|
260
|
+
// const validation = validatePatch(document, patch, resourceUrl);
|
|
261
|
+
// if (!validation.valid) {
|
|
262
|
+
// return reply.code(409).send({ error: 'Conflict', message: validation.error });
|
|
263
|
+
// }
|
|
264
|
+
|
|
265
|
+
// Apply the patch
|
|
266
|
+
let updatedDocument;
|
|
267
|
+
try {
|
|
268
|
+
updatedDocument = applyN3Patch(document, patch, resourceUrl);
|
|
269
|
+
} catch (e) {
|
|
270
|
+
return reply.code(409).send({
|
|
271
|
+
error: 'Conflict',
|
|
272
|
+
message: 'Failed to apply patch: ' + e.message
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Write updated document
|
|
277
|
+
const updatedContent = JSON.stringify(updatedDocument, null, 2);
|
|
278
|
+
const success = await storage.write(urlPath, Buffer.from(updatedContent));
|
|
279
|
+
|
|
280
|
+
if (!success) {
|
|
281
|
+
return reply.code(500).send({ error: 'Write failed' });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const origin = request.headers.origin;
|
|
285
|
+
const headers = getAllHeaders({ isContainer: false, origin, resourceUrl });
|
|
286
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
287
|
+
|
|
288
|
+
return reply.code(204).send();
|
|
289
|
+
}
|