Skip to content

Including all changes and additions to date - #110

Merged
ebremer merged 140 commits into
masterfrom
next
Sep 2, 2026
Merged

ebremer merged 140 commits into
masterfrom
next

Conversation

@ebremer

@ebremer ebremer commented Sep 2, 2026

Copy link
Copy Markdown
Member

No description provided.

ebremer added 30 commits July 16, 2026 19:23
- SecuredStatementIterator.remove()/removeNext() Read→Delete escalation. remove() now enforces graph Update + triple Delete on the statement captured from next() (mirroring SecuredStatementImpl.remove()), and removeNext() routes through it. The finding understated the surface: filterKeep/filterDrop/mapWith/andThen handed back the inner unchecked chain, so listStatements().filterKeep(s -> true).removeNext() would have sidestepped a remove-only fix — derived iterators now wrap this so removal stays guarded. The escalation was real: the permitted-path test proves a Jena 6.1.0 mem-model iterator remove really deletes from the graph.
-  rewriter passed OpPropFunc/OpProcedure through. Both now go through passThroughOrDeny, failing closed like OpPath/OpQuad*. Recorded in the note: on the live engine path this is defense-in-depth (the optimizer manufactures OpPropFunc after the rewriter runs, and those execute against the secured graph proxy's filtered find()); the residual is external-index property functions like jena-text, none of which Halcyon ships.
-  SecuredNodeIterator/SecuredResIterator unguarded. A node can't be mapped back to the triple(s) a removal mutates, so both iterators now require Update + Delete-over-Triple.ANY, failing closed for per-triple principals.
The listing is now a SelectDataProvider + AjaxFallbackDefaultDataTable:
the H6 admin/creator/wac:Read rule rides in as the provider's row filter
(via a new principal-free StackStore.canReadStack overload, same rule,
single authority), so the count and paging windows only ever reflect
rows the user may see. Row selection is node-keyed (SelectionColumn +
NodeSelection keyed by the stack graph) with writability as the
eligibility gate and a select-all header; delete re-derives each
stack's root subject in-txn for the creator check and ACL cleanup.
Replaces the hand-rolled ListView/CheckGroup/loadStacks machinery;
adds paging, header sorting, and per-render freshness.
A new /lwscontainers screen (AUTHENTICATED, in the menu) that browses the
W3C Linked Web Storage hierarchy strictly through the LWS APIs — every
byte arrives over HTTP via LwsClient with the signed-in user's own bearer
token, so what the page shows is exactly what ACP grants that user.

- Tree over the selected storage's root container (any configured storage
  selectable): expanding a container lists its sub-containers and
  resources with the listing's lws:mediaType/size metadata. Pagination
  follows the protocol's opaque Link-header cursors (never fabricates a
  page URI) with a per-container window at a chosen page size; the
  media-type filter narrows resources, never navigation.

- Right-click Properties on any item: admins edit WHO HAS ACCESS by
  rewriting the item's own ACP access control resource (rel=acl ->
  GET .acr as Turtle -> conditional PUT; the storage still demands
  acl:Control). The AcrDoc translator only offers the structured editor
  when it can rebuild the document faithfully — deny/noneOf/client
  matchers/validity windows drop to raw Turtle rather than silently
  rewriting rules; structural nodes are blank on purpose. Non-admins file
  an ODRL AccessRequest with the Data Sharing Service instead.

- A right-hand preview panel renders the selection with a default viewer
  by media type: img/video/audio/PDF stream through a page-scoped relay
  that carries the session token (no open proxy — configured storages
  only, passive media only, nosniff); text-like types are a bounded
  (256 kB) escaped server-side fetch. HTML and SVG get source view, never
  same-origin rendering — relaying them would be stored XSS.

LwsClient grows Link-header parsing, HEAD, raw-Turtle GET with ETag,
conditional PUT, and streaming/bounded-preview reads. Mount + access are
registered in PageAccess (test-enforced); AcrDoc, PreviewKind and the
link parsing are unit-tested.
The preview panel now asks the vg:MediaBinding shapes (vandegraph
0.2.0-SNAPSHOT) which viewer a media type gets: the resolved default
renders, and when the bindings list alternates an "open with" picker
offers them, filtered to viewers actually registered in MediaRegistry.
Reader-discovered RDF types now survive listing parse into Entry so
vg:rdfType selectors (e.g. zeph:Stack) can match.

Host security stays code-enforced regardless of bindings data: the
token relay's src is handed only to PreviewKind-relayable passive
media, and text content is fetched bounded server-side. PreviewKind's
javadoc now states its narrowed job — the relay whitelist and the
text heuristic; viewer CHOICE lives in the shapes.
Zephyr joins the vandegraph media layer: hal:ZephyrViewer and
hal:ZephyrEditor are registered in the MediaRegistry (code) and
selected by Halcyon's vg:MediaBinding overlay (data,
halcyon/media-bindings.ttl) — whole-slide TIFFs open in Zephyr by
exact-type override of the defaults' image/* pattern (the plain image
viewer stays listed as an alternate), and resources the LWS readers
typed zeph:Stack open as that stack. This file is where default
viewers/editors per media type are specified and alternates listed.

The v1 wrapper embeds Zephyr3 in a same-origin iframe via a new
bookmarkable entry (?stack= / ?image=). No new authority: access is
enforced on the page class (PageAccess AUTHENTICATED) however it is
reached, OPEN_STACK still runs the H6 read check in loadGraph, and
Save still authorizes through StackStore. Honest limit: Zephyr loads
imagery through /iiif/, so LWS-only slides need that pipeline taught
to read from the storage before this binding delivers on them.

Also fixes the classpath landmine this surfaced: BeakGraph's jar
bundles stale com.ebremer.ns copies (HAL, LWS, EXIF, GEO, GEOF, PYR)
and preceded halcyon-core, so NEW vocabulary constants failed to
resolve at compile time. halcyon-core now comes first, with a pom
comment saying why it must.
New ui.md covers the /storage browser and the /lwscontainers container
tree: the everything-over-LwsClient rule, protocol-cursor pagination
that never fabricates page URIs, the media-type filter, the right-click
access properties (admin ACP editor with the AcrDoc faithfulness guard
and conditional PUT; ODRL access requests for everyone else), and the
media-binding-driven preview pane — Halcyon's overlay binding Zephyr to
whole-slide TIFFs and zeph:Stack resources, the token relay's
passive-media whitelist, bounded text previews, and the
HTML/SVG-as-source-view stance. Indexed from docs/lws/README.md (whose
reactor note now names both UIs) and the root README.
Each storage gains a reserved .iiif endpoint (unmintable by clients,
like .description) and advertises it in the storage description — a
capability entry typed by the IIIF protocol IRI (http://iiif.io/api/image)
plus an ImageService entry in the service list — ONLY when an imaging
implementation is actually installed: a capability entry is a contract,
not decoration.

The servlet owns everything protocol- and policy-shaped: the query
dialect (?iiif={imageUri}/{region}/{size}/{rotation}/{quality}.{format}
or {imageUri}/info.json), confinement of the image identity to a data
resource of THIS storage (an image service, not an open proxy), the ACP
acl:Read demand on that resource, and resolution of its bytes to the
content-store path. The imaging itself — tile engine, format readers —
lives above this module, so it arrives as a pluggable IiifService handed
to the servlet at construction; absent one, the endpoint 404s and
nothing is advertised.
LwsIiifBridge implements the storages' IiifService with the existing
engine — tile cache, reader pool, output budgets — pointed at the LWS
resource's blob. The wiring is careful about H9: the reader pool keeps
refusing file: identifiers (the ?iiif= parameter is attacker-
controlled), and the bridge instead registers each (resource, content
path) pair under an unguessable urn:lws-iiif: key through a new
code-only trusted-source channel on ImageReaderPoolFactory. The key
never leaves the server, so the public /iiif servlet cannot name it;
a replaced blob mints a fresh key, so tile/reader caches can never
serve stale content; synthetic-scheme validation keeps the channel
from ever shadowing PathMapper's http(s) rules or recreating the
file: primitive. ImageServer gains a serve(..., source) entry that
overrides only where the pixels come from — request shape and
info.json id still derive from the request itself.

LwsStorageConfiguration installs one bridge across all storages,
which is also what flips on the ImageService capability in each
storage's description.
…e/tiff)

