Skip to content

Commit de02f15

Browse files
Recognise .acl and .meta as RDF resources for conneg (JavaScriptSolidServer#294)
getContentType() in src/utils/url.js is extension-based, but path.extname() returns '' for leading-dot names like '.acl' / '.meta' (node treats them as dotfiles, not extensions). Both fell through to application/octet-stream, which isRdfContentType() rejects, so handleGet's conneg branch never ran for them. Effect: Turtle-native clients (umai, Soukai-based apps, older Solid tooling) fetching <container>/.meta got JSON-LD back and errored with 'Malformed Turtle document — Expected entity but got { on line 2'. Hit today against ewingson's test.solidweb.app pod. Fix: one basename check in getContentType mapping '.acl' / '.meta' to application/ld+json — the format JSS already writes them in (via serializeAcl(), createPodStructure(), etc.). Conneg then handles translation for Turtle clients through handleGet's existing JSON-LD-to-Turtle path, and handlePut already converts incoming Turtle to JSON-LD before storing when --conneg is on. Zero migration: existing pods on disk stay as-is and become Turtle-servable immediately. Tests: - test/url.test.js: new 'getContentType' describe block — 6 cases (extension mapping, dotfile mapping, the 'acl-in-filename isn't an ACL file' guard). - test/conneg.test.js: new 'Solid convention dotfiles (JavaScriptSolidServer#294)' block — 3 integration cases against a running server (GET .meta default → ld+json, GET .meta with Accept: turtle → turtle, PUT turtle .meta → round-trips to JSON-LD). Full suite: 407/407 pass (was 398 + 9 new). The sibling tightening — reject non-JSON-LD PUTs with 415 when --conneg is off — is tracked separately as JavaScriptSolidServer#295 to avoid conflating a visible interop break with a design-ier purity fix. Fixes JavaScriptSolidServer#294
1 parent c9c206b commit de02f15

3 files changed

Lines changed: 112 additions & 1 deletion

File tree

src/utils/url.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,16 @@ export function getContentType(filePath) {
239239
'.m3u8': 'application/vnd.apple.mpegurl',
240240
'.pls': 'audio/x-scpls'
241241
};
242+
243+
// Solid convention dotfiles (.acl, .meta) are RDF resources. path.extname
244+
// returns '' for leading-dot names, so the map lookup above misses them;
245+
// fall back to a basename check and tag them as JSON-LD — the format JSS
246+
// writes them in via serializeAcl() / createPodStructure(). Content
247+
// negotiation then handles Turtle-native clients (umai, Soukai-based apps,
248+
// older Solid tooling) via handleGet's conneg branch.
249+
const base = path.basename(filePath);
250+
if (base === '.acl' || base === '.meta') return 'application/ld+json';
251+
242252
return types[ext] || 'application/octet-stream';
243253
}
244254

