From 27beb651d830e36051ea16676b74ea34e5837e80 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 26 Aug 2026 12:11:23 -0500 Subject: [PATCH 1/4] Do not let Annotations assert 'type' or '@type' onto entities. --- controllers/utils.js | 8 +++++++- routes/__tests__/id.test.js | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/controllers/utils.js b/controllers/utils.js index a5c09bc5..fc8b804f 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -123,8 +123,14 @@ const TARGET_KEYS = ["target", "target.@id", "target.id", "target.source", "targ /** * Identity, system, and processing properties an Annotation body must never overwrite when its * assertions are merged onto an entity. + * + * 'type' and '@type' are here because class is identity. The record declares what it is; an + * Annotation targeting it describes it and has no standing to say it is also something else. + * Without them a body carrying a 'type' appended to the entity's own class, so the expanded + * entity answered to a type the stored record never claimed and clients had to re-read the + * unexpanded record to recover the real one. */ -const PROTECTED_EXPANSION_KEYS = new Set(["@id", "id", "_id", "__rerum", "__deleted", "__proto__", "@context"]) +const PROTECTED_EXPANSION_KEYS = new Set(["@id", "id", "_id", "__rerum", "__deleted", "__proto__", "@context", "type", "@type"]) /** * Escape the RegExp metacharacters in a literal so it can be embedded in a pattern and match only diff --git a/routes/__tests__/id.test.js b/routes/__tests__/id.test.js index 5d9f9773..8734c9fb 100644 --- a/routes/__tests__/id.test.js +++ b/routes/__tests__/id.test.js @@ -277,6 +277,10 @@ describe('GET /id/:id/expanded', () => { anno({ body: { __rerum: { evil: true } } }), anno({ body: { __deleted: { time: "2025-01-01T00:00:00.000" } } }), anno({ body: { "@context": "https://evil.example.org/context.json" } }), + // Class is identity. An Annotation describes the record; it does not get to say the record + // is also something else, under either spelling of the key. + anno({ body: { "@type": "Hijacked" } }), + anno({ body: { type: "Hijacked" } }), // An object literal with a __proto__ key sets the prototype instead of creating an own // property. JSON.parse creates the own property, which is what a MongoDB document has. anno({ body: JSON.parse('{"__proto__":{"polluted":"yes"}}') }) @@ -291,6 +295,8 @@ describe('GET /id/:id/expanded', () => { assert.strictEqual(response.body.__rerum.generatedBy, MOCK_AGENT) assert.strictEqual(response.body.__deleted, undefined) assert.strictEqual(response.body["@context"], "http://www.loc.gov/mods") + assert.strictEqual(response.body["@type"], "named-gloss", 'the record keeps the class it declared') + assert.strictEqual(response.body.type, undefined, 'an Annotation cannot add the other spelling either') assert.strictEqual(Object.hasOwn(response.body, '__proto__'), false) assert.strictEqual({}.polluted, undefined, 'Object.prototype must not be polluted') assert.strictEqual(response.headers['annotations-merged'], '0') From 8a5f8b2a275a6e13c5300d44f3752b9aae2b4279 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 26 Aug 2026 12:16:20 -0500 Subject: [PATCH 2/4] comment cleanup --- controllers/utils.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/controllers/utils.js b/controllers/utils.js index fc8b804f..cd465c47 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -123,12 +123,6 @@ const TARGET_KEYS = ["target", "target.@id", "target.id", "target.source", "targ /** * Identity, system, and processing properties an Annotation body must never overwrite when its * assertions are merged onto an entity. - * - * 'type' and '@type' are here because class is identity. The record declares what it is; an - * Annotation targeting it describes it and has no standing to say it is also something else. - * Without them a body carrying a 'type' appended to the entity's own class, so the expanded - * entity answered to a type the stored record never claimed and clients had to re-read the - * unexpanded record to recover the real one. */ const PROTECTED_EXPANSION_KEYS = new Set(["@id", "id", "_id", "__rerum", "__deleted", "__proto__", "@context", "type", "@type"]) From 05c29bff6c2b6cb33d1a6b3df2d529dc57d816ed Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 26 Aug 2026 12:17:30 -0500 Subject: [PATCH 3/4] comment cleanup --- routes/__tests__/id.test.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/routes/__tests__/id.test.js b/routes/__tests__/id.test.js index 8734c9fb..60e72756 100644 --- a/routes/__tests__/id.test.js +++ b/routes/__tests__/id.test.js @@ -277,8 +277,6 @@ describe('GET /id/:id/expanded', () => { anno({ body: { __rerum: { evil: true } } }), anno({ body: { __deleted: { time: "2025-01-01T00:00:00.000" } } }), anno({ body: { "@context": "https://evil.example.org/context.json" } }), - // Class is identity. An Annotation describes the record; it does not get to say the record - // is also something else, under either spelling of the key. anno({ body: { "@type": "Hijacked" } }), anno({ body: { type: "Hijacked" } }), // An object literal with a __proto__ key sets the prototype instead of creating an own From e63fb5dfff3cbd4c04f076e5124e7282ae529e44 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 26 Aug 2026 13:56:23 -0500 Subject: [PATCH 4/4] update docs --- controllers/utils.js | 5 +++-- public/API.html | 26 ++++++++++++-------------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/controllers/utils.js b/controllers/utils.js index cd465c47..e7be35db 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -121,8 +121,9 @@ const URI_DOUBLED_FILTER_KEYS = new Set(["__rerum.generatedBy", "creator"]) const TARGET_KEYS = ["target", "target.@id", "target.id", "target.source", "target.source.@id", "target.source.id"] /** - * Identity, system, and processing properties an Annotation body must never overwrite when its - * assertions are merged onto an entity. + * Identity, classification, system, and processing properties an Annotation body must never overwrite when its + * assertions are merged onto an entity. The entity stays the authority on its own '@context' and + * on the class it declares under 'type' or '@type'. */ const PROTECTED_EXPANSION_KEYS = new Set(["@id", "id", "_id", "__rerum", "__deleted", "__proto__", "@context", "type", "@type"]) diff --git a/public/API.html b/public/API.html index 3cd1a6a6..54804b09 100644 --- a/public/API.html +++ b/public/API.html @@ -1714,23 +1714,21 @@

Entity Expansion

record said and what each Annotation added.

- A record's identity, system, and processing properties are never overwritten by an Annotation. An - Annotation body asserting @id, + A record's identity, classification, system, and processing properties + are never overwritten by an Annotation. An Annotation body asserting + @id, id, _id, + @context, + @type, + type, __rerum, - __deleted, - __proto__, or - @context contributes nothing. The expanded record - answers to the same URI as the record you asked for, and reads under the same - @context. -

-

- Linked Data keywords that describe the record rather than identify it are not held back. An Annotation - asserting type or - @type - contributes it like any other property, which collects the record's own value and the asserted one - into an Array. + __deleted, or + __proto__ + contributes nothing. The expanded record answers to the same URI as the + record you asked for, and reads under the same + @context + as the same class of thing it declared itself to be.

A deleted record is never expanded. Whatever it used to assert is inside its