The mirror gateway adopted files with URLConnection's name guess, which
has never heard of .svs or .ndpi — so a whole-slide image dropped on
disk recorded as application/octet-stream and nothing downstream (the
Type Index, the UI's media-binding viewer selection) treated it as
imagery. MediaTypeFormats gains the reverse direction — extension ->
media type for the specialist formats the readers know (svs/ndpi/tif ->
image/tiff, jp2, jxl, dcm, h5, RDF text types) — and the reconciler
consults it before the JDK guess. Already-adopted resources keep their
recorded type until re-adopted.
A tile request comes from an <img>/viewer fetch the page cannot attach
a bearer token to — and publishing the token into the DOM so it could
is the C5 hole that was closed. Same answer as the /rdf proxy: the
credential stays server-side. A new filter on the storage mappings
attaches the session's own token when a request reaches a storage's
.iiif endpoint with no Authorization of its own, so ACP decides as
that user.

Deliberately narrow, because cookie-derived authority is a CSRF
surface: GET only, the .iiif endpoint only (a read that changes
nothing); every other storage request keeps the pure bearer contract,
and a request that brought its own Authorization is never rewritten.
Registered for REQUEST and FORWARD dispatch — the global /iiif servlet
will forward LWS identifiers here.
…rage

The legacy /iiif/?iiif= servlet now detects an image identity inside a
configured LWS storage and forwards the request to that storage's own
.iiif endpoint — where the ACP decision is made and the session-auth
filter supplies the browser's credential. Fixed-prefix viewers (Zephyr)
therefore work unchanged for slides living in LWS storage: click an
.svs in the container tree, the media bindings open Zephyr, Zephyr asks
/iiif, /iiif forwards to the storage, ACP answers as the signed-in
user, tiles flow. The bridge's engine call carries an explicit source,
so the forward can never re-enter itself.

Retires the documented 'known limit' in ZephyrMediaPanel and ui.md;
http-api.md records both conveniences and their deliberate narrowness.
Every content-creation path recorded the client's Content-Type
verbatim — and a browser uploads .svs as application/octet-stream,
because it has never heard of the format. The recorded type is what
every media-type-driven consumer keys on (Type Index scanning, the
UI's viewer bindings), so a UI-uploaded whole-slide image previewed as
'no viewer bound' and the direct link just downloaded bytes.

MediaTypeFormats.recordedMediaType is now the one rule at all four
sites (object-store POST and PUT-replace, mirror POST and PUT): the
client's SPECIFIC type always wins; an absent/blank/octet-stream type
with a known extension records the known format; a replace with no
Content-Type keeps the recorded type. Existing resources keep their
recorded octet-stream until re-uploaded (or, on the mirror, the file
is re-adopted).
A file adopted before the name→type mapping existed keeps its recorded
application/octet-stream forever: the reconcile loop only re-adopted on
size/mtime drift, and the bytes never changed. The disk is the source
of truth in this storage and the type is a fact about the file, so an
up-to-date entry whose recorded type is opaque while its name now
identifies a known format (.svs → image/tiff) is re-adopted once —
fresh type and etag flow to listings and every media-type-driven
consumer. Whole pre-existing slide archives correct themselves on the
next startup/sweep with no manual re-drop.
…rage

A stack seeded from an LWS image now lives WITH its imagery. Zephyr3
mints the new stack's URI beside the seed (the image's container,
discovered from the API's rel=up; flat-storage URIs hang off the base
with the container stashed in the session), and Save routes any
storage-resident stack URI through the LWS API instead of StackStore:
first save POSTs the Turtle into the container with the user's own
token (ACP authorizes, the storage records ownership, creator is
server-stamped as before), later saves are conditional PUTs (a
concurrent edit answers 409). Opening a storage-resident stack fetches
its Turtle over the API as the signed-in user — the H6 check stays for
triple-store stacks; for LWS stacks ACP IS that authority.

The last link was the typing: RDFFileReader.getMeta(URI) — the exact
overload the LWS metadata scanner calls — threw
UnsupportedOperationException, so RDF resources were never typed at
all. It now returns the root-focused rdf/dcterms/prov subset, which
carries the document's own rdf:type: a saved stack types itself
zeph:Stack, listings surface it, the hal:ZephyrStackBinding fires, and
the container tree both shows the stack next to its slide and reopens
it in Zephyr on click. Sharing comes free: an LWS stack is an ordinary
resource under the right-click ACP editor and access requests.
Triple-store stacks are untouched and still appear on /stacks.
The viewer modules are plain static files with no content hashing, and
the no-cache header guarding against stale copies was applied only in
dev mode — so in deployment, browsers' heuristic caching kept serving
old modules ACROSS UPGRADES. Diagnosed live: a pre-H1
stackPersistence.js (whose Save posts a SPARQL update to /rdf) kept
running from cache long after the endpoint went read-only, so every
stack save died with '/rdf request failed: 400' and no server rebuild
could fix it. no-cache + must-revalidate costs a conditional 304 per
module and ends the trap.
A second context-menu entry opens a confirmation dialog in the existing
modal, then issues the LWS DELETE with the entity tag read first (the
storage answers 428 to an unconditional delete) and Depth: infinity for
containers. ACP stays the storage's decision - a refusal is rendered
verbatim. The storage root gets no delete button; the dialog says why.
After a successful delete the parent listing is re-read fresh and the
preview clears if it showed the deleted item.
An LWS-resident stack is now written the way a web document should be:
it references ITSELF as <> and its same-container companions - the
imagery and the annotation-layer JSON files - by bare sibling name, with
no @base directive. On every read the document inherits the URI it was
dereferenced from, so a container can be moved, mirrored or renamed
without rewriting the stacks inside it.

StackTurtle does the relativization explicitly rather than through
Jena's writer-side base mechanism, which also emits ../-hopping and
root-relative forms for out-of-container URIs - forms that resolve
correctly but rebind wrongly the moment a relative document is copied.
Anything outside the stack's container (a cross-container layer, the
creator WebID) stays absolute. Round-trip pinned by StackTurtleTest;
readers are unchanged because RDFFileReader, loadLwsStack and the
browser all already parse with the resource URI as base.
…ionally