test/conneg.test.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,73 @@ describe('Content Negotiation (conneg enabled)', () => {
194194
'Accept-Post should include text/turtle');
195195
});
196196
});
197+
198+
// Regression coverage for #294 — Solid convention dotfiles (.acl, .meta)
199+
// were excluded from conneg because getContentType() returned
200+
// application/octet-stream for them. Turtle-native clients (umai etc.)
201+
// fetching <container>/.meta got JSON-LD back and errored on parse.
202+
describe('Solid convention dotfiles (#294)', () => {
203+
const metaData = {
204+
'@context': { 'ldp': 'http://www.w3.org/ns/ldp#' },
205+
'@id': '',
206+
'@type': 'ldp:BasicContainer'
207+
};
208+
209+
before(async () => {
210+
// Write a JSON-LD .meta file (the format JSS writes internally).
211+
await request('/connegtest/public/.meta', {
212+
method: 'PUT',
213+
headers: { 'Content-Type': 'application/ld+json' },
214+
body: JSON.stringify(metaData),
215+
auth: 'connegtest'
216+
});
217+
});
218+
219+
it('serves .meta as JSON-LD by default', async () => {
220+
const res = await request('/connegtest/public/.meta', { auth: 'connegtest' });
221+
assertStatus(res, 200);
222+
assertHeaderContains(res, 'Content-Type', 'application/ld+json');
223+
});
224+
225+
it('serves .meta as Turtle when Accept: text/turtle (the umai case)', async () => {
226+
const res = await request('/connegtest/public/.meta', {
227+
headers: { 'Accept': 'text/turtle' },
228+
auth: 'connegtest'
229+
});
230+
assertStatus(res, 200);
231+
assertHeaderContains(res, 'Content-Type', 'text/turtle');
232+
const turtle = await res.text();
233+
// First byte after the `@prefix` block must parse as Turtle,
234+
// not '{' (the bug signature umai hit).
235+
assert.ok(!turtle.trimStart().startsWith('{'),
236+
`response looks like JSON, not Turtle: ${turtle.slice(0, 60)}`);
237+
});
238+
239+
it('accepts Turtle PUT to .meta and round-trips to JSON-LD', async () => {
240+
const turtle = `
241+
@prefix ldp: <http://www.w3.org/ns/ldp#>.
242+
<> a ldp:BasicContainer.
243+
`;
244+
const putRes = await request('/connegtest/public/.meta', {
245+
method: 'PUT',
246+
headers: { 'Content-Type': 'text/turtle' },
247+
body: turtle,
248+
auth: 'connegtest'
249+
});
250+
assert.ok(putRes.status < 300, `PUT turtle should succeed, got ${putRes.status}`);
251+
252+
// Default GET now serves the converted-and-stored JSON-LD.
253+
const getRes = await request('/connegtest/public/.meta', {
254+
headers: { 'Accept': 'application/ld+json' },
255+
auth: 'connegtest'
256+
});
257+
assertStatus(getRes, 200);
258+
assertHeaderContains(getRes, 'Content-Type', 'application/ld+json');
259+
const body = await getRes.json();
260+
assert.ok(body['@context'] || body['@graph'] || body['@type'] || body['@id'],
261+
'round-tripped JSON-LD should have at least one @-keyword');
262+
});
263+
});
197264
});
198265

199266
describe('Content Negotiation (conneg disabled - default)', () => {

test/url.test.js

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import { describe, it } from 'node:test';
99
import assert from 'node:assert';
10-
import { getPodName } from '../src/utils/url.js';
10+
import { getPodName, getContentType } from '../src/utils/url.js';
1111

1212
describe('getPodName', () => {
1313
describe('subdomain mode', () => {
@@ -73,3 +73,37 @@ describe('getPodName', () => {
7373
});
7474
});
7575
});
76+
77+
// Regression coverage for #294 — .acl and .meta must be recognised as RDF
78+
// resources so content negotiation kicks in for Turtle-native clients.
79+
describe('getContentType', () => {
80+
describe('extension-based mapping (existing)', () => {
81+
it('maps .jsonld → application/ld+json', () => {
82+
assert.strictEqual(getContentType('/x/card.jsonld'), 'application/ld+json');
83+
});
84+
it('maps .ttl → text/turtle', () => {
85+
assert.strictEqual(getContentType('/x/card.ttl'), 'text/turtle');
86+
});
87+
it('falls back to application/octet-stream for unknown extensions', () => {
88+
assert.strictEqual(getContentType('/x/file.xyz'), 'application/octet-stream');
89+
});
90+
});
91+
92+
describe('Solid convention dotfiles (#294)', () => {
93+
it('treats .acl as application/ld+json (the format JSS writes it in)', () => {
94+
assert.strictEqual(getContentType('/alice/public/.acl'), 'application/ld+json');
95+
assert.strictEqual(getContentType('.acl'), 'application/ld+json');
96+
});
97+
98+
it('treats .meta as application/ld+json', () => {
99+
assert.strictEqual(getContentType('/alice/public/.meta'), 'application/ld+json');
100+
assert.strictEqual(getContentType('.meta'), 'application/ld+json');
101+
});
102+
103+
it('does not mistake non-dotfile paths containing .acl for ACL files', () => {
104+
// A regular file that happens to have "acl" in its name/path stays
105+
// classified by extension, not by coincidence.
106+
assert.strictEqual(getContentType('/alice/notes/my-acl-plan.md'), 'text/markdown');
107+
});
108+
});
109+
});

0 commit comments

Comments
 (0)