/v1/id/_:id/expanded endpoint - #286
Conversation
…s cannot be expected to paginate simple GET requests. The response is now fully assembled.
…but only has one {} in it, as we can treat that the same way as a body that is just {} for expanding purposes.
…_contextid guard related to latent bug. Documentation cleanup.
…nored /expanded skips the Annotation gather and the merge for a deleted record and hands the tombstone back exactly as GET /v1/id/:_id does, with both count headers at 0. A production record with 8 leaf Annotations targeting it was being re-dressed with the descriptive data of the record it used to be. configureRerumOptions only receives a trustworthy __rerum on the update path, where the caller hands it a record from the database, so a supplied __rerum is read there alone. history and releases are built locally instead of being rebound to the received objects, so caller properties under them no longer reach storage and an affected record is cleaned on its next version.
… expanding the entity.
…ests Introduce tests
|
Reviewed the diff end to end. The Required: strip
queryObj["$and"].push({ [key]: value })So a client can POST Add a guard in Recommended: confirm two behavior changes
|
cubap
left a comment
There was a problem hiding this comment.
Reviewed the diff end to end. The __rerum forgery fix and the hasOwnProperty to Object.hasOwn change are both correct and well tested. One thing needs to change before this ships.
| const RESERVED_FILTER_KEYS = ["target", "type", "@type", "__rerum.history"] | ||
|
|
||
| /** | ||
| * The Annotation body types whose value is kept whole rather than read as a single assertion. |
There was a problem hiding this comment.
This drops four reserved keys but leaves every other key alone, including $-prefixed MongoDB operators. findLeafAnnotationsFor() pushes each surviving key straight into the query as { [key]: value }, so a client can POST {"$where": "<js>"} and it lands as a live operator. $where runs JavaScript on the server and this endpoint is unauthenticated, so that is a real RCE path scoped to POST /v1/id/:_id/expanded (the GET path only reads ?generator/?creator, so it is safe).
Add a guard here that drops any key starting with $, and a test asserting a $where or $or key is stripped.
Closes #285.
Summary
Adds a
GETandPOST /v1/id/:_id/expandedendpoint — a generic, unauthenticated, browser-cacheable endpoint that returns a RERUM object with the assertions of every current Annotation targeting it already merged in.Changes
routes/id.js—/:_id/expandedregistered ahead of/:_id.GET,POST(behindrest.verifyJsonContentType), andHEADvia Express's nativeGEThandling. Every other method is a405.OPTIONSis answered204by the CORS layer, which sits ahead of the route.controllers/crud.js—idExpanded()plus three helpers:sanitizeExpansionFilters()reduces a POST body to the filter keys this endpoint will honor.assertionsFrom()reads onlybodyandbodyValueoff an Annotation, so no other property of an Annotation can leak onto the entity.applyExpansionAnnotations()merges those assertions onto a clone of the entity and re-appends__rerumlast.db-controller.js— exportsidExpanded.controllers/utils.js—findLeafAnnotationsFor()builds the Annotation search and reads the whole result set from a single cursor, which the driver pages server-side in strides ofEXPANSION_BATCH_SIZE(200). The query plan promises no order, so the result is sorted by_idbefore_idis dropped. That is roughly Annotation creation order, and it makes the assembled entity byte-stable, which is what lets the weakETaganswer a revalidation with304.Two smaller fixes in the same file, both needed by this endpoint:
_contextid()now skips non-string members of an@contextArray. An inline term definition object names no context, and the old code called.includes()on it —_contextid(["@context" array starting with an object])threw aTypeError. Legal JSON-LD, reachable through any/v1/id/read.idNegotiation()restructured to return early instead of cloning up front and conditionally rebuilding. This prevents needless extra cloning between the routes and this helper function.utils.jsTwo changes here, neither specific to
/expandedbut both on paths it reads through:configureRerumOptions()only inheritsreceived.__rerumwhenupdateis true. This fixes a bug where client supplied values went into the __rerum property. AffectsPOST /v1/api/createandPOST /v1/api/bulkCreate.isContainerType()accepts an Array@type/typeand ignores non-string members. JSON-LD serializes a single type as a one-element Array, and with the old code a numeric type threw aTypeErrorout ofconfigureWebAnnoHeadersFor().Neither form pages
Both methods gather every Annotation matching their filters before the entity is assembled. A client asks once and receives the whole thing. There is no
?limit=or?skip=on this endpoint — the 200 infindLeafAnnotationsFor()is an internal cursor stride, not a page size.GET— the convenience formImagined as the plain
GETwhere nothing else is supplied. Recognizes?generator=and?creator=only. A value shaped like a URI is matched across both thehttpandhttpsspellings; any other value is matched exactly. A parameter supplied more than once arrives as an Array and is dropped rather than applied, as is a bracketed form like?generator[k]=v; the other parameter still applies if it was supplied correctly.POST— the filtered formThe JSON body is an object of literal MongoDB filter keys,
ANDed into the Annotation search — so filtering by generating app means supplying the real property name,__rerum.generatedBy. URL parameters supply no filters here. Not browser-cached.Because every supplied filter is
ANDed in, a filter can only narrow the result set — there is no way to widen it past the entity in the request URI.The
http/httpsdoubling applies to__rerum.generatedByandcreatorin this form too, since it lives in the shared helper. Every other key is applied exactly as given.Response headers
Cache-Control: max-age=86400, must-revalidate— only on theGET, so browsers can cache for 24h. Annotations on an entity are volatile during initial creation and stable afterward, so this trades a hard reload in the rare stale case for not re-running the expand on every visit.Annotations-Gathered— how many current Annotations were gathered for this expansion. This is a complete count, not the size of a page.Annotations-Merged— how many of those could contribute. Annotations with multiple bodies are not counted. A counted Annotation may still assert nothing mergeable, so this is not a count of properties received either.Deleted records
A deleted record is returned exactly as
GET /v1/id/:_idreturns it, with no expansion attempted and both count headers reporting0.controllers/gog.jsRefactored onto the shared helper, which is where the bulk of the deletions come from. Three behavior changes ride along, all on
/gog/id/:_id:hasOwnProperty→Object.hasOwn. An Annotation body assertinghasOwnPropertywould otherwise shadow the method and break the merge. This one is load-bearing, not cosmetic./gog/id/:_idis now restricted to data generated by a Gallery of Glosses agent and expands only with Annotations from the entity's own generator. A deleted record short-circuits ahead of that gate and returns its tombstone, since a tombstone has no generator to match on.Measured against production before merging, over the full population rather than a sample:
rerum.io/v1/id/URI under any of the six target keys.So the generator filter drops nothing today.
Documentation
public/API.htmlgets both forms underGETandPOST, plus anEntity Expansionsection carrying the merge rules, the reserved filter keys, the protected properties, and the deleted-record case. It also picks up unrelated markup repairs in passing — unclosed</span>s, a nested<code>, and two malformed<code __rerum.generatedBy>tags.openapi/contracts/core-provider.openapi.yamlgets/id/{id}/expandedwithget,head, andpost, and documents the response headers for/id/{id}as well. The reserved filter keys thePOSTignores are documented inpublic/API.htmlbut not yet in the contract.