The relative stack Turtle assumes its annotation-layer JSONs live in the
stack's own container, so the writer must honor what the reader assumes:
Zephyr3 now injects stackContainer (the minted parent for a new stack,
the rel="up" link for a reopened one) and a NEW shape file is born
there, not in the image's container - the two only differ for imagery
added from elsewhere, which would otherwise strand the JSON outside the
container the stack's relative reference points into. Stack-less legacy
pages keep the image-container fallback.

Re-saving an edited layer now carries If-Match from a fresh HEAD: the
LWS storage refuses an unconditional overwrite with 428, so every
re-save of an existing annotation file used to land in the failed list.
A target with no ETag (legacy LDP) stays unconditional. Also replaces
stackPersistence.js's stale pre-H1 header, which still described saving
as a SPARQL update to /rdf.
…e once

The metadata scanner dispatched by extension through the ServiceLoader
provider, which keeps ONE winner per extension - and for ttl that winner
is the image-pipeline reader (RDFImageReaderFactory, feature layers
rendered as tiles), whose getMeta(URI) throws. Every stored Turtle
document therefore scanned as nothing at all: a saved Zephyr stack never
surfaced its zeph:Stack type, listings said bare DataResource, and the
container tree could only ever show such a file as text.

RDF document extensions (ttl, nt, jsonld, rdf) now route straight to
RDFFileReaderFactory - the reader that parses with the resource URI as
base, which the relative stack documents (<> a zeph:Stack) depend on.
The tile pipeline keeps its winner untouched. CURRENT_SCAN_VERSION bumps
to 2, so the next start re-reads everything stamped v1 in the background
and existing stacks pick up their type with no manual step.
…njunctively

The ZephyrStackBinding was rdf:type-only, and type-only bindings are the
weakest rank - the defaults' text/* source view outranked it, so even a
correctly typed stack.ttl rendered as text. With vandegraph's new
conjunctive selectors the binding now requires BOTH the format and the
scanner-discovered zeph:Stack type, ranks as an exact media match, and
beats the source view - which stays offered as an alternate. Plain or
not-yet-scanned Turtle keeps the text view. The old test resolved with a
null media type, which is exactly the blind spot that hid this; it now
pins the real listing case.
application/x-hdf5 matched no binding at all - not the defaults (image,
video, audio, pdf, text, structured text) and not the overlay - so the
LWS Containers preview could only say "no viewer is bound" and offer
the raw link. The rendering path has existed all along: the storage's
.iiif endpoint feeds Halcyon's IIIF engine, whose reader pool picks
BeakGraphImageReader for .h5 content, and ZephyrMediaPanel seeds any
non-stack resource as layer 0 of a fresh stack, where the .h5 extension
makes it a feature layer pulling those tiles. One binding connects the
dots, under both recorded spellings of the HDF media type.
AcpSecuredDatasetGraph filtered every find(), getGraph(), listGraphNodes()
and the union - and a SPARQL query over it still read forbidden graphs.
Two holes, both in the wrapper superclass it extends: stream() delegates
straight to the unfiltered base (and query engines use that access path),
and nothing marked the wrapper as view-changing, leaving engines free to
unwrap it. stream() now routes through the filtered find, and the class
implements DatasetGraphWrapperView, the marker that forbids unwrapping.

Found by the new AcpSecuredDatasetGraphTest, written because /rdf2 makes
this class load-bearing: it pins that the internal graphs (system, ACP,
subscriptions, KEYS - one holds a private key) stay invisible even to an
allow-everything evaluator, and that an unreadable resource is absent
from every access path - enumeration, wildcards, union, and SPARQL.
The query surface for lws-tdb2 that /rdf is for the classic store - but
never the raw dataset, which holds the ACP policies, the storage keys
and a private key. Every request builds the ACP-secured view for the
CALLER (the signed-in session's WebID, else the public agent), so a
query answers exactly what that agent's own LWS GETs could fetch and an
unauthorized resource is not discoverable at all - the Type Search
guarantee, reused. Deliberately a servlet on the app port rather than a
Fuseki mount: a static dataset would freeze one agent's view forever,
while the ACP evaluator's contract is one instance per request, and the
decision must be live.

Read-only twice over (H1 discipline): the request must parse as a
SPARQL Query, and the dataset is a DatasetGraphReadOnly. Nothing lives
in the store's default graph, so it is served as the ACP-filtered UNION
of the named graphs - a bare ?s ?p ?o works, and GRAPH ?g enumerates
only readable resources. SELECT/ASK answer JSON (or XML/CSV/TSV by
Accept), CONSTRUCT/DESCRIBE answer Turtle or N-Triples.
One page, one menu item: /sparql keeps targeting the classic store
through /rdf, and /sparql?endpoint=rdf2 targets the W3C LWS store
through the new /rdf2 - with an on-page picker to switch, so a second
"SPARQL 2" menu entry is unnecessary. The parameter is mapped against
an allowlist, never echoed: whatever arrives, the only strings that
reach the inline script are the two server-side constants, injected
JsSafe via renderHead.

Each endpoint gets its own YASGUI persistence namespace, and the
current tab is steered to the chosen endpoint on load - a persisted
YASGUI tab keeps its own endpoint, so without both, tabs opened
against one store would quietly keep querying the other.
The LWS twin of the classic :hasResourceHandler multi-disk layout,
except the URI space does not move at all: a mirror storage may declare

  :hasMount [ :containerPath "tcga/brca" ; :resourceBase <file:///F:/brca/> ]

and everything under that sub-container lives on the other disk.
Clients see one storage tree; listings, containment, ACP and ETags are
untouched, which is why nothing about a mount is ever advertised.

MountTable is the one brain: longest-prefix, segment-wise key->root
resolution (tcga never claims tcga2; nested mounts shadow the subtree
of the mount above, exactly like OS mounts), per-root escape guards,
and the walk-root enumeration with the inverse disk->key mapping. The
content store resolves every read/write/delete through it - staging
files were already siblings of their targets, so atomic replaces stay
on the destination volume. The reconciler walks every root, adopts the
mount directory itself as its sub-container (synthesizing ancestors a
deep mount needs), skips shadowed paths, and treats an OFFLINE mount as
unavailable rather than deleted - its subtree stays registered and
reappears with the disk. The watcher registers every root; a mount
point's directory is never deleted with its container. Flat (uuid)
storages refuse mounts: their keys are not paths.

Mounts are mirror-only, slug-only, validated leniently (a malformed
mount is logged and skipped; the storage stands).
ebremer added 29 commits July 22, 2026 16:17
The /user/colorclasses editor (vandegraph SHACLForm over the user's
hal:AnnotationClassList, loaded/saved to their LWS storage as
{userDataStorage}/users/{name}/colorclasses.ttl via LwsCommandNode) was
reachable only from an admin-gated menu link. Color classes are the user's own
per-user document, exactly like the Account page, so the link now shows for
every signed-in user. No page or persistence change -- the LWS save/load was
already in place (4ebd23e).

Builds green: Halcyon 74 tests.
HalcyonSession.getUser() returned the full WebID for a WebID login but the short
preferred_username for a Keycloak login. The Color Classes editor builds
{userDataStorage}/users/{getUser()}/colorclasses.ttl, so for a WebID user the
whole WebID was URL-encoded into one path segment
(users/https%3A%2F%2Febremer.com%2Fid%2Ferich/...) -- the %2F the storage
(Jetty) rejects with HTTP 400, which surfaced as the page's construction error.

Set user = principal.getPreferredUserName() in the WebID branch (its WebID is
already carried by getUserURI()), matching the Keycloak branch. This also makes
the editor agree with the /colorclasses palette relay, which already keys on
getPreferredUserName() -- so both now read/write the SAME document
(users/{shortname}/colorclasses.ttl) instead of two different paths.

getUser() has exactly one caller (ColorClasses), so the semantic alignment is
low-risk.

Builds green: Halcyon 74 tests.
Image layers tile a square power-of-two grid but the display plane is scaled to
the actual (non-square) image size, so the outermost tiles cover a cell larger
than their real content and ClampToEdgeWrapping stretched their last texel across
the overhang -- the stretched right column / bottom row, worst where the image is
farthest from a power-of-two multiple of the tile size (e.g. a 166908x84951 SVS).

Discard the overhang in the tile shader hook: the map transform puts real content
in vMapUv [0,1], so fragments outside that range are the clamp region and now
render nothing (the image ends cleanly) instead of a smeared edge texel. Interior
tiles have repeat=1 (vMapUv stays in [0,1]) so nothing is discarded; a small
epsilon keeps a shared interior edge from dropping a seam pixel to interpolation.

JS-only (three.js r160, vMapUv); verify visually in the Zephyr viewer.
Content-Digest and Repr-Digest on GET (binary answered from a stored sha-256, range-aware; JSON/Turtle/linkset over the in-memory bytes), Want-Repr-Digest/Want-Content-Digest negotiation, and inbound Content-Digest verification on writes (a streamed upload is checked against the sha-256 computed while it streamed in, a sha-512 by re-reading the just-stored blob; patch bodies are verified in memory). Want-Content-Digest is advertised on resource responses and OPTIONS, and the storage description now advertises the rfc9530 capability (sha-256, sha-512). Persists the content sha-256 -- wiring the previously-declared LWSX.sha256 -- as a new LwsResource field so binary Repr-Digest needs no blob re-read. Adds DigestFields + DigestFieldsTest; all 160 HalcyonLWS tests pass.
Enumerates every RFC and named specification the Halcyon Project implements in full or in part across its six modules (jena-permissions, halcyon-core, HalcyonLWS, HalcyonLWS-S3, HalcyonMCP, Halcyon), grouped by category with full/partial/vocabulary coverage markers and per-entry evidence. Compiled by scanning source citations, RDF vocabulary namespaces, and declared dependencies.
PATCH on a JSON data resource now accepts application/json-patch+json alongside application/merge-patch+json, applied via jakarta.json Json.createPatch. Per RFC 6902 a failed operation (a test that does not hold, or a remove/replace/move of an absent path) rejects the WHOLE patch with nothing applied -> 409; a body that is not an array is 400; a JSON scalar target is 409. Both formats are advertised via Accept-Patch on JSON data resources (GET/OPTIONS/write responses) and in the storage description PatchSupport capability. Linksets remain merge-patch only. Adds JsonPatchTest (6 tests); all 166 HalcyonLWS tests pass.
It is now supported on JSON data resources (commit b267ea1); remove it from the Notably-NOT-implemented list and add an HTTP row.
…not found)

MCP-17 (1decf0e) inserted the top-level halcyon: rate-limit block into the
middle of the spring: tree, re-parenting spring.h2/datasource/jpa/ssl under
halcyon.*. A fresh install's dumped application.yml therefore set
server.ssl.bundle=server without defining the bundle where Spring looks, and
the very first boot died with NoSuchSslBundleException. Dev boxes never saw it
because INIT only dumps the default when no application.yml exists.

Move the halcyon: block below the spring: tree, and drop the unconditional
server.ssl block entirely: a fresh box has no halcyonkeystore.jks and its
settings.ttl defaults to hal:HTTPS2enabled false, so the connector must come
up plain HTTP (previously it also crashed on the missing keystore even with
correct nesting). SslConfig puts TLS on the connector when HTTPS2 is enabled;
the spring.ssl bundle definition stays for the OIDC client's trust of the
server's own certificate, and is registered lazily so the absent keystore is
harmless while HTTPS is off.

Verified by fresh-booting the rebuilt jar in an empty directory: Wicket home
page answers 200 over plain HTTP on :8888.
MyErrorController returned the view names error401/error403/error500, which
exist nowhere in the tree - no template, no static file, no Wicket mount. Every
Spring-dispatched error therefore fell through view resolution into the static
resource handler and surfaced as 404 "No static resource error500", swallowing
deliberate user-facing sendError messages. The WebID login is the worst case:
its precise diagnostics ("dynamic client registration failed at ...") were
replaced by that riddle.

Render a small self-contained HTML page directly (no view resolution), showing
the real status, the sendError/exception message, and the path - both escaped,
since messages echo request parameters. The JSON error path for API clients is
untouched. Also raise WebIdLoginServlet's begin-failure log from debug to warn,
matching the callback servlet - failed logins are operationally significant.

Verified against the rebuilt jar: GET /webid-login with the feature disabled
now answers "404 Not Found / WebID login is not enabled" instead of the masked
"No static resource error500".
root OFF suppressed everything, including Boot's "APPLICATION FAILED TO START"
report - every startup problem looked like a silent hang (the SSL-bundle crash
and a port-in-use failure both hit this). New defaults: root ERROR everywhere;
com.ebremer.halcyon and com.ebremer.lws at INFO for the one-time/operational
events an operator needs (service startup, LWS storage mounts, Keycloak key
loads, WebID login successes at INFO / failures at WARN); the per-file storage
scanner capped to WARN instead of OFF so its errors still surface. The console
pattern now carries level and logger like the file pattern. Deps embedded under
other com.ebremer packages (beakgraph, vandegraph) stay at root's ERROR.
RFC 9110 15.5.6: the origin server "MUST generate an Allow header field in a
405 response containing a list of the target resource's currently supported
methods". None of the eleven refusals did — a client was told "not that
method" and never told which ones. Touchstone caught it on POST to a data
resource (core/post-to-non-container-405).

Problem.methodNotAllowed now takes the Allow list as a required argument, so
the omission is a compile error rather than something each call site is free
to forget.

The lists come from one place: allowFor(kind, resource), which OPTIONS now
uses too. They had to agree anyway — a client refused a method and then handed
a different list by OPTIONS learns nothing it can act on.

Two corrections fall out of sharing it:

- The storage root no longer advertises DELETE. It is a container, so the old
  code listed it, but delete() refuses the root with a 405 — a promise the
  server would break.
- QUERY on a non-queryable resource resolves the resource through the same
  gate OPTIONS uses, so its Allow is the resource's real list. An agent holding
  no mode on it is told the resource is not there, exactly as OPTIONS answers,
  rather than being handed a method list it may not know about.
…fused

An unconditional PUT to a slug (mirror) storage answered 428 and deleted the
resource anyway. Touchstone caught it on vulcan: create, PUT with no If-Match,
then GET the resource that was supposed to have survived -> 500, and the file
was gone for good.

Both halves were in putMirror:

- mirror.writeAt landed the upload ON the live file (atomic move, REPLACE_
  EXISTING) before the transaction ran. But the transaction is where If-Match
  is compared -- deliberately, so the compare-and-swap happens under TDB2's
  single writer -- so by the time the precondition was found missing, the bytes
  it guards were already replaced.
- The rollback then called content.delete(key), and in a mirror store the key
  IS the path. So the refusal finished by unlinking the resource, leaving a
  registered entry pointing at nothing: GET set Content-Length from the stale
  size, wrote no body, and Jetty answered 500 until the reconciler caught up.

The mirror store now stages instead: bytes are fsynced into a .tmp-{uuid}
beside the target and published by PathKeyedStore.Staged#publish only after the
metadata transaction commits; close() discards them if it never does. writeAt
stays as stage+publish in one step for callers with nothing to commit in
between (the mount tests, and nothing on a request path).

The same defect was in patchContent and createMirror, and both are fixed the
same way -- a refused JSON Patch had exactly the same effect on the document it
could not patch.

This inverts steps 3 and 4 of the documented write order for this storage
alone. The crash window it opens is the harmless one: committed metadata over
the resource's old bytes, which the reconciler corrects from disk, the
direction it already trusts. architecture.md now says so.

The sharded storages were never affected: they mint a fresh key per write, so
a refused swap only ever leaves an unreferenced blob for the orphan sweep.
lws10-core: a non-empty container deleted without recursion "MUST be rejected
with 409 Conflict". Demanding If-Match first made that 409 unreachable for the
client most likely to need it. A plain DELETE of a non-empty container answered
428, and a client that dutifully fetched the entity tag and tried again then
got the 409 that was true the first time — two round trips to learn one fact.

collect() now runs before Preconditions.requireIfMatch, so a request that
cannot be honoured whatever entity tag it carries is refused on its own terms.
Trees deeper than 256 move ahead of the precondition with it.

RFC 9110 13.2.2 states the rule for received preconditions: ignore them when
the request would have failed anyway with something other than 2xx or 412. A
precondition the server DEMANDS is not literally that case, but the reasoning
carries — a conditional is asked for so that a write can be made safe, and this
request is not going to write.

Neither check mutates anything, so this only changes which refusal is reported;
authorization still precedes both, and both still run under the single writer
in the one transaction.

This does not touch the conditional requirement itself: an unconditional DELETE
of an EMPTY resource is still a 428, and a recursive DELETE still needs
If-Match. Touchstone's core/delete-non-empty-container-409 gets past the 409
step and then fails on its unconditional Depth: infinity delete, which is that
separate question.
lws10-core: POST into a container that does not exist "MUST return a 404 error
status unless another status code is more appropriate". Touchstone's
core/create-in-missing-container-404 got 401 on vulcan, whose /alpha/ answers
anonymous GETs of its root with a listing — that agent was not short of
credentials, the container was simply not there.

hidden() is the single answer given both for "does not exist" and for "you hold
nothing on it", and the two have to stay indistinguishable or the pair is an
oracle for walking the URI space. That is unchanged. What changes is which
answer anonymous gets: 401 only where a challenge could actually tell it
something, which is a storage granting the public nothing. Where the public
already has access, authenticating would not change the answer, and 404 is the
honest reply.

The standing test is taken against the storage ROOT, never the resource in
hand. Asked of the resource it would answer 401 for the ones the public may not
see and 404 for the ones that were never there — the very distinction hidden()
exists to erase, rebuilt one level down. Asked of the root it is the same answer
for every URI in the storage, so it discloses nothing about any of them.

A storage seeded by AcpBootstrap grants anonymous nothing and so still
challenges, which is what auth-oidc/anonymous-request-401-challenge pins. A
storage that opens only a sub-container to the public still challenges as well
— the root is the whole test — which is narrower than the spec would like, but
it errs toward the challenge rather than toward disclosure.

This also unblocks the closing steps of core/delete-resource-updates-parent and
core/delete-non-empty-container-409, which both end by GETting a resource they
deleted and expecting 404. Both still fail earlier, on DELETE's demand for
If-Match, which is a separate question.
…es it

The Keycloak stack could not be turned off. :AuthServer had no off position —
absent, it fell back to http://localhost:8888, this server's own address rather
than an authorization server's, so a settings file that never mentioned it still
pointed the OIDC client and the bearer verifier at Halcyon itself and let them
fail at discovery. That is not a disabled subsystem, it is a broken one.

:AuthServer is now the switch. Present, everything is exactly as it was; commented
out, isKeycloakEnabled() is false and nothing Keycloak-shaped is constructed:

- the pac4j KeycloakOidcClient, its Config, the /callback and logout filters, and
  the security filters over getSecuredURLs()/getAdminURLs() (Cool, HalcyonConfigFactory,
  Main) — the Wicket pages fall to HalcyonAuthorizationStrategy alone, off the WebID
  the interactive login seats, which is what it already does for a WebID session
  when Keycloak IS running;
- the /auth/* reverse proxy, unmounted rather than left to 502;
- BearerTokenVerifier in every storage's CredentialChain.

That last one is the reason this has to skip CONSTRUCTION and not merely refuse
tokens. The verifier performs OIDC discovery in its constructor, so building one
against a Keycloak that is not running hangs each storage's startup on a
connection nobody will accept.

The gate is @conditional(KeycloakEnabled.class), reading the HalcyonSettings
singleton — loaded from settings.ttl by INIT.init() in main(), before the context
is built, so it is already there to be asked. It logs once which stack is live.

The challenge's as_uri becomes {site}/webid-login when Keycloak is off. It is
REQUIRED and there is no single authorization server to name: under WebID-OIDC
the issuer is whichever OP the agent's own WebID nominates, unknowable until one
is presented. The interactive login is the one URI on this host that takes an
agent from no credential to a credential.

Four UI affordances go with it — the Login menu item, the Account page's Keycloak
console link, HalcyonSession's Keycloak admin REST block (already unreachable
without a pac4j profile; now it says so), and LogoutLink, which redirected to the
end-session endpoint under the unmounted /auth and would have landed the user on
a 404 having genuinely logged them out.

With both Keycloak and lws-oidc.json off the chain is empty: PUBLIC for a request
with no credential, invalid_token for one with. An unconfigured server
authenticates nobody rather than everybody.

Nothing is deleted. Restoring the :AuthServer line brings all of it back.
Every DELETE was answered 428 until the client had first fetched the resource for
its entity tag. Touchstone failed two MUSTs on it — core/delete-resource-updates-parent
on the plain delete, core/delete-non-empty-container-409 on the Depth: infinity one —
and they were the only two failures left in the core suite.

lws10-core mandates the 428 in exactly two places: an unconditional PUT on an ETagged
resource (put-unconditional-rejected-428), and a PUT or PATCH on a linkset
(linkset-if-match-412-428). Of DELETE it says only "on success, the server MUST respond
with 204 No Content. Servers SHOULD support conditional requests, as defined in
[RFC9110]."

SHOULD *support* is an obligation to honour a validator that arrives, not a licence to
require one, and reading it as a requirement inverted the MUST beside it: the 204 the
spec demands on success was unreachable, because success was. The tell was in
Preconditions' own javadoc, which quoted the PUT clause as the justification for a rule
it was applying to DELETE as well.

checkIfMatch is the honour-if-offered form; requireIfMatch now delegates to it after its
own absence check, so the comparison keeps a single definition and PUT is untouched. The
DELETE path is the only call site that moved: put, commitMirrorPut, putAcr and
patchLinkset all still demand the conditional, and core/put-unconditional-428 still
passes.

The compare-and-swap is undiminished for anyone who wants it. A stale tag is still
refused 412, still compared inside the write transaction, still under TDB2's single
writer. What changes is that not sending one is no longer an error — which was always
the client's call to make.

This also lets the comment above collect() say what it means. It used to note that RFC
9110 13.2.2 "is not literally that case" because the server DEMANDED the precondition;
with the demand gone, a 409 that will be returned whatever the validator says is
literally the case 13.2.2 describes.

docs/lws/http-api.md now separates the two: required on PUT and the linkset/ACR writes,
optional on DELETE.
LwsResource gained a 14th component, sha256, but the two factory helpers
in this test still pass 13 arguments, so the module fails testCompile:

  constructor LwsResource in record com.ebremer.lws.store.LwsResource
  cannot be applied to given types
  reason: actual and formal argument lists differ in length

A record declares exactly one canonical constructor and this one has no
overload, so there is no arity the 13-argument call could satisfy. Both
helpers describe resources with no content digest, so the added argument
is null, matching the component's documented meaning (null for
containers, and for a data resource whose digest was never computed).

This went unnoticed because ci.yml triggers only on master and develop,
while all development lands on next.
Claude Code writes settings.local.json as the machine-local counterpart
to a shared settings.json: it holds the commands approved on this
machine. This one carries a Windows user path, the absolute path of a
separate repository, localhost probe URLs and blanket grants such as
Bash(curl *) and Bash(git rm *), none of which should follow a clone.

Ignoring it rather than committing it matches how the repository already
treats CLAUDE.md, settings.ttl and lws-oidc.json. A shared
.claude/settings.json, if one is ever added, remains tracked.
The workflow watched only master and develop. All current work is on
next, which was 127 commits ahead of master and built by nothing, so
every branch this file covered was dormant and CI proved nothing.

The gap was not theoretical. BeakGraphQueryCapabilityTest kept calling
the 13-argument LwsResource constructor after the record gained a 14th
component (sha256); that testCompile failure sat on next until it was
found by hand. A workflow that had been watching the branch would have
caught it on the push that introduced it.

master and develop stay listed: this adds coverage rather than moving it.
XMP packets arrive inside uploaded images, so the XML XMP.getXMP parses
is attacker-controlled. Both parse sites built a stock
DocumentBuilderFactory with only setNamespaceAware(true), so an external
general entity was resolved and inlined into the //rdf:RDF subtree --
which getXMP then re-serialises and asserts as triples on the resource's
own graph, readable by whoever uploaded it.

Reproduced end to end against this class before the fix. A packet
carrying

  <!DOCTYPE r [ <!ENTITY xxe SYSTEM "file:///path/canary"> ]>
  ... <dc:title>&xxe;</dc:title>

produced exactly one triple whose object was the file's contents:

  [http://victim/store/bomb.tif, dc:title, SECRET-CANARY-...]

The reachable path is an ordinary upload: LwsServlet create ->
LwsMetadataScanner.enrichAsync -> TiffImageReader.getMeta reads TIFF tag
700 and hands the bytes to XMP.getXMP, and ResourceRegistry
.addDiscoveredTypes merges the result into the resource's named graph. So
any file the server process can read -- settings.ttl, keystores, other
tenants' TDB2 files -- was retrievable by uploading a crafted TIFF, and an
http: entity gave SSRF to internal hosts.

Both DocumentBuilderFactory sites now come from
JenaXMLInput.newDocumentBuilderFactory(), which is the hardening Jena
already applies to its own parsers, so the two cannot drift apart. Layered
behaviour, each measured:

  stock factory (before)         entity resolved -> LEAKS
  JenaXMLInput alone             SAXParseException: 'file' access is not
                                 allowed due to accessExternalDTD
  JenaXMLInput + doctype refused SAXParseException: DOCTYPE is disallowed

JenaXMLInput alone closes it; disallowing the doctype rejects a packet
earlier still and is safe because an XMP packet never needs a DTD. The
two TransformerFactory sites that re-serialise the extracted node also
get ACCESS_EXTERNAL_DTD/STYLESHEET cleared, guarded because not every
implementation knows those attributes.

Verified after the fix: the malicious packet yields no triples at all,
and a benign XMP packet still parses to its dc:title.
The engine every LWS authorization passes through was covered by exactly
one policy shape -- a single anyOf matcher granting Read, inherited from
LwsOidcAcpIssuerTest, whose subject is the OIDC issuer chain rather than
the engine. Deny, the allOf/noneOf branches, the inheritance asymmetry,
the fail-closed guards and the time-boxing were all untested, so any of
them could have been deleted or inverted without turning the build red.
That is the wrong safety net to hold while refactoring an authorization
engine, and Theme C of the review proposes doing exactly that.

41 tests over the real engine and a real (temp) TDB2 LwsStore, opened
reflectively as LwsOidcAcpIssuerTest already does. Nothing on the
decision path is mocked. Grouped as: fail-closed guards, matcher
combination (allOf/anyOf/noneOf and their ANDing), deny-beats-allow
including the Write=>Append implication and the append-only inbox,
agent matching (Public/Authenticated/Creator/Owner/WebID), the
client/issuer/vc context attributes, inheritance, time-boxed grants,
and memoisation.

Two of these carry properties a plausible refactor destroys, so both
were mutation-checked rather than merely observed to pass:

  removing the no-matcher guard in satisfied()
    -> aPolicyWithNoMatchersGrantsNobodyNotEveryone fails,
       expected <[]> but was <[READ]> for the ANONYMOUS agent
  making the ancestor walk follow accessControl instead of
  memberAccessControl
    -> 4 Inheritance tests fail, including
       anAncestorsOwnAccessControlMustNotReachTheChild

Two deliberate choices. The memoisation test pins a trap rather than a
feature: the memo is keyed by resource URI alone, so one engine reused
across agents hands the second agent the first's decision. That is why
every write path builds a fresh AcpEngine, and the test records the
reason so the discipline can be relaxed deliberately rather than by
accident. And aNonLiteralBoundThrowsInsteadOfFailingClosed_knownDefectF058
pins current behaviour, not correct behaviour: a non-literal
schema:expires throws LiteralRequiredException instead of failing
closed. Asserting the correct result would make the build red; omitting
it would lose the defect. The test says what to change it to when F058
is fixed.
Five entry points let a caller make this server issue an outbound request,
each guarded differently or not at all: the store-wide /rdf2 endpoint, the
per-resource BeakGraph surface (every .h5 resource URL), the MCP
sparql_query tool, and webhook subscription inboxes. They now share one
rule.

The MCP federation ban was bypassable. Its check walked the parsed
Element tree, which never enters an expression, so a SERVICE nested in
FILTER EXISTS / FILTER NOT EXISTS / BIND(EXISTS{...}) was invisible to
it. Measured across ten query shapes before changing anything: those
three passed straight through while plain, subquery, OPTIONAL, MINUS,
UNION and GRAPH nesting were all caught -- the ban read as working while
being one keyword from bypass, which is the worst state for a security
check. SparqlGuard compiles to the algebra and walks it with an
expression visitor, which puts every SERVICE in one place.

The HTTP endpoints deliberately do NOT ban SERVICE. Commit f4550da added
ServiceHttpClient precisely so a SERVICE back to this server's own origin
completes over its self-signed certificate -- every LWS resource is a
SPARQL endpoint there, so federation is a feature, and banning it would
have deleted working behaviour to fix a different problem. Instead every
SERVICE target goes through the SsrfGuard the OIDC fetches already use,
so loopback, private, link-local and cloud-metadata addresses are
refused while ordinary federation continues. A SERVICE with a variable
endpoint is refused: its target is not knowable until bindings flow,
which is after the point where it could be checked.

The allow-list is read from server configuration only -- the
deployment's :ProxyHostName plus lws-oidc.json's allowedInternalHosts,
the same list the OIDC fetches honour. Deliberately not from the request:
deriving "this server's own host" from the Host header would let a caller
send Host: 169.254.169.254 and thereby allow-list the metadata endpoint,
so the guard would wave through the exact request it exists to stop.

Notifications.subscribe now checks the webhook inbox the same way. An
unchecked inbox is an SSRF that fires on every matching event; it is
refused at subscribe time so the subscriber learns immediately rather
than silently never being called.

Closes F016, F014, C34, C23, F076. Not addressed here: F141 (Zephyr
save.js PUTs to a URL named in stack RDF) and F154 (a Wicket panel acts
on an arbitrary uri parameter) -- both are filed under the same theme but
are client-side and UI-authorization concerns, not this egress path.

51 tests: the nesting table above as a parameterized case per shape, the
internal-address refusals as IP literals so nothing depends on DNS, the
allow-listed-host path that keeps self-federation working, and the
:ProxyHostName parsing -- where a first cut mistook a bracketed IPv6
literal's colons for a port and returned "[::1]:8888", a host string
matching nothing, which would have dropped an IPv6 deployment from its
own allow-list with no error.
All ACP data for a storage shares one graph, and {resource}.acr is a
client-writable window onto it. Four ways that scope leaked.

AcrStore.replace required the submitted ACR to declare its target but
never forbade it declaring others. AcpEngine resolves a resource's ACRs
by acp:resource alone -- it does not require the subject to be typed, or
to be named {resource}.acr -- so one extra object on that property, or
one on any node hung off the ACR, aimed the submitter's policies at a
resource they were never given Control over. Every acp:resource triple
must now be exactly the one claim the ACR is entitled to make, and
nothing may declare a second AccessControlResource in the same write.
(F009.)

replace also spliced the submitted model in wholesale, whatever its
subjects were, so a PUT could carry a triple about ANOTHER resource's
ACR node and edit policy it does not control, or leave detached policy
behind to surface later. Every triple must now be reachable from the ACR
node -- the same rule read() uses to carve the ACR back out, so anything
it would refuse to serve it also refuses to store. (F010.)

purge reached only {resource}.acr. An access grant installs its policy
under its own node (urn:lws:grantacr:{id}-{n}) carrying acp:resource
<target>, and that separation is deliberate: it is what lets a grant
survive the owner rewriting their own ACR. On delete it was a leak --
the grant outlived the resource, and under the slug naming policy the
URI is reused, so re-creating the same name inherited a stranger's
grant. Delete now purges every ACR targeting the resource, one at a
time so the existing reference count still protects nodes shared with a
survivor. (F059.)

Revoking a grant removed the graph REACHABLE from the grant's ACR node,
and that walk went through the matcher's acp:agent object -- a URI the
requester supplies as the assignee. Naming the storage root's ACR there
and then deleting one's own grant took the root's whole policy tree with
it: authorization for the entire storage destroyed by an authenticated
agent with Control over a single resource of their own. Reachability was
never the right rule here; installPolicy mints a closed node set and its
javadoc already promised revocation "removes exactly this and nothing
else". Enumerating those four nodes keeps that promise by construction,
so nothing a client writes can widen what a revocation deletes.
MAX_CLOSURE_HOPS and three imports go with the walk. (F061.)

Evidence rather than assertion. AcrStoreTest failed 4 of 9 against the
unfixed code -- one failure per defect above -- and passes 9 of 9 after.
The revocation fix was mutation-checked separately: restoring the
reachability walk fails revokingAGrantCannotDeleteTheStorageRootsPolicy
with "expected: <[READ]> but was: <[]>", the root policy gone.
AcpEngineTest (41) and SparqlGuardTest (32) stayed green throughout,
which is why those went in first. Full verify clean: HalcyonLWS 255,
Halcyon 85, HalcyonMCP 70, no failures anywhere -- the tightened
validation refuses nothing the codebase legitimately writes.
The pac4j filter guarding the IIIF servlet was registered on "/iiif*/".
That is not a legal servlet mapping -- a glob may only sit at the end of
a prefix spec -- and it never said so, because of an asymmetry in Jetty:
a SERVLET mapping is parsed through ServletPathSpec's constructor, which
rejects an illegal spec at startup, but a FILTER mapping is kept as a raw
String and matched with the static ServletPathSpec.match. So the illegal
pattern was accepted silently and degraded to an exact match on its own
seven literal characters, which no request path can be. ImageServer at
"/iiif/*" was unguarded, with no error, no warning and no log line at any
point. "/f*" was dead the same way (Jetty parses it as EXACT and only
warns, from a constructor filters never reach), and "/invalidateSession"
was legal but covered nothing under the "/invalidateSession/*" its
servlet is actually mounted on.

Two changes, because either alone leaves the hole.

UrlPatternRules + UrlMappingAudit refuse to start on a pattern that
cannot do its job: assertLegal for the form, and assertCovers for the
case legality cannot catch -- a pattern that names a servlet's URL space
without covering it. Both throw rather than warn. The defect being
prevented produced no observable signal for however long it existed, so
a log line would simply join the same silence; a server that cannot
enforce the access control it was configured with should not accept
traffic. UrlMappingAudit is deliberately NOT @conditional: every bean in
Cool is @conditional(KeycloakEnabled) and the checked-in settings.ttl
declares no :AuthServer, so a check hosted beside the filters would never
run in the configuration this project ships.

ImageServer's classic path now resolves the session itself and answers
401 without one. The filter cannot be the only gate precisely because it
is conditional on Keycloak, and with the shipped settings it does not
exist at all. The check sits AFTER the LWS forward and is skipped when
source != null: those are the bridge re-entering once LwsServlet has run
storage confinement and demandOn(acl:Read), and the forward handing an
LWS identifier to the owning storage's .iiif capability, whose ACP
decision may legitimately permit an anonymous reader. Requiring a session
there would override a storage's own policy.

The patterns themselves become "/iiif/*" and "/invalidateSession/*", and
the dead "/f*" is removed.

Closes F008 and the C24 dead/under-covering entries. 20 tests over the
rules, including the exact strings that failed. Full verify clean across
all six modules -- which is the check that mattered here, since the audit
now runs against every registered pattern in the application at every
boot, not just the two lists corrected above.
aea06e7 shipped two defects that no test in this repository could see,
and both were found by launching the server.

First, the @bean method inside UrlMappingAudit was named after its own
enclosing class. A @configuration class is registered under its
decapitalised simple name, so 'urlMappingAudit' was defined twice and the
context refused to refresh. Renamed to securityUrlPatternAudit.

Second, and worse in principle: the audit applied its secured-URL rule --
"a pattern must not cover the whole application" -- to every servlet
registration it could see, including Spring's own dispatcherServlet,
which is correctly mapped on "/". A change meant to make the server
refuse unsafe configuration instead made it refuse a correct one. Those
are two different questions and they now have two methods. assertLegal
checks FORM only, where "/" and "/*" are legal and are what a dispatcher
and a framework filter are normally registered on; assertGuardable adds
the strict rules and is applied only to the lists this project maintains,
URLControl.getSecuredURLs() and getAdminURLs().

The reason both reached a real launch is worth recording, because it is
not specific to this class: nothing in this repository starts a Spring
context. The full 29,000-test verify passed before and after aea06e7 and
could not have caught either defect -- a suite that exercises logic
exhaustively and wiring not at all. UrlMappingAuditContextTest is the
first test here that refreshes a context. It covers both shapes: a
refresh that must succeed, and a registration set containing a
dispatcherServlet on "/" alongside an imageServer on "/iiif/*".
Deliberately not @SpringBootTest -- that would start Jetty, TDB2 and the
LWS storages; registering the one configuration class catches the whole
class of defect in milliseconds.

It also caught a third defect, in the test: asserting "exactly one
SmartInitializingSingleton" counted Spring's own
EventListenerMethodProcessor, which implements that interface. The
assertion now names the bean it means instead of counting the type.
Jena 6.2.0 reorganised its test artifact, so the version bump and the
migration have to land together -- the bump alone does not compile.

Every main source in all six modules builds against 6.2.0 unchanged,
which is the result that matters for a vendored jena-permissions: no
Jena interface grew a method the fork would have to wrap, and
SecuredItemInvokerCoverageTest -- the build-time reflection sweep over
the thirteen proxied types, which exists precisely to turn that event
into a build failure rather than a runtime surprise -- passes.

What moved, all of it in the jena-core tests jar:

  org.apache.jena.graph.test.*             -> org.apache.jena.graph.*
  org.apache.jena.rdf.model.test.*         -> org.apache.jena.rdf.model.*
  org.apache.jena.rdf.model.test.helpers.* -> org.apache.jena.rdf.model.helpers.*
  TestingModelFactory                      -> ModelCreator
  TS3_Model1.PlainModelFactory             -> deleted; ModelCreator.plain
  AbstractTestPackage                      -> concrete, takes a ModelCreator

The interface change needed a judgement rather than a rename.
TestingModelFactory declared createModel(), getPrefixMapping() and
createModel(Graph); ModelCreator extends Creator<Model> and declares only
create(). Both dropped methods were checked before deleting them, because
this is the security fork and a quietly-removed method is exactly how a
test stops testing anything: getPrefixMapping() delegated straight to the
model it creates, and createModel(Graph) wrapped a caller-supplied graph
WITHOUT securing it. Neither did security work. The secured construction
in create() is unchanged and is the only load-bearing part.

Spring AI 2.0.0 -> 2.0.1 rides along; the pom's own policy asks for a
review on a minor or major, and this is a patch.

Full reactor verify clean: 29,518 tests, no failures, across all seven
modules. jena-permissions itself runs 29,040, up 54 from 6.1.0 because
the migrated upstream classes contribute more cases.

Not fixed here, and pre-existing: SecTestLiterals, SecTestReaders and
SecTestReaderEvents pass a PLAIN, unsecured model creator, so they run
upstream's suite against an ordinary model and exercise none of this
fork's security layer. That was true before the migration
(TS3_Model1.PlainModelFactory was equally plain) and preserving it was
deliberate -- changing what a test tests does not belong in a build fix.
It is the same shape as review finding F101/F167.
…ng it

24e539f refused SERVICE with a variable endpoint outright, on the
reasoning that its target is unknown until bindings flow and so cannot be
checked before the request is made. That was safe and wrong. It broke a
legitimate query:

  select distinct ?g where {
    graph ?g { ?s as:mediaType "application/x-hdf5" }
    service ?g { ?sx exif:width ?w ; exif:height ?h } }

which answered 400. Every LWS resource on this server IS a SPARQL
endpoint, so "graph ?g { ... } service ?g { ... }" is the natural way to
ask one question of every matching resource -- the same capability
f4550da added ServiceHttpClient to support. A blanket refusal deleted a
working feature to close a hole that can be closed properly.

The target is knowable, just later than the static check looks. Jena's
service executor chain hands a ChainingServiceExecutor both the original
OpService and the SUBSTITUTED one, with the variable already resolved
against the current binding, immediately before the request is made.
SparqlServiceEgressExecutor checks there, against the same SsrfGuard and
the same allow-list a constant endpoint gets, and is installed at startup
beside ServiceHttpClient.

This is strictly better than what it replaces, not merely less strict: it
runs once per resolved target rather than once per query, and it catches
an endpoint derived through VALUES or a sub-select, which no static check
of the query text could see. SparqlGuard.checkEgress now passes over a
null target rather than throwing, and says why; serviceTargets still
reports it as null and federates() still counts it, so the MCP tool ban
(refuseFederation) refuses a variable endpoint exactly as before.

The two halves must ship together: without the executor installed, a
variable endpoint is unchecked. Both files say so.

SparqlGuardTest gains the graph-then-service idiom that regressed and
replaces the assertion that pinned the wrong behaviour. 33 tests there;
full reactor verify clean at 29,518.
as: is what the LWS media-type triples use (as:mediaType), so it belongs
in the editor's prefix list alongside sdo:, hal: and rdfs:.
@ebremer
ebremer merged commit 50d358c into master Sep 2, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant