You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@causeway.apache.org by da...@apache.org on 2026/08/18 08:27:34 UTC

(causeway) branch htmx updated (8b2f7bdd9f8 -> a6f8242d7cd)

This is an automated email from the ASF dual-hosted git repository.

danhaywood pushed a change to branch htmx
in repository https://gitbox.apache.org/repos/asf/causeway.git


    from 8b2f7bdd9f8 archives rich GraphQL object interaction correctness
     new 4111a57230f promotes rich GraphQL resource link safety proposal
     new 96176b2fc3a fixes rich GraphQL resource link safety
     new a6f8242d7cd archives rich GraphQL resource link safety

The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../pages/sections/causeway.viewer.graphql.adoc    |  29 +-
 .../core/config/CausewayConfiguration.java         |  46 +++-
 .../.openspec.yaml                                 |   0
 .../design.md                                      |  14 +-
 .../proposal.md                                    |   0
 .../rich-graphql-resource-link-safety/spec.md      |   0
 .../tasks.md                                       |  29 ++
 .../planned-changes/web-component-viewer/README.md |   7 +-
 .../fix-rich-graphql-resource-link-safety/tasks.md |  29 --
 .../rich-graphql-resource-link-safety/spec.md      |   8 +-
 viewers/graphql/adoc/modules/ROOT/pages/about.adoc |  27 +-
 .../model/domain/common/query/CommonMeta.java      |   9 +-
 .../model/domain/common/query/ResourcePath.java    | 122 ++++++++
 .../common/query/meta/CommonMetaFetcher.java       |  14 +-
 .../domain/rich/query/RichPropertyGetBlob.java     |  13 +-
 .../rich/query/RichPropertyGetBlobBytes.java       |   8 +-
 .../domain/rich/query/RichPropertyGetClob.java     |   7 +-
 .../rich/query/RichPropertyGetClobAbstract.java    |   8 +-
 .../rich/query/RichPropertyGetClobChars.java       |   8 +-
 .../rich/query/RichPropertyGetClobMimeType.java    |   2 +-
 .../domain/rich/query/RichPropertyGetClobName.java |   4 +-
 .../model/domain/simple/query/SimpleProperty.java  |  18 +-
 .../simple/query/SimplePropertyLobAbstract.java    |   8 +-
 .../simple/query/SimplePropertyLobBytes.java       |   8 +-
 .../simple/query/SimplePropertyLobChars.java       |   8 +-
 .../simple/query/SimplePropertyLobMimeType.java    |   2 +-
 .../domain/simple/query/SimplePropertyLobName.java |   4 +-
 .../domain/common/query/ResourcePathTest.java      | 118 ++++++++
 .../viewer/test/domain/dept/StaffMember.java       |  18 ++
 .../special/ResourceDeploymentPath_IntegTest.java  | 105 +++++++
 .../test/e2e/special/ResourcePolicy_IntegTest.java | 141 ++++++++++
 ...member_by_name_and_download_photo.approved.json |  26 --
 ...dsStaffMemberAndSafelyDownloadsResources._.gql} |   9 +
 ...MemberAndSafelyDownloadsResources.approved.json |  35 +++
 .../viewer/test/e2e/special/Staff_2_IntegTest.java | 130 +++++----
 ...eate_staff_member_with_department.approved.json |   2 +-
 ...gTest.each.list_all_staff_members.approved.json |  10 +-
 viewers/graphql/test/src/test/resources/schema.gql |  50 ++++
 .../viewer/controller/ResourceController.java      | 306 +++++++++++++--------
 39 files changed, 1087 insertions(+), 295 deletions(-)
 rename openspec/{planned-changes/web-component-viewer/fix-rich-graphql-resource-link-safety => changes/archive/2026-08-18-fix-rich-graphql-resource-link-safety}/.openspec.yaml (100%)
 rename openspec/{planned-changes/web-component-viewer/fix-rich-graphql-resource-link-safety => changes/archive/2026-08-18-fix-rich-graphql-resource-link-safety}/design.md (84%)
 rename openspec/{planned-changes/web-component-viewer/fix-rich-graphql-resource-link-safety => changes/archive/2026-08-18-fix-rich-graphql-resource-link-safety}/proposal.md (100%)
 copy openspec/{planned-changes/web-component-viewer/fix-rich-graphql-resource-link-safety => changes/archive/2026-08-18-fix-rich-graphql-resource-link-safety}/specs/rich-graphql-resource-link-safety/spec.md (100%)
 create mode 100644 openspec/changes/archive/2026-08-18-fix-rich-graphql-resource-link-safety/tasks.md
 delete mode 100644 openspec/planned-changes/web-component-viewer/fix-rich-graphql-resource-link-safety/tasks.md
 rename openspec/{planned-changes/web-component-viewer/fix-rich-graphql-resource-link-safety => }/specs/rich-graphql-resource-link-safety/spec.md (91%)
 create mode 100644 viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/ResourcePath.java
 create mode 100644 viewers/graphql/model/src/test/java/org/apache/causeway/viewer/graphql/model/domain/common/query/ResourcePathTest.java
 create mode 100644 viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/ResourceDeploymentPath_IntegTest.java
 create mode 100644 viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/ResourcePolicy_IntegTest.java
 delete mode 100644 viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/Staff_2_IntegTest.find_staff_member_by_name_and_download_photo.approved.json
 rename viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/{Staff_2_IntegTest.find_staff_member_by_name_and_download_photo._.gql => Staff_2_IntegTest.findsStaffMemberAndSafelyDownloadsResources._.gql} (67%)
 create mode 100644 viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/Staff_2_IntegTest.findsStaffMemberAndSafelyDownloadsResources.approved.json


(causeway) 01/03: promotes rich GraphQL resource link safety proposal

Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

danhaywood pushed a commit to branch htmx
in repository https://gitbox.apache.org/repos/asf/causeway.git

commit 4111a57230f3e9dfe8c1d9451c132972d55543cb
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Mon Aug 17 23:38:19 2026 +0100

    promotes rich GraphQL resource link safety proposal
---
 .../fix-rich-graphql-resource-link-safety/.openspec.yaml           | 0
 .../fix-rich-graphql-resource-link-safety/design.md                | 0
 .../fix-rich-graphql-resource-link-safety/proposal.md              | 0
 .../specs/rich-graphql-resource-link-safety/spec.md                | 0
 .../fix-rich-graphql-resource-link-safety/tasks.md                 | 0
 openspec/planned-changes/web-component-viewer/README.md            | 7 ++++---
 6 files changed, 4 insertions(+), 3 deletions(-)

diff --git a/openspec/planned-changes/web-component-viewer/fix-rich-graphql-resource-link-safety/.openspec.yaml b/openspec/changes/fix-rich-graphql-resource-link-safety/.openspec.yaml
similarity index 100%
rename from openspec/planned-changes/web-component-viewer/fix-rich-graphql-resource-link-safety/.openspec.yaml
rename to openspec/changes/fix-rich-graphql-resource-link-safety/.openspec.yaml
diff --git a/openspec/planned-changes/web-component-viewer/fix-rich-graphql-resource-link-safety/design.md b/openspec/changes/fix-rich-graphql-resource-link-safety/design.md
similarity index 100%
rename from openspec/planned-changes/web-component-viewer/fix-rich-graphql-resource-link-safety/design.md
rename to openspec/changes/fix-rich-graphql-resource-link-safety/design.md
diff --git a/openspec/planned-changes/web-component-viewer/fix-rich-graphql-resource-link-safety/proposal.md b/openspec/changes/fix-rich-graphql-resource-link-safety/proposal.md
similarity index 100%
rename from openspec/planned-changes/web-component-viewer/fix-rich-graphql-resource-link-safety/proposal.md
rename to openspec/changes/fix-rich-graphql-resource-link-safety/proposal.md
diff --git a/openspec/planned-changes/web-component-viewer/fix-rich-graphql-resource-link-safety/specs/rich-graphql-resource-link-safety/spec.md b/openspec/changes/fix-rich-graphql-resource-link-safety/specs/rich-graphql-resource-link-safety/spec.md
similarity index 100%
rename from openspec/planned-changes/web-component-viewer/fix-rich-graphql-resource-link-safety/specs/rich-graphql-resource-link-safety/spec.md
rename to openspec/changes/fix-rich-graphql-resource-link-safety/specs/rich-graphql-resource-link-safety/spec.md
diff --git a/openspec/planned-changes/web-component-viewer/fix-rich-graphql-resource-link-safety/tasks.md b/openspec/changes/fix-rich-graphql-resource-link-safety/tasks.md
similarity index 100%
rename from openspec/planned-changes/web-component-viewer/fix-rich-graphql-resource-link-safety/tasks.md
rename to openspec/changes/fix-rich-graphql-resource-link-safety/tasks.md
diff --git a/openspec/planned-changes/web-component-viewer/README.md b/openspec/planned-changes/web-component-viewer/README.md
index ae943e37b61..ce0f645f6f2 100644
--- a/openspec/planned-changes/web-component-viewer/README.md
+++ b/openspec/planned-changes/web-component-viewer/README.md
@@ -3,10 +3,11 @@
 These are fully drafted follow-on changes for the Causeway web-component viewer programme.
 They are held outside `openspec/changes/` because the repository permits only one active OpenSpec change at a time.
 The foundation change `establish-graphql-web-component-foundation`, read-only change `add-read-only-domain-web-components`, interaction change `add-domain-web-component-interactions`, analysis change `analyze-rich-graphql-referenceapp-coverage`, and first P0 change `fix-rich-graphql-object-interaction-correctness` are archived.
-The remaining drafts stay queued until promoted in order.
+The second P0 change, `fix-rich-graphql-resource-link-safety`, is now active.
+The remaining drafts stay queued until that change is implemented and archived.
 
 Each child directory is a complete planned change containing `.openspec.yaml`, `proposal.md`, `design.md`, `tasks.md`, and delta specifications.
-Promote the next draft verbatim:
+After the active change is archived, promote the next draft verbatim:
 
 ```shell
 git mv openspec/planned-changes/web-component-viewer/<name> openspec/changes/<name>
@@ -25,7 +26,7 @@ Matrix entry references point to `viewers/graphql/adoc/modules/ROOT/examples/ref
 | 2 | `add-domain-web-component-interactions` *(archived)* | Complete | Archived read-only components | MODIFIED `domain-web-components` |
 | 3 | `analyze-rich-graphql-referenceapp-coverage` *(archived; analysis only)* | Complete | Archived foundation and components; pinned reference application | NEW `rich-graphql-referenceapp-analysis` |
 | 4 | `fix-rich-graphql-object-interaction-correctness` *(archived)* | Complete | Completed analysis | NEW `rich-graphql-object-interaction-correctness` |
-| 5 | `fix-rich-graphql-resource-link-safety` | P0 | Completed analysis | NEW `rich-graphql-resource-link-safety` |
+| 5 | `fix-rich-graphql-resource-link-safety` *(active)* | P0 | Completed analysis | NEW `rich-graphql-resource-link-safety` |
 | 6 | `add-rich-graphql-value-and-resource-semantics` | P1 | Object-interaction correctness and resource-link safety | NEW `rich-graphql-value-semantics` |
 | 7 | `add-rich-graphql-collection-windowing` | P1 | Object-interaction correctness | NEW `rich-graphql-collection-windowing` |
 | 8 | `add-rich-graphql-application-entry-points` | P1 | Object-interaction correctness and resource-link safety | NEW `rich-graphql-application-entry-points` |


(causeway) 02/03: fixes rich GraphQL resource link safety

Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

danhaywood pushed a commit to branch htmx
in repository https://gitbox.apache.org/repos/asf/causeway.git

commit 96176b2fc3aba2b658f3a2cd81fc3fe575b809a5
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Tue Aug 18 06:43:27 2026 +0100

    fixes rich GraphQL resource link safety
---
 .../pages/sections/causeway.viewer.graphql.adoc    |  29 +-
 .../core/config/CausewayConfiguration.java         |  46 +++-
 .../design.md                                      |  14 +-
 .../fix-rich-graphql-resource-link-safety/tasks.md |  30 +-
 viewers/graphql/adoc/modules/ROOT/pages/about.adoc |  27 +-
 .../model/domain/common/query/CommonMeta.java      |   9 +-
 .../model/domain/common/query/ResourcePath.java    | 122 ++++++++
 .../common/query/meta/CommonMetaFetcher.java       |  14 +-
 .../domain/rich/query/RichPropertyGetBlob.java     |  13 +-
 .../rich/query/RichPropertyGetBlobBytes.java       |   8 +-
 .../domain/rich/query/RichPropertyGetClob.java     |   7 +-
 .../rich/query/RichPropertyGetClobAbstract.java    |   8 +-
 .../rich/query/RichPropertyGetClobChars.java       |   8 +-
 .../rich/query/RichPropertyGetClobMimeType.java    |   2 +-
 .../domain/rich/query/RichPropertyGetClobName.java |   4 +-
 .../model/domain/simple/query/SimpleProperty.java  |  18 +-
 .../simple/query/SimplePropertyLobAbstract.java    |   8 +-
 .../simple/query/SimplePropertyLobBytes.java       |   8 +-
 .../simple/query/SimplePropertyLobChars.java       |   8 +-
 .../simple/query/SimplePropertyLobMimeType.java    |   2 +-
 .../domain/simple/query/SimplePropertyLobName.java |   4 +-
 .../domain/common/query/ResourcePathTest.java      | 118 ++++++++
 .../viewer/test/domain/dept/StaffMember.java       |  18 ++
 .../special/ResourceDeploymentPath_IntegTest.java  | 105 +++++++
 .../test/e2e/special/ResourcePolicy_IntegTest.java | 141 ++++++++++
 ...member_by_name_and_download_photo.approved.json |  26 --
 ...dsStaffMemberAndSafelyDownloadsResources._.gql} |   9 +
 ...MemberAndSafelyDownloadsResources.approved.json |  35 +++
 .../viewer/test/e2e/special/Staff_2_IntegTest.java | 130 +++++----
 ...eate_staff_member_with_department.approved.json |   2 +-
 ...gTest.each.list_all_staff_members.approved.json |  10 +-
 viewers/graphql/test/src/test/resources/schema.gql |  50 ++++
 .../viewer/controller/ResourceController.java      | 306 +++++++++++++--------
 33 files changed, 1062 insertions(+), 277 deletions(-)

diff --git a/core/config/src/main/adoc/modules/config/pages/sections/causeway.viewer.graphql.adoc b/core/config/src/main/adoc/modules/config/pages/sections/causeway.viewer.graphql.adoc
index 64a42a87a3b..bd335eae6d6 100644
--- a/core/config/src/main/adoc/modules/config/pages/sections/causeway.viewer.graphql.adoc
+++ b/core/config/src/main/adoc/modules/config/pages/sections/causeway.viewer.graphql.adoc
@@ -91,13 +91,40 @@ target-arg-name
 | null
 
 
+|
+[[causeway.viewer.graphql.resources.external-path-prefix]]
+causeway.viewer.graphql.resources. +
+external-path-prefix
+
+|
+| null
+
+
 |
 [[causeway.viewer.graphql.resources.response-type]]
 causeway.viewer.graphql.resources. +
 response-type
 
 |  FORBIDDEN
-| null
+| Deprecated compatibility fallback; prefer the category-specific settings.
+
+
+|
+[[causeway.viewer.graphql.resources.structural-metadata-response-type]]
+causeway.viewer.graphql.resources. +
+structural-metadata-response-type
+
+|
+| Falls back to `response-type` when unset.
+
+
+|
+[[causeway.viewer.graphql.resources.value-content-response-type]]
+causeway.viewer.graphql.resources. +
+value-content-response-type
+
+|
+| Falls back to `response-type` when unset.
 
 
 |
diff --git a/core/config/src/main/java/org/apache/causeway/core/config/CausewayConfiguration.java b/core/config/src/main/java/org/apache/causeway/core/config/CausewayConfiguration.java
index 6fb47b4ad5a..d48abecbe71 100644
--- a/core/config/src/main/java/org/apache/causeway/core/config/CausewayConfiguration.java
+++ b/core/config/src/main/java/org/apache/causeway/core/config/CausewayConfiguration.java
@@ -2625,16 +2625,48 @@ public enum ResponseType {
 
             public record Resources(
                 /**
-                 * How resources ({@link org.apache.causeway.applib.value.Blob} bytes,
-                 * {@link org.apache.causeway.applib.value.Clob} chars, grids and icons) can be downloaded from the
-                 * resource controller.
+                 * Compatibility setting used for a resource category whose category-specific response type is not
+                 * configured.
                  *
-                 * <p>By default the download of these resources if {@link ResponseType#FORBIDDEN}, but alternatively
-                 * they can be enabled to download either {@link ResponseType#DIRECT}ly or as an
-                 * {@link ResponseType#ATTACHMENT}.
+                 * <p>By default resources are {@link ResponseType#FORBIDDEN}.
+                 * Prefer {@link #structuralMetadataResponseType()} and {@link #valueContentResponseType()} for new
+                 * applications.
                  */
+                @Deprecated
                 @DefaultValue("FORBIDDEN")
-                ResponseType responseType) {
+                ResponseType responseType,
+                /**
+                 * How effective grids, icons, and other structural metadata resources are returned.
+                 * If unset, temporarily falls back to {@link #responseType()}.
+                 */
+                @Nullable
+                ResponseType structuralMetadataResponseType,
+                /**
+                 * How {@link org.apache.causeway.applib.value.Blob} bytes and
+                 * {@link org.apache.causeway.applib.value.Clob} characters are returned.
+                 * If unset, temporarily falls back to {@link #responseType()}.
+                 */
+                @Nullable
+                ResponseType valueContentResponseType,
+                /**
+                 * Optional externally visible path prefix for a deployment behind a path-rewriting reverse proxy.
+                 * The prefix is combined with the servlet context and configured GraphQL endpoint path when links are
+                 * published.
+                 */
+                @Nullable
+                String externalPathPrefix) {
+
+                public ResponseType effectiveStructuralMetadataResponseType() {
+                    return structuralMetadataResponseType != null
+                            ? structuralMetadataResponseType
+                            : Objects.requireNonNullElse(responseType, ResponseType.FORBIDDEN);
+                }
+
+                public ResponseType effectiveValueContentResponseType() {
+                    return valueContentResponseType != null
+                            ? valueContentResponseType
+                            : Objects.requireNonNullElse(responseType, ResponseType.FORBIDDEN);
+                }
             }
 
             public record Authentication(
diff --git a/openspec/changes/fix-rich-graphql-resource-link-safety/design.md b/openspec/changes/fix-rich-graphql-resource-link-safety/design.md
index e9ebe1aae91..f843c3669d3 100644
--- a/openspec/changes/fix-rich-graphql-resource-link-safety/design.md
+++ b/openspec/changes/fix-rich-graphql-resource-link-safety/design.md
@@ -33,7 +33,7 @@ The same global response policy controls effective grids and icons, which are st
 GraphQL publishes an origin-relative path beginning with exactly one slash, or another documented relative-reference form that resolves against the current application origin.
 It does not publish a protocol-relative authority.
 
-URL construction uses the configured GraphQL endpoint and deployment base path as path components rather than string concatenation.
+URL construction uses `spring.graphql.http.path`, with temporary fallback to deprecated `spring.graphql.path`, together with the servlet context and configured external reverse-proxy prefix as path components rather than string concatenation.
 Tests cover root deployment, servlet context, reverse-proxy prefix, encoded object identity, and non-default GraphQL endpoint paths.
 
 ### Separate metadata and value-content policy
@@ -48,10 +48,11 @@ Enabling value content does not bypass object or member authorization.
 
 ### Do not advertise forbidden references
 
-When a category is statically forbidden, its optional resource field is absent from the generated shape or resolves to null according to the selected additive compatibility design.
+When a category is statically forbidden, its optional resource field is absent from the generated schema.
+Blob and Clob descriptive name and media-type fields remain available while their `bytes` or `chars` link field is omitted.
 It never publishes a dereferenceable or object-bearing URL that is guaranteed to fail by policy.
 
-The exact migration shape is selected by schema compatibility tests.
+Schema compatibility tests pin this omission behavior.
 The behavior and policy are discoverable without attempting a download.
 
 ### Authorize every dereference
@@ -78,11 +79,12 @@ Malformed, stale, unauthorized, and forbidden requests return bounded statuses w
 ## Migration Plan
 
 Introduce separate metadata and value-content settings while reading the existing global resource response type as a temporary compatibility fallback.
+Both categories therefore retain the legacy default of `FORBIDDEN` until explicitly enabled.
 Correct generated paths for all enabled categories.
 Deprecate the global setting after applications can select each category explicitly.
 Document that clients must treat returned references as opaque same-origin references rather than rewriting slash prefixes.
 
-## Open Questions
+## Resolved Questions
 
-- Whether forbidden optional fields should be omitted at schema construction or retained as documented null fields during the compatibility period.
-- Whether authorized structural metadata should default to direct response or remain explicitly enabled.
+- Forbidden optional link fields are omitted at schema construction while Blob and Clob descriptive fields remain available.
+- Structural metadata remains forbidden through the legacy default unless its category-specific setting explicitly enables it.
diff --git a/openspec/changes/fix-rich-graphql-resource-link-safety/tasks.md b/openspec/changes/fix-rich-graphql-resource-link-safety/tasks.md
index 7aca9f325f2..e5038604a08 100644
--- a/openspec/changes/fix-rich-graphql-resource-link-safety/tasks.md
+++ b/openspec/changes/fix-rich-graphql-resource-link-safety/tasks.md
@@ -1,29 +1,29 @@
 ## 1. Pin Resource Evidence
 
-- [ ] 1.1 Translate matrix entries `REF-RESOURCE-02`, `REF-RESOURCE-03`, and `REF-LAYOUT-01` into reduced deterministic tests.
-- [ ] 1.2 Record compatibility behavior for the existing global response-type setting and manually normalized clients.
+- [x] 1.1 Translate matrix entries `REF-RESOURCE-02`, `REF-RESOURCE-03`, and `REF-LAYOUT-01` into reduced deterministic tests.
+- [x] 1.2 Record compatibility behavior for the existing global response-type setting and manually normalized clients.
 
 ## 2. Correct Resource References
 
-- [ ] 2.1 Replace slash concatenation with deployment-aware endpoint path construction.
-- [ ] 2.2 Verify exactly one same-origin path prefix under root, servlet-context, reverse-proxy, and non-default endpoint deployments.
-- [ ] 2.3 Preserve opaque encoded identities without double encoding or content disclosure.
+- [x] 2.1 Replace slash concatenation with deployment-aware endpoint path construction.
+- [x] 2.2 Verify exactly one same-origin path prefix under root, servlet-context, reverse-proxy, and non-default endpoint deployments.
+- [x] 2.3 Preserve opaque encoded identities without double encoding or content disclosure.
 
 ## 3. Separate and Enforce Policy
 
-- [ ] 3.1 Add independent structural-metadata and value-content resource policy.
-- [ ] 3.2 Define migration behavior for the existing global resource response type.
-- [ ] 3.3 Omit or null forbidden resource capabilities without publishing unusable object-bearing references.
-- [ ] 3.4 Recheck authentication, authorization, target identity, member visibility, and category policy on every dereference.
+- [x] 3.1 Add independent structural-metadata and value-content resource policy.
+- [x] 3.2 Define migration behavior for the existing global resource response type.
+- [x] 3.3 Omit or null forbidden resource capabilities without publishing unusable object-bearing references.
+- [x] 3.4 Recheck authentication, authorization, target identity, member visibility, and category policy on every dereference.
 
 ## 4. HTTP and Security Verification
 
-- [ ] 4.1 Add grid, icon, Blob, and Clob success tests with media type, length, and cache behavior.
-- [ ] 4.2 Add malformed, stale, hidden, unauthorized, and forbidden tests proving bounded non-disclosing responses.
-- [ ] 4.3 Add diagnostics and logging tests proving resource content and unredacted identifiers are absent.
+- [x] 4.1 Add grid, icon, Blob, and Clob success tests with media type, length, and cache behavior.
+- [x] 4.2 Add malformed, stale, hidden, unauthorized, and forbidden tests proving bounded non-disclosing responses.
+- [x] 4.3 Add diagnostics and logging tests proving resource content and unredacted identifiers are absent.
 
 ## 5. Compatibility and Documentation
 
-- [ ] 5.1 Select field omission or nullable-field migration behavior through schema compatibility tests.
-- [ ] 5.2 Document path resolution, policy categories, defaults, migration aliases, authorization, caching, and client treatment of opaque references.
-- [ ] 5.3 Run GraphQL model and viewer tests, resource-controller tests, reference-derived fixtures, documentation checks, formatting, and strict OpenSpec validation.
+- [x] 5.1 Select field omission or nullable-field migration behavior through schema compatibility tests.
+- [x] 5.2 Document path resolution, policy categories, defaults, migration aliases, authorization, caching, and client treatment of opaque references.
+- [x] 5.3 Run GraphQL model and viewer tests, resource-controller tests, reference-derived fixtures, documentation checks, formatting, and strict OpenSpec validation.
diff --git a/viewers/graphql/adoc/modules/ROOT/pages/about.adoc b/viewers/graphql/adoc/modules/ROOT/pages/about.adoc
index a63680e93bf..544e27d9fca 100644
--- a/viewers/graphql/adoc/modules/ROOT/pages/about.adoc
+++ b/viewers/graphql/adoc/modules/ROOT/pages/about.adoc
@@ -700,7 +700,7 @@ This will result in a response (for the rich schema) such as:
             "results" : {
               "photo" : {
                 "get" : {
-                  "bytes" : "///graphql/object/university.dept.StaffMember:123/photo/blobBytes"
+                  "bytes" : "/graphql/object/university.dept.StaffMember:123/photo/blobBytes"
                 }
               }
             }
@@ -774,9 +774,28 @@ The xref:#the-meta-fieldtype[meta field] mentioned earlier also allows the `icon
 <.> URL to download the grid layout (typically the `.layout.xml` file)
 
 
-Because the resource controller exposes information directly, these fields are suppressed by default.
-To enable, use the xref:refguide:config:sections/causeway.viewer.graphql.adoc#causeway.viewer.graphql.resources.response-type[causeway.viewer.graphql.resources.response-type] configuration property.
-If you do this, then you should also make sure that the resource controller is made secure in some appropriate fashion.
+Resource references are opaque origin-relative paths beginning with exactly one slash.
+Clients must resolve them against the current origin without adding, removing, or rewriting slash prefixes.
+The path combines the optional xref:refguide:config:sections/causeway.viewer.graphql.adoc#causeway.viewer.graphql.resources.external-path-prefix[external path prefix], servlet context, and Spring GraphQL HTTP endpoint path.
+Applications deployed behind a path-rewriting reverse proxy should configure `external-path-prefix` with the externally visible prefix.
+The current Spring endpoint property is `spring.graphql.http.path`; the deprecated `spring.graphql.path` remains a temporary fallback for link construction and controller mapping.
+
+Resource policy is split into structural metadata and domain value content.
+The xref:refguide:config:sections/causeway.viewer.graphql.adoc#causeway.viewer.graphql.resources.structural-metadata-response-type[structural metadata response type] governs effective grids and icons.
+The xref:refguide:config:sections/causeway.viewer.graphql.adoc#causeway.viewer.graphql.resources.value-content-response-type[value-content response type] governs Blob bytes and Clob characters.
+Each accepts `FORBIDDEN`, `DIRECT`, or `ATTACHMENT`.
+When a category is `FORBIDDEN`, its link fields are omitted from the generated schema and direct endpoint attempts return HTTP 403 without content.
+Blob and Clob names and media types remain available because they are descriptive metadata rather than downloadable content.
+
+For compatibility, either category-specific setting falls back to the deprecated xref:refguide:config:sections/causeway.viewer.graphql.adoc#causeway.viewer.graphql.resources.response-type[global response type] when unset.
+Existing applications therefore retain their current behavior while they migrate each category independently.
+A manually slash-normalizing client must stop rewriting references because generated paths are now browser-valid.
+
+Every dereference executes inside a Causeway interaction derived from the request principal, configured GraphQL fallback identity, or anonymous context.
+The controller re-resolves the target and rechecks object and property visibility rather than trusting the published link.
+Hosts must still configure transport authentication appropriate to the deployment.
+Hidden, missing, stale, and unauthorized targets return bounded non-disclosing responses.
+Successful responses include the declared media type, content length, `X-Content-Type-Options: nosniff`, and `Cache-Control: private, no-store`; `ATTACHMENT` additionally supplies `Content-Disposition`.
 
 
 
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/CommonMeta.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/CommonMeta.java
index 138e1594a1d..0370fe66686 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/CommonMeta.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/CommonMeta.java
@@ -82,15 +82,16 @@ public CommonMeta(
         addChildFieldFor(this.metaLayout = new CommonMetaLayout(context));
         addChildFieldFor(this.metaSaveAs = new CommonMetaSaveAs(context));
 
-        addChildFieldFor(this.metaIcon = isResourceNotForbidden() ? new CommonMetaIcon(context) : null);
-        addChildFieldFor(this.metaGrid = isResourceNotForbidden() ? new CommonMetaGrid(context) : null);
+        addChildFieldFor(this.metaIcon = isStructuralMetadataEnabled() ? new CommonMetaIcon(context) : null);
+        addChildFieldFor(this.metaGrid = isStructuralMetadataEnabled() ? new CommonMetaGrid(context) : null);
 
         var fieldName = graphqlConfiguration.metaData().fieldName();
         buildObjectTypeAndField(fieldName, "Object metadata");
     }
 
-    private boolean isResourceNotForbidden() {
-        return graphqlConfiguration.resources().responseType() != CausewayConfiguration.Viewer.Graphql.ResponseType.FORBIDDEN;
+    private boolean isStructuralMetadataEnabled() {
+        return graphqlConfiguration.resources().effectiveStructuralMetadataResponseType()
+                != CausewayConfiguration.Viewer.Graphql.ResponseType.FORBIDDEN;
     }
 
     private boolean isEntity() {
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/ResourcePath.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/ResourcePath.java
new file mode 100644
index 00000000000..df09039c20b
--- /dev/null
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/ResourcePath.java
@@ -0,0 +1,122 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.causeway.viewer.graphql.model.domain.common.query;
+
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+import org.apache.causeway.core.config.CausewayConfiguration;
+
+/**
+ * Builds opaque, origin-relative references to the GraphQL resource controller.
+ */
+public final class ResourcePath {
+
+    private final String resourceControllerPath;
+
+    private ResourcePath(final String resourceControllerPath) {
+        this.resourceControllerPath = resourceControllerPath;
+    }
+
+    public static ResourcePath from(final CausewayConfiguration configuration) {
+        var graphqlConfiguration = configuration.viewer().graphql();
+        return of(
+                graphqlConfiguration.resources().externalPathPrefix(),
+                configuration.valueOf("server.servlet.context-path").orElse(""),
+                configuration.valueOf("spring.graphql.http.path")
+                        .or(() -> configuration.valueOf("spring.graphql.path"))
+                        .orElse("/graphql"));
+    }
+
+    static ResourcePath of(
+            final String externalPathPrefix,
+            final String servletContextPath,
+            final String graphqlPath) {
+        return new ResourcePath(joinPath(
+                externalPathPrefix,
+                servletContextPath,
+                graphqlPath,
+                "object"));
+    }
+
+    public String metadata(
+            final Bookmark bookmark,
+            final String metadataFieldName,
+            final String resourceName) {
+        return append(
+                bookmarkSegment(bookmark),
+                requireSegment(metadataFieldName),
+                requireSegment(resourceName));
+    }
+
+    public String property(
+            final Bookmark bookmark,
+            final String propertyId,
+            final String resourceName) {
+        return append(
+                bookmarkSegment(bookmark),
+                requireSegment(propertyId),
+                requireSegment(resourceName));
+    }
+
+    String controllerPath() {
+        return resourceControllerPath;
+    }
+
+    private String append(final String... segments) {
+        return resourceControllerPath + "/" + String.join("/", segments);
+    }
+
+    private static String bookmarkSegment(final Bookmark bookmark) {
+        Objects.requireNonNull(bookmark);
+        return requireSegment(bookmark.logicalTypeName()) + ":" + requireSegment(bookmark.identifier());
+    }
+
+    private static String requireSegment(final String segment) {
+        if (segment == null
+                || segment.isBlank()
+                || segment.indexOf('/') >= 0
+                || segment.indexOf('\\') >= 0
+                || segment.indexOf('?') >= 0
+                || segment.indexOf('#') >= 0) {
+            throw new IllegalArgumentException("Resource identity is not a valid opaque path segment");
+        }
+        return segment;
+    }
+
+    private static String joinPath(final String... pathComponents) {
+        var path = Arrays.stream(pathComponents)
+                .filter(Objects::nonNull)
+                .map(String::trim)
+                .filter(component -> !component.isEmpty())
+                .peek(ResourcePath::requireApplicationPath)
+                .flatMap(component -> Arrays.stream(component.split("/+")))
+                .filter(component -> !component.isEmpty())
+                .collect(Collectors.joining("/"));
+        return "/" + path;
+    }
+
+    private static void requireApplicationPath(final String path) {
+        if (path.contains("://") || path.indexOf('?') >= 0 || path.indexOf('#') >= 0) {
+            throw new IllegalArgumentException("Resource application path must not contain an authority, query, or fragment");
+        }
+    }
+}
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaFetcher.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaFetcher.java
index 3aebcba62d4..cf737114bc4 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaFetcher.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaFetcher.java
@@ -29,6 +29,7 @@
 import org.apache.causeway.core.metamodel.object.Bookmarkable;
 import org.apache.causeway.core.metamodel.object.ManagedObject;
 import org.apache.causeway.core.metamodel.objectmanager.ObjectManager;
+import org.apache.causeway.viewer.graphql.model.domain.common.query.ResourcePath;
 
 /**
  * Metadata for every domain object.
@@ -39,7 +40,7 @@ public class CommonMetaFetcher {
     private final BookmarkService bookmarkService;
     private final ObjectManager objectManager;
     private final CausewayConfiguration causewayConfiguration;
-    private final String graphqlPath;
+    private final ResourcePath resourcePath;
 
     public CommonMetaFetcher(
             final Bookmark bookmark,
@@ -51,7 +52,7 @@ public CommonMetaFetcher(
         this.bookmarkService = bookmarkService;
         this.objectManager = objectManager;
         this.causewayConfiguration = causewayConfiguration;
-        this.graphqlPath = causewayConfiguration.valueOf("spring.graphql.path").orElse("/graphql");
+        this.resourcePath = ResourcePath.from(causewayConfiguration);
     }
 
     public String logicalTypeName() {
@@ -114,10 +115,11 @@ public String icon() {
 
     private String resource(final String resource) {
         return managedObject()
-                .flatMap(Bookmarkable::getBookmark
-                ).map(bookmark -> String.format(
-                        "//%s/object/%s:%s/%s/%s",
-                        graphqlPath, bookmark.logicalTypeName(), bookmark.identifier(), causewayConfiguration.viewer().graphql().metaData().fieldName(), resource))
+                .flatMap(Bookmarkable::getBookmark)
+                .map(bookmark -> resourcePath.metadata(
+                        bookmark,
+                        causewayConfiguration.viewer().graphql().metaData().fieldName(),
+                        resource))
                 .orElse(null);
     }
 
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetBlob.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetBlob.java
index 86c01a4eb31..7cbfb57ff19 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetBlob.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetBlob.java
@@ -35,9 +35,9 @@ public class RichPropertyGetBlob
 
     final MemberInteractor<OneToOneAssociation> memberInteractor;
 
-    final RichPropertyGetBlobBytes blobName;
+    final RichPropertyGetBlobName blobName;
     final RichPropertyGetBlobMimeType blobMimeType;
-    final RichPropertyGetBlobName blobBytes;
+    final RichPropertyGetBlobBytes blobBytes;
 
     private final CausewayConfiguration.Viewer.Graphql graphqlConfiguration;
 
@@ -57,9 +57,9 @@ public RichPropertyGetBlob(
             return;
         }
 
-        addChildFieldFor(blobName = new RichPropertyGetBlobBytes(memberInteractor, context));
+        addChildFieldFor(blobName = new RichPropertyGetBlobName(memberInteractor, context));
         addChildFieldFor(blobMimeType = new RichPropertyGetBlobMimeType(memberInteractor, context));
-        addChildFieldFor(blobBytes = isResourceNotForbidden() ? new RichPropertyGetBlobName(memberInteractor, context) : null);
+        addChildFieldFor(blobBytes = isValueContentEnabled() ? new RichPropertyGetBlobBytes(memberInteractor, context) : null);
 
         setField(newFieldDefinition()
                     .name("get")
@@ -67,8 +67,9 @@ public RichPropertyGetBlob(
                     .build());
     }
 
-    private boolean isResourceNotForbidden() {
-        return graphqlConfiguration.resources().responseType() != CausewayConfiguration.Viewer.Graphql.ResponseType.FORBIDDEN;
+    private boolean isValueContentEnabled() {
+        return graphqlConfiguration.resources().effectiveValueContentResponseType()
+                != CausewayConfiguration.Viewer.Graphql.ResponseType.FORBIDDEN;
     }
 
     @Override
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetBlobBytes.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetBlobBytes.java
index f97beee9753..5401e02cce0 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetBlobBytes.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetBlobBytes.java
@@ -24,18 +24,19 @@
 import org.apache.causeway.core.metamodel.spec.feature.OneToOneAssociation;
 import org.apache.causeway.viewer.graphql.model.context.Context;
 import org.apache.causeway.viewer.graphql.model.domain.common.interactors.MemberInteractor;
+import org.apache.causeway.viewer.graphql.model.domain.common.query.ResourcePath;
 import org.apache.causeway.viewer.graphql.model.fetcher.BookmarkedPojo;
 
 public class RichPropertyGetBlobBytes extends RichPropertyGetBlobAbstract {
 
-    private final String graphqlPath;
+    private final ResourcePath resourcePath;
 
     public RichPropertyGetBlobBytes(
             final MemberInteractor<OneToOneAssociation> memberInteractor,
             final Context context) {
         super(memberInteractor, context, "bytes");
 
-        this.graphqlPath = context.causewayConfiguration.valueOf("spring.graphql.path").orElse("/graphql");
+        this.resourcePath = ResourcePath.from(context.causewayConfiguration);
     }
 
     @Override
@@ -45,8 +46,7 @@ protected Object fetchData(DataFetchingEnvironment environment) {
         var bookmarkIfAny = context.bookmarkService.bookmarkFor(sourcePojo);
         return bookmarkIfAny.map(x -> {
             final ObjectFeature objectFeature = memberInteractor.getObjectMember();
-            return String.format(
-                    "//%s/object/%s:%s/%s/blobBytes", graphqlPath, x.logicalTypeName(), x.identifier(), objectFeature.asciiId());
+            return resourcePath.property(x, objectFeature.asciiId(), "blobBytes");
         }).orElse(null);
 
     }
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetClob.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetClob.java
index 9d7f85d7f85..35a44727da5 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetClob.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetClob.java
@@ -58,7 +58,7 @@ public RichPropertyGetClob(
 
         addChildFieldFor(clobName = new RichPropertyGetClobName(memberInteractor, context));
         addChildFieldFor(clobMimeType = new RichPropertyGetClobMimeType(memberInteractor, context));
-        addChildFieldFor(clobChars = isResourceNotForbidden() ? new RichPropertyGetClobChars(memberInteractor, context) : null);
+        addChildFieldFor(clobChars = isValueContentEnabled() ? new RichPropertyGetClobChars(memberInteractor, context) : null);
 
         setField(newFieldDefinition()
                     .name("get")
@@ -66,8 +66,9 @@ public RichPropertyGetClob(
                     .build());
     }
 
-    private boolean isResourceNotForbidden() {
-        return graphqlConfiguration.resources().responseType() != CausewayConfiguration.Viewer.Graphql.ResponseType.FORBIDDEN;
+    private boolean isValueContentEnabled() {
+        return graphqlConfiguration.resources().effectiveValueContentResponseType()
+                != CausewayConfiguration.Viewer.Graphql.ResponseType.FORBIDDEN;
     }
 
     @Override
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetClobAbstract.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetClobAbstract.java
index a8738586774..53202def866 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetClobAbstract.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetClobAbstract.java
@@ -25,7 +25,7 @@
 import graphql.schema.DataFetchingEnvironment;
 import graphql.schema.GraphQLFieldDefinition;
 
-import org.apache.causeway.applib.value.Blob;
+import org.apache.causeway.applib.value.Clob;
 import org.apache.causeway.core.metamodel.object.ManagedObject;
 import org.apache.causeway.core.metamodel.spec.feature.OneToOneAssociation;
 import org.apache.causeway.viewer.graphql.model.context.Context;
@@ -49,7 +49,7 @@ public RichPropertyGetClobAbstract(
                     .build());
     }
 
-    protected Object fetchDataFromBlob(DataFetchingEnvironment environment, Function<Blob, ?> mapper) {
+    protected Object fetchDataFromClob(DataFetchingEnvironment environment, Function<Clob, ?> mapper) {
         var sourcePojo = BookmarkedPojo.sourceFrom(environment);
 
         var sourcePojoClass = sourcePojo.getClass();
@@ -65,8 +65,8 @@ protected Object fetchDataFromBlob(DataFetchingEnvironment environment, Function
 
         return Optional.ofNullable(resultManagedObject)
                 .map(ManagedObject::getPojo)
-                .filter(Blob.class::isInstance)
-                .map(Blob.class::cast)
+                .filter(Clob.class::isInstance)
+                .map(Clob.class::cast)
                 .map(mapper)
                 .orElse(null);
     }
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetClobChars.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetClobChars.java
index 0c22beed1dd..a29d7cff9e0 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetClobChars.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetClobChars.java
@@ -24,18 +24,19 @@
 import org.apache.causeway.core.metamodel.spec.feature.OneToOneAssociation;
 import org.apache.causeway.viewer.graphql.model.context.Context;
 import org.apache.causeway.viewer.graphql.model.domain.common.interactors.MemberInteractor;
+import org.apache.causeway.viewer.graphql.model.domain.common.query.ResourcePath;
 import org.apache.causeway.viewer.graphql.model.fetcher.BookmarkedPojo;
 
 public class RichPropertyGetClobChars extends RichPropertyGetClobAbstract {
 
-    private final String graphqlPath;
+    private final ResourcePath resourcePath;
 
     public RichPropertyGetClobChars(
             final MemberInteractor<OneToOneAssociation> memberInteractor,
             final Context context) {
         super(memberInteractor, context, "chars");
 
-        this.graphqlPath = context.causewayConfiguration.valueOf("spring.graphql.path").orElse("/graphql");
+        this.resourcePath = ResourcePath.from(context.causewayConfiguration);
     }
 
     @Override
@@ -45,8 +46,7 @@ protected Object fetchData(DataFetchingEnvironment environment) {
         var bookmarkIfAny = context.bookmarkService.bookmarkFor(sourcePojo);
         return bookmarkIfAny.map(x -> {
             final ObjectFeature objectFeature = holder.getObjectMember();
-            return String.format(
-                    "//%s/object/%s:%s/%s/clobChars", graphqlPath, x.logicalTypeName(), x.identifier(), objectFeature.asciiId());
+            return resourcePath.property(x, objectFeature.asciiId(), "clobChars");
         }).orElse(null);
 
     }
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetClobMimeType.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetClobMimeType.java
index 0c033efed15..40d04b22d25 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetClobMimeType.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetClobMimeType.java
@@ -35,7 +35,7 @@ public RichPropertyGetClobMimeType(
 
     @Override
     protected Object fetchData(DataFetchingEnvironment environment) {
-        return fetchDataFromBlob(environment, blob -> blob.mimeType().toString());
+        return fetchDataFromClob(environment, clob -> clob.mimeType().toString());
     }
 
 }
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetClobName.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetClobName.java
index 47af2469576..0c750497c07 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetClobName.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichPropertyGetClobName.java
@@ -20,7 +20,7 @@
 
 import graphql.schema.DataFetchingEnvironment;
 
-import org.apache.causeway.applib.value.Blob;
+import org.apache.causeway.applib.value.Clob;
 import org.apache.causeway.core.metamodel.spec.feature.OneToOneAssociation;
 import org.apache.causeway.viewer.graphql.model.context.Context;
 import org.apache.causeway.viewer.graphql.model.domain.common.interactors.MemberInteractor;
@@ -35,7 +35,7 @@ public RichPropertyGetClobName(
 
     @Override
     protected Object fetchData(DataFetchingEnvironment environment) {
-        return fetchDataFromBlob(environment, Blob::name);
+        return fetchDataFromClob(environment, Clob::name);
     }
 
 }
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/query/SimpleProperty.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/query/SimpleProperty.java
index a33cc2577db..a2c75696202 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/query/SimpleProperty.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/query/SimpleProperty.java
@@ -23,6 +23,7 @@
 
 import org.apache.causeway.applib.value.Blob;
 import org.apache.causeway.applib.value.Clob;
+import org.apache.causeway.core.config.CausewayConfiguration;
 import org.apache.causeway.core.metamodel.object.ManagedObject;
 import org.apache.causeway.core.metamodel.spec.ObjectSpecification;
 import org.apache.causeway.core.metamodel.spec.feature.OneToOneAssociation;
@@ -82,10 +83,18 @@ public SimpleProperty(
             addChildFieldFor(glqObjectTypeBuilder, lobMimeType = new SimplePropertyLobMimeType(this, context));
 
             if(isBlob(getObjectMember())) {
-                addChildFieldFor(glqObjectTypeBuilder, lobBytes = new SimplePropertyLobBytes(this, context));
+                if (isValueContentEnabled(context)) {
+                    addChildFieldFor(glqObjectTypeBuilder, lobBytes = new SimplePropertyLobBytes(this, context));
+                } else {
+                    lobBytes = null;
+                }
                 lobChars = null;
             } else {
-                addChildFieldFor(glqObjectTypeBuilder, lobChars = new SimplePropertyLobChars(this, context));
+                if (isValueContentEnabled(context)) {
+                    addChildFieldFor(glqObjectTypeBuilder, lobChars = new SimplePropertyLobChars(this, context));
+                } else {
+                    lobChars = null;
+                }
                 lobBytes = null;
             }
 
@@ -174,6 +183,11 @@ protected Object fetchData(final DataFetchingEnvironment dataFetchingEnvironment
         }
     }
 
+    private static boolean isValueContentEnabled(final Context context) {
+        return context.causewayConfiguration.viewer().graphql().resources().effectiveValueContentResponseType()
+                != CausewayConfiguration.Viewer.Graphql.ResponseType.FORBIDDEN;
+    }
+
     private static boolean isBlobOrClob(OneToOneAssociation otota) {
         return isBlob(otota) || isClob(otota);
     }
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/query/SimplePropertyLobAbstract.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/query/SimplePropertyLobAbstract.java
index 893a7cfe811..e7ce05ae0b6 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/query/SimplePropertyLobAbstract.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/query/SimplePropertyLobAbstract.java
@@ -21,7 +21,7 @@
 import java.util.Optional;
 import java.util.function.Function;
 
-import org.apache.causeway.applib.value.Blob;
+import org.apache.causeway.applib.value.NamedWithMimeType;
 import org.apache.causeway.core.metamodel.object.ManagedObject;
 import org.apache.causeway.core.metamodel.spec.feature.OneToOneAssociation;
 import org.apache.causeway.viewer.graphql.model.context.Context;
@@ -50,7 +50,7 @@ public SimplePropertyLobAbstract(
                     .build());
     }
 
-    protected Object fetchDataFromBlob(DataFetchingEnvironment environment, Function<Blob, ?> mapper) {
+    protected Object fetchDataFromLob(DataFetchingEnvironment environment, Function<NamedWithMimeType, ?> mapper) {
         var sourcePojo = BookmarkedPojo.sourceFrom(environment);
 
         var sourcePojoClass = sourcePojo.getClass();
@@ -66,8 +66,8 @@ protected Object fetchDataFromBlob(DataFetchingEnvironment environment, Function
 
         return Optional.ofNullable(resultManagedObject)
                 .map(ManagedObject::getPojo)
-                .filter(Blob.class::isInstance)
-                .map(Blob.class::cast)
+                .filter(NamedWithMimeType.class::isInstance)
+                .map(NamedWithMimeType.class::cast)
                 .map(mapper)
                 .orElse(null);
     }
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/query/SimplePropertyLobBytes.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/query/SimplePropertyLobBytes.java
index 60f01e58dce..64f0feedef1 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/query/SimplePropertyLobBytes.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/query/SimplePropertyLobBytes.java
@@ -24,18 +24,19 @@
 import org.apache.causeway.core.metamodel.spec.feature.OneToOneAssociation;
 import org.apache.causeway.viewer.graphql.model.context.Context;
 import org.apache.causeway.viewer.graphql.model.domain.common.interactors.MemberInteractor;
+import org.apache.causeway.viewer.graphql.model.domain.common.query.ResourcePath;
 import org.apache.causeway.viewer.graphql.model.fetcher.BookmarkedPojo;
 
 public class SimplePropertyLobBytes extends SimplePropertyLobAbstract {
 
-    private final String graphqlPath;
+    private final ResourcePath resourcePath;
 
     public SimplePropertyLobBytes(
             final MemberInteractor<OneToOneAssociation> memberInteractor,
             final Context context) {
         super(memberInteractor, context, "bytes");
 
-        this.graphqlPath = context.causewayConfiguration.valueOf("spring.graphql.path").orElse("/graphql");
+        this.resourcePath = ResourcePath.from(context.causewayConfiguration);
     }
 
     @Override
@@ -45,8 +46,7 @@ protected Object fetchData(DataFetchingEnvironment environment) {
         var bookmarkIfAny = context.bookmarkService.bookmarkFor(sourcePojo);
         return bookmarkIfAny.map(x -> {
             final ObjectFeature objectFeature = memberInteractor.getObjectMember();
-            return String.format(
-                    "//%s/object/%s:%s/%s/blobBytes", graphqlPath, x.logicalTypeName(), x.identifier(), objectFeature.asciiId());
+            return resourcePath.property(x, objectFeature.asciiId(), "blobBytes");
         }).orElse(null);
 
     }
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/query/SimplePropertyLobChars.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/query/SimplePropertyLobChars.java
index dce5d4c7152..02ee58e50f1 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/query/SimplePropertyLobChars.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/query/SimplePropertyLobChars.java
@@ -24,18 +24,19 @@
 import org.apache.causeway.core.metamodel.spec.feature.OneToOneAssociation;
 import org.apache.causeway.viewer.graphql.model.context.Context;
 import org.apache.causeway.viewer.graphql.model.domain.common.interactors.MemberInteractor;
+import org.apache.causeway.viewer.graphql.model.domain.common.query.ResourcePath;
 import org.apache.causeway.viewer.graphql.model.fetcher.BookmarkedPojo;
 
 public class SimplePropertyLobChars extends SimplePropertyLobAbstract {
 
-    private final String graphqlPath;
+    private final ResourcePath resourcePath;
 
     public SimplePropertyLobChars(
             final MemberInteractor<OneToOneAssociation> memberInteractor,
             final Context context) {
         super(memberInteractor, context, "chars");
 
-        this.graphqlPath = context.causewayConfiguration.valueOf("spring.graphql.path").orElse("/graphql");
+        this.resourcePath = ResourcePath.from(context.causewayConfiguration);
     }
 
     @Override
@@ -45,8 +46,7 @@ protected Object fetchData(DataFetchingEnvironment environment) {
         var bookmarkIfAny = context.bookmarkService.bookmarkFor(sourcePojo);
         return bookmarkIfAny.map(x -> {
             final ObjectFeature objectFeature = memberInteractor.getObjectMember();
-            return String.format(
-                    "//%s/object/%s:%s/%s/clobChars", graphqlPath, x.logicalTypeName(), x.identifier(), objectFeature.asciiId());
+            return resourcePath.property(x, objectFeature.asciiId(), "clobChars");
         }).orElse(null);
 
     }
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/query/SimplePropertyLobMimeType.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/query/SimplePropertyLobMimeType.java
index 8f7142f20a1..9bae05c4b1a 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/query/SimplePropertyLobMimeType.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/query/SimplePropertyLobMimeType.java
@@ -35,7 +35,7 @@ public SimplePropertyLobMimeType(
 
     @Override
     protected Object fetchData(DataFetchingEnvironment environment) {
-        return fetchDataFromBlob(environment, blob -> blob.mimeType().toString());
+        return fetchDataFromLob(environment, lob -> lob.mimeType().toString());
     }
 
 }
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/query/SimplePropertyLobName.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/query/SimplePropertyLobName.java
index 51c6f3f2d25..5dd20a9b236 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/query/SimplePropertyLobName.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/query/SimplePropertyLobName.java
@@ -18,7 +18,7 @@
  */
 package org.apache.causeway.viewer.graphql.model.domain.simple.query;
 
-import org.apache.causeway.applib.value.Blob;
+import org.apache.causeway.applib.value.NamedWithMimeType;
 import org.apache.causeway.core.metamodel.spec.feature.OneToOneAssociation;
 import org.apache.causeway.viewer.graphql.model.context.Context;
 import org.apache.causeway.viewer.graphql.model.domain.common.interactors.MemberInteractor;
@@ -35,7 +35,7 @@ public SimplePropertyLobName(
 
     @Override
     protected Object fetchData(DataFetchingEnvironment environment) {
-        return fetchDataFromBlob(environment, Blob::name);
+        return fetchDataFromLob(environment, NamedWithMimeType::name);
     }
 
 }
diff --git a/viewers/graphql/model/src/test/java/org/apache/causeway/viewer/graphql/model/domain/common/query/ResourcePathTest.java b/viewers/graphql/model/src/test/java/org/apache/causeway/viewer/graphql/model/domain/common/query/ResourcePathTest.java
new file mode 100644
index 00000000000..5755688d515
--- /dev/null
+++ b/viewers/graphql/model/src/test/java/org/apache/causeway/viewer/graphql/model/domain/common/query/ResourcePathTest.java
@@ -0,0 +1,118 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.causeway.viewer.graphql.model.domain.common.query;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+import org.apache.causeway.core.config.CausewayConfiguration;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ResourcePathTest {
+
+    private static final Bookmark BOOKMARK =
+            Bookmark.forLogicalTypeNameAndIdentifier("example.Customer", "cust-1");
+
+    @Test
+    void rootDeploymentUsesOneOriginRelativePrefix() {
+        var path = ResourcePath.of(null, null, "/graphql");
+
+        assertEquals(
+                "/graphql/object/example.Customer:cust-1/_meta/grid",
+                path.metadata(BOOKMARK, "_meta", "grid"));
+        assertFalse(path.controllerPath().startsWith("//"));
+    }
+
+    @Test
+    void servletContextAndNonDefaultEndpointAreRetained() {
+        var path = ResourcePath.of(null, "/causeway/", "/api/query/");
+
+        assertEquals(
+                "/causeway/api/query/object/example.Customer:cust-1/photo/blobBytes",
+                path.property(BOOKMARK, "photo", "blobBytes"));
+    }
+
+    @Test
+    void configuredReverseProxyPrefixIsRetainedExactlyOnce() {
+        var path = ResourcePath.of("/public", "/causeway", "/graphql");
+
+        assertEquals(
+                "/public/causeway/graphql/object",
+                path.controllerPath());
+    }
+
+    @Test
+    void opaqueUrlSafeIdentityIsNotDoubleEncoded() {
+        var bookmark = Bookmark.forLogicalTypeNameAndIdentifier(
+                "example.Customer",
+                "cust%2D1");
+
+        var resource = ResourcePath.of(null, null, "/graphql")
+                .property(bookmark, "photo", "blobBytes");
+
+        assertTrue(resource.contains("cust%2D1"));
+        assertFalse(resource.contains("%252D"));
+    }
+
+    @Test
+    void unsafeAuthorityAndIdentityAreRejectedWithoutEchoingInput() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ResourcePath.of(null, null, "https://other.example/graphql"));
+        var failure = assertThrows(
+                IllegalArgumentException.class,
+                () -> ResourcePath.of(null, null, "/graphql").property(
+                        Bookmark.forLogicalTypeNameAndIdentifier("example.Customer", "secret/id"),
+                        "photo",
+                        "blobBytes"));
+        assertFalse(failure.getMessage().contains("secret"));
+    }
+
+    @Test
+    @SuppressWarnings("deprecation")
+    void categoryPoliciesFallBackToLegacySettingUnlessOverridden() {
+        var legacy = new CausewayConfiguration.Viewer.Graphql.Resources(
+                CausewayConfiguration.Viewer.Graphql.ResponseType.ATTACHMENT,
+                null,
+                null,
+                null);
+        assertEquals(
+                CausewayConfiguration.Viewer.Graphql.ResponseType.ATTACHMENT,
+                legacy.effectiveStructuralMetadataResponseType());
+        assertEquals(
+                CausewayConfiguration.Viewer.Graphql.ResponseType.ATTACHMENT,
+                legacy.effectiveValueContentResponseType());
+
+        var split = new CausewayConfiguration.Viewer.Graphql.Resources(
+                CausewayConfiguration.Viewer.Graphql.ResponseType.ATTACHMENT,
+                CausewayConfiguration.Viewer.Graphql.ResponseType.DIRECT,
+                CausewayConfiguration.Viewer.Graphql.ResponseType.FORBIDDEN,
+                "/public");
+        assertEquals(
+                CausewayConfiguration.Viewer.Graphql.ResponseType.DIRECT,
+                split.effectiveStructuralMetadataResponseType());
+        assertEquals(
+                CausewayConfiguration.Viewer.Graphql.ResponseType.FORBIDDEN,
+                split.effectiveValueContentResponseType());
+    }
+}
diff --git a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/domain/dept/StaffMember.java b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/domain/dept/StaffMember.java
index 175bc195505..2bb155dd7af 100644
--- a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/domain/dept/StaffMember.java
+++ b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/domain/dept/StaffMember.java
@@ -18,6 +18,7 @@
  */
 package org.apache.causeway.viewer.graphql.viewer.test.domain.dept;
 
+import java.nio.charset.StandardCharsets;
 import java.util.Comparator;
 
 import jakarta.persistence.AttributeOverride;
@@ -38,7 +39,9 @@
 import org.apache.causeway.applib.annotation.Optionality;
 import org.apache.causeway.applib.annotation.Property;
 import org.apache.causeway.applib.annotation.PropertyLayout;
+import org.apache.causeway.applib.annotation.Where;
 import org.apache.causeway.applib.value.Blob;
+import org.apache.causeway.applib.value.Clob;
 import org.apache.causeway.persistence.jpa.applib.types.BlobJpaEmbeddable;
 
 import lombok.Getter;
@@ -106,6 +109,21 @@ public void setPhoto(final Blob photo) {
         this.photo = BlobJpaEmbeddable.fromBlob(photo);
     }
 
+    @Property
+    @PropertyLayout(fieldSetId = "content", sequence = "2")
+    public Clob getProfile() {
+        return new Clob("profile.txt", "text/plain", "Profile for " + name);
+    }
+
+    @Property
+    @PropertyLayout(hidden = Where.EVERYWHERE)
+    public Blob getHiddenPhoto() {
+        return new Blob(
+                "hidden.pdf",
+                "application/pdf",
+                "CONFIDENTIAL_RESOURCE_CONTENT".getBytes(StandardCharsets.UTF_8));
+    }
+
     @Override
     public int compareTo(final StaffMember o) {
         return Comparator.comparing(StaffMember::getName).compare(this, o);
diff --git a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/ResourceDeploymentPath_IntegTest.java b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/ResourceDeploymentPath_IntegTest.java
new file mode 100644
index 00000000000..7091ed1a815
--- /dev/null
+++ b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/ResourceDeploymentPath_IntegTest.java
@@ -0,0 +1,105 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.causeway.viewer.graphql.viewer.test.e2e.special;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.Map;
+
+import tools.jackson.databind.ObjectMapper;
+
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+
+import org.apache.causeway.core.config.CausewayConfiguration;
+import org.apache.causeway.viewer.graphql.viewer.test.e2e.Abstract_IntegTest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@Order(62)
+@ActiveProfiles("test")
+public class ResourceDeploymentPath_IntegTest extends Abstract_IntegTest {
+
+    @DynamicPropertySource
+    static void deploymentPaths(final DynamicPropertyRegistry registry) {
+        registry.add("server.servlet.context-path", () -> "/causeway");
+        registry.add("spring.graphql.http.path", () -> "/api/query");
+        registry.add("causeway.viewer.graphql.resources.external-path-prefix", () -> "/public");
+        registry.add(
+                "causeway.viewer.graphql.resources.structural-metadata-response-type",
+                CausewayConfiguration.Viewer.Graphql.ResponseType.DIRECT::name);
+    }
+
+    @Test
+    void retainsServletProxyAndNonDefaultGraphQlPaths() throws Exception {
+        var objectMapper = new ObjectMapper();
+        var requestBody = objectMapper.writeValueAsString(Map.of("query", """
+                {
+                  rich {
+                    university_dept_Staff {
+                      findStaffMemberByName {
+                        invoke(name: "Gerry Jones") {
+                          results { _meta { grid } }
+                        }
+                      }
+                    }
+                  }
+                }
+                """));
+        var graphQlRequest = HttpRequest.newBuilder()
+                .uri(URI.create(String.format(
+                        "http://0.0.0.0:%d/causeway/api/query",
+                        port)))
+                .header("Content-Type", "application/json")
+                .timeout(Duration.ofSeconds(30))
+                .POST(HttpRequest.BodyPublishers.ofString(requestBody))
+                .build();
+        var graphQlResponse = HttpClient.newHttpClient().send(
+                graphQlRequest,
+                HttpResponse.BodyHandlers.ofString());
+        assertThat(graphQlResponse.statusCode()).isEqualTo(200);
+        var body = objectMapper.readTree(graphQlResponse.body());
+        assertThat(body.at("/errors").isMissingNode()).isTrue();
+        var gridPath = body.at(
+                "/data/rich/university_dept_Staff/findStaffMemberByName/invoke/results/_meta/grid")
+                .stringValue();
+        assertThat(gridPath)
+                .matches("/public/causeway/api/query/object/university.dept.StaffMember:(\\d+)/_meta/grid")
+                .doesNotStartWith("//");
+
+        var internalPath = gridPath.substring("/public".length());
+        var resourceRequest = HttpRequest.newBuilder()
+                .uri(URI.create(String.format("http://0.0.0.0:%d%s", port, internalPath)))
+                .timeout(Duration.ofSeconds(30))
+                .GET()
+                .build();
+        var resourceResponse = HttpClient.newHttpClient().send(
+                resourceRequest,
+                HttpResponse.BodyHandlers.ofByteArray());
+        assertThat(resourceResponse.statusCode()).isEqualTo(200);
+        assertThat(resourceResponse.body()).isNotEmpty();
+    }
+}
diff --git a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/ResourcePolicy_IntegTest.java b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/ResourcePolicy_IntegTest.java
new file mode 100644
index 00000000000..1d3274f0ee9
--- /dev/null
+++ b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/ResourcePolicy_IntegTest.java
@@ -0,0 +1,141 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.causeway.viewer.graphql.viewer.test.e2e.special;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.Map;
+
+import graphql.schema.GraphQLObjectType;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.ObjectMapper;
+
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+
+import org.apache.causeway.core.config.CausewayConfiguration;
+import org.apache.causeway.viewer.graphql.viewer.test.e2e.Abstract_IntegTest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@Order(61)
+@ActiveProfiles("test")
+public class ResourcePolicy_IntegTest extends Abstract_IntegTest {
+
+    @DynamicPropertySource
+    static void resourcePolicies(final DynamicPropertyRegistry registry) {
+        registry.add(
+                "causeway.viewer.graphql.resources.structural-metadata-response-type",
+                CausewayConfiguration.Viewer.Graphql.ResponseType.DIRECT::name);
+        registry.add(
+                "causeway.viewer.graphql.resources.value-content-response-type",
+                CausewayConfiguration.Viewer.Graphql.ResponseType.FORBIDDEN::name);
+    }
+
+    @Test
+    void separatesStructuralMetadataFromValueContent() throws Exception {
+        var objectMapper = new ObjectMapper();
+        var graphQlResponse = executeGraphQl(objectMapper, """
+                {
+                  rich {
+                    university_dept_Staff {
+                      findStaffMemberByName {
+                        invoke(name: "Gerry Jones") {
+                          results {
+                            _meta { id grid }
+                            photo { get { name mimeType } }
+                          }
+                        }
+                      }
+                    }
+                  }
+                }
+                """);
+        assertThat(graphQlResponse.at("/errors").isMissingNode()).isTrue();
+        var resultPath = "/data/rich/university_dept_Staff/findStaffMemberByName/invoke/results";
+        var id = graphQlResponse.at(resultPath + "/_meta/id").stringValue();
+        var gridUrl = graphQlResponse.at(resultPath + "/_meta/grid").stringValue();
+        assertThat(gridUrl).startsWith("/graphql/object/");
+        assertThat(graphQlResponse.at(resultPath + "/photo/get/name").stringValue())
+                .isEqualTo("StaffMember-photo-Bar.pdf");
+        assertThat(graphQlResponse.at(resultPath + "/photo/get/mimeType").stringValue())
+                .isEqualTo("application/pdf");
+
+        var schema = graphQlSourceForCauseway.schema();
+        var richLobType = (GraphQLObjectType) schema.getType(
+                "rich__university_dept_StaffMember__photo__gqlv_property_lob");
+        var simpleLobType = (GraphQLObjectType) schema.getType(
+                "simple__university_dept_StaffMember__photo__gqlv_member");
+        assertThat(richLobType.getFieldDefinition("bytes")).isNull();
+        assertThat(simpleLobType.getFieldDefinition("bytes")).isNull();
+
+        var gridResponse = get(gridUrl);
+        assertThat(gridResponse.statusCode()).isEqualTo(200);
+        assertThat(gridResponse.headers().firstValue("Content-Type").orElseThrow())
+                .startsWith("application/xml");
+        assertThat(gridResponse.headers().firstValue("Content-Length").orElseThrow())
+                .isEqualTo(Integer.toString(gridResponse.body().length));
+        assertThat(gridResponse.headers().firstValue("Cache-Control").orElseThrow())
+                .isEqualTo("private, no-store");
+        assertThat(gridResponse.headers().firstValue("Content-Disposition")).isEmpty();
+
+        var blobResponse = get(String.format(
+                "/graphql/object/university.dept.StaffMember:%s/photo/blobBytes",
+                id));
+        assertThat(blobResponse.statusCode()).isEqualTo(403);
+        assertThat(blobResponse.body()).isEmpty();
+        assertThat(blobResponse.headers().firstValue("Cache-Control").orElseThrow())
+                .isEqualTo("private, no-store");
+    }
+
+    private JsonNode executeGraphQl(
+            final ObjectMapper objectMapper,
+            final String query) throws Exception {
+        var requestBody = objectMapper.writeValueAsString(Map.of("query", query));
+        var request = HttpRequest.newBuilder()
+                .uri(URI.create(String.format("http://0.0.0.0:%d/graphql", port)))
+                .header("Content-Type", "application/json")
+                .timeout(Duration.ofSeconds(30))
+                .POST(HttpRequest.BodyPublishers.ofString(requestBody))
+                .build();
+        var response = HttpClient.newHttpClient().send(
+                request,
+                HttpResponse.BodyHandlers.ofString());
+        assertThat(response.statusCode()).isEqualTo(200);
+        return objectMapper.readTree(response.body());
+    }
+
+    private HttpResponse<byte[]> get(final String path) throws Exception {
+        var request = HttpRequest.newBuilder()
+                .uri(URI.create(String.format("http://0.0.0.0:%d%s", port, path)))
+                .timeout(Duration.ofSeconds(30))
+                .GET()
+                .build();
+        return HttpClient.newHttpClient().send(
+                request,
+                HttpResponse.BodyHandlers.ofByteArray());
+    }
+}
diff --git a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/Staff_2_IntegTest.find_staff_member_by_name_and_download_photo.approved.json b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/Staff_2_IntegTest.find_staff_member_by_name_and_download_photo.approved.json
deleted file mode 100644
index 297bb0ea492..00000000000
--- a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/Staff_2_IntegTest.find_staff_member_by_name_and_download_photo.approved.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
-  "data" : {
-    "rich" : {
-      "university_dept_Staff" : {
-        "findStaffMemberByName" : {
-          "invoke" : {
-            "results" : {
-              "name" : {
-                "get" : "Gerry Jones"
-              },
-              "_meta" : {
-                "grid" : "///graphql/object/university.dept.StaffMember:NNN/_meta/grid",
-                "icon" : "///graphql/object/university.dept.StaffMember:NNN/_meta/icon"
-              },
-              "photo" : {
-                "get" : {
-                  "bytes" : "///graphql/object/university.dept.StaffMember:NNN/photo/blobBytes"
-                }
-              }
-            }
-          }
-        }
-      }
-    }
-  }
-}
\ No newline at end of file
diff --git a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/Staff_2_IntegTest.find_staff_member_by_name_and_download_photo._.gql b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/Staff_2_IntegTest.findsStaffMemberAndSafelyDownloadsResources._.gql
similarity index 67%
rename from viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/Staff_2_IntegTest.find_staff_member_by_name_and_download_photo._.gql
rename to viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/Staff_2_IntegTest.findsStaffMemberAndSafelyDownloadsResources._.gql
index b97353188fb..149daf0fb97 100644
--- a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/Staff_2_IntegTest.find_staff_member_by_name_and_download_photo._.gql
+++ b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/Staff_2_IntegTest.findsStaffMemberAndSafelyDownloadsResources._.gql
@@ -13,9 +13,18 @@
             }
             photo {
               get {
+                name
+                mimeType
                 bytes
               }
             }
+            profile {
+              get {
+                name
+                mimeType
+                chars
+              }
+            }
           }
         }
       }
diff --git a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/Staff_2_IntegTest.findsStaffMemberAndSafelyDownloadsResources.approved.json b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/Staff_2_IntegTest.findsStaffMemberAndSafelyDownloadsResources.approved.json
new file mode 100644
index 00000000000..b6d4715113f
--- /dev/null
+++ b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/Staff_2_IntegTest.findsStaffMemberAndSafelyDownloadsResources.approved.json
@@ -0,0 +1,35 @@
+{
+  "data" : {
+    "rich" : {
+      "university_dept_Staff" : {
+        "findStaffMemberByName" : {
+          "invoke" : {
+            "results" : {
+              "name" : {
+                "get" : "Gerry Jones"
+              },
+              "_meta" : {
+                "grid" : "/graphql/object/university.dept.StaffMember:NNN/_meta/grid",
+                "icon" : "/graphql/object/university.dept.StaffMember:NNN/_meta/icon"
+              },
+              "photo" : {
+                "get" : {
+                  "name" : "StaffMember-photo-Bar.pdf",
+                  "mimeType" : "application/pdf",
+                  "bytes" : "/graphql/object/university.dept.StaffMember:NNN/photo/blobBytes"
+                }
+              },
+              "profile" : {
+                "get" : {
+                  "name" : "profile.txt",
+                  "mimeType" : "text/plain",
+                  "chars" : "/graphql/object/university.dept.StaffMember:NNN/profile/clobChars"
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/Staff_2_IntegTest.java b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/Staff_2_IntegTest.java
index b565fa9e3f7..97b0535d061 100644
--- a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/Staff_2_IntegTest.java
+++ b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/Staff_2_IntegTest.java
@@ -23,6 +23,8 @@
 import java.net.http.HttpClient;
 import java.net.http.HttpRequest;
 import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
 
 import tools.jackson.databind.ObjectMapper;
 
@@ -31,78 +33,108 @@
 import org.approvaltests.reporters.UseReporter;
 import org.junit.jupiter.api.Order;
 import org.junit.jupiter.api.Test;
-import org.springframework.test.context.ActiveProfiles;
+import org.junit.jupiter.api.extension.ExtendWith;
 
-import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+import org.springframework.test.context.ActiveProfiles;
 
 import org.apache.causeway.viewer.graphql.viewer.test.e2e.Abstract_IntegTest;
 
+import static org.assertj.core.api.Assertions.assertThat;
+
 //NOT USING @Transactional since we are running server within same transaction otherwise
 @Order(60)
 @ActiveProfiles("test")
+@ExtendWith(OutputCaptureExtension.class)
 public class Staff_2_IntegTest extends Abstract_IntegTest {
 
     @Test
     @UseReporter(DiffReporter.class)
-    void find_staff_member_by_name_and_download_photo() throws Exception {
-
+    void findsStaffMemberAndSafelyDownloadsResources(final CapturedOutput output) throws Exception {
         String response = submit();
         Approvals.verify(response, jsonOptions());
 
         var objectMapper = new ObjectMapper();
         var jsonNodeRoot = objectMapper.readTree(response);
-
-        var gridUrl = jsonNodeRoot
-                .at("/data/rich/university_dept_Staff/findStaffMemberByName/invoke/results/_meta/grid")
-                .asText();
-
-        assertThat(gridUrl).matches("///graphql/object/university.dept.StaffMember:(\\d+)/_meta/grid");
-        var gridHttpResponse = submitReturningString(gridUrl);
-
-        assertThat(gridHttpResponse.statusCode()).isEqualTo(200);
-        var gridChars = gridHttpResponse.body();
-        assertThat(gridChars).isNotEmpty();
-
-        var photoBytesUrl = jsonNodeRoot
-                .at("/data/rich/university_dept_Staff/findStaffMemberByName/invoke/results/photo/get/bytes")
-                .asText();
-
-        assertThat(photoBytesUrl).matches("///graphql/object/university.dept.StaffMember:(\\d+)/photo/blobBytes");
+        var resultPath = "/data/rich/university_dept_Staff/findStaffMemberByName/invoke/results";
+        var gridUrl = jsonNodeRoot.at(resultPath + "/_meta/grid").stringValue();
+        assertThat(gridUrl).matches("/graphql/object/university.dept.StaffMember:(\\d+)/_meta/grid");
+        var id = gridUrl.substring(
+                gridUrl.indexOf(':') + 1,
+                gridUrl.indexOf("/_meta/grid"));
+        var gridResponse = submitReturningBytes(gridUrl);
+        assertSuccessfulAttachment(gridResponse, "application/xml");
+        assertThat(new String(gridResponse.body(), StandardCharsets.UTF_8)).isNotBlank();
+
+        var photoBytesUrl = jsonNodeRoot.at(resultPath + "/photo/get/bytes").stringValue();
+        assertThat(photoBytesUrl).matches("/graphql/object/university.dept.StaffMember:(\\d+)/photo/blobBytes");
         var photoBytesResponse = submitReturningBytes(photoBytesUrl);
-        assertThat(photoBytesResponse.statusCode()).isEqualTo(200);
-        var photoBytes = photoBytesResponse.body();
-        assertThat(photoBytes).isNotEmpty();
-
-        var iconBytesUrl = jsonNodeRoot
-                .at("/data/rich/university_dept_Staff/findStaffMemberByName/invoke/results/_meta/icon")
-                .asText();
-
-        assertThat(iconBytesUrl).matches("///graphql/object/university.dept.StaffMember:(\\d+)/_meta/icon");
+        assertSuccessfulAttachment(photoBytesResponse, "application/pdf");
+        assertThat(photoBytesResponse.body()).isNotEmpty();
+
+        var profileCharsUrl = jsonNodeRoot.at(resultPath + "/profile/get/chars").stringValue();
+        assertThat(profileCharsUrl).matches("/graphql/object/university.dept.StaffMember:(\\d+)/profile/clobChars");
+        var profileCharsResponse = submitReturningBytes(profileCharsUrl);
+        assertSuccessfulAttachment(profileCharsResponse, "text/plain");
+        assertThat(new String(profileCharsResponse.body(), StandardCharsets.UTF_8))
+                .isEqualTo("Profile for Gerry Jones");
+
+        var iconBytesUrl = jsonNodeRoot.at(resultPath + "/_meta/icon").stringValue();
+        assertThat(iconBytesUrl).matches("/graphql/object/university.dept.StaffMember:(\\d+)/_meta/icon");
         var iconBytesResponse = submitReturningBytes(iconBytesUrl);
-        assertThat(iconBytesResponse.statusCode()).isEqualTo(200);
-        var iconBytes = iconBytesResponse.body();
-        assertThat(iconBytes).isNotEmpty();
+        assertSuccessfulAttachment(iconBytesResponse, "image/png");
+        assertThat(iconBytesResponse.body()).isNotEmpty();
+
+        assertNotFoundWithoutDisclosure(String.format(
+                "/graphql/object/university.dept.StaffMember:%s/hiddenPhoto/blobBytes",
+                id));
+        assertNotFoundWithoutDisclosure(
+                "/graphql/object/university.dept.StaffMember:diagnostic-secret-id/photo/blobBytes");
+        assertNotFoundWithoutDisclosure(String.format(
+                "/graphql/object/university.dept.StaffMember:%s/not-meta/grid",
+                id));
+
+        assertThat(output.getAll())
+                .doesNotContain("CONFIDENTIAL_RESOURCE_CONTENT")
+                .doesNotContain("diagnostic-secret-id");
     }
 
-    private HttpResponse<byte[]> submitReturningBytes(String url) throws IOException, InterruptedException {
-        return submitReturningResponseHandledBy(url, HttpResponse.BodyHandlers.ofByteArray());
+    private void assertNotFoundWithoutDisclosure(final String url) throws Exception {
+        var response = submitReturningBytes(url);
+        assertThat(response.statusCode()).isEqualTo(404);
+        assertThat(response.body()).isEmpty();
+        assertThat(response.headers().firstValue("Cache-Control").orElseThrow())
+                .isEqualTo("private, no-store");
     }
 
-    private HttpResponse<String> submitReturningString(String url) throws IOException, InterruptedException {
-        return submitReturningResponseHandledBy(url, HttpResponse.BodyHandlers.ofString());
+    private static void assertSuccessfulAttachment(
+            final HttpResponse<byte[]> response,
+            final String expectedMediaType) {
+        assertThat(response.statusCode()).isEqualTo(200);
+        assertThat(response.headers().firstValue("Content-Type").orElseThrow())
+                .startsWith(expectedMediaType);
+        assertThat(response.headers().firstValue("Content-Length").orElseThrow())
+                .isEqualTo(Integer.toString(response.body().length));
+        assertThat(response.headers().firstValue("Cache-Control").orElseThrow())
+                .isEqualTo("private, no-store");
+        assertThat(response.headers().firstValue("Content-Disposition").orElseThrow())
+                .startsWith("attachment;");
+        assertThat(response.headers().firstValue("X-Content-Type-Options").orElseThrow())
+                .isEqualTo("nosniff");
     }
 
-    private <T> HttpResponse<T> submitReturningResponseHandledBy(String url, HttpResponse.BodyHandler<T> responseBodyHandler) throws IOException, InterruptedException {
-        var urlSuffix = url.substring(3); // strip off the '///' prefix
-        var uri = URI.create(String.format("http://0.0.0.0:%d/%s", port, urlSuffix));
-
-        HttpRequest httpRequest = HttpRequest.newBuilder().
-                uri(uri).
-                GET().
-                build();
-
-        HttpClient httpClient = HttpClient.newHttpClient();
-        return httpClient.send(httpRequest, responseBodyHandler);
+    private HttpResponse<byte[]> submitReturningBytes(final String url)
+            throws IOException, InterruptedException {
+        var baseUri = URI.create(String.format("http://0.0.0.0:%d", port));
+        var uri = baseUri.resolve(url);
+        var httpRequest = HttpRequest.newBuilder()
+                .uri(uri)
+                .timeout(Duration.ofSeconds(30))
+                .GET()
+                .build();
+        return HttpClient.newHttpClient().send(
+                httpRequest,
+                HttpResponse.BodyHandlers.ofByteArray());
     }
-
 }
diff --git a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/staff/Staff_IntegTest.each.create_staff_member_with_department.approved.json b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/staff/Staff_IntegTest.each.create_staff_member_with_department.approved.json
index 0eac192ed46..ccacc8b607b 100644
--- a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/staff/Staff_IntegTest.each.create_staff_member_with_department.approved.json
+++ b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/staff/Staff_IntegTest.each.create_staff_member_with_department.approved.json
@@ -47,7 +47,7 @@
                     "logicalTypeName" : "university.dept.StaffMember",
                     "version" : null,
                     "cssClass" : null,
-                    "icon" : "///graphql/object/university.dept.StaffMember:NNN/_meta/icon"
+                    "icon" : "/graphql/object/university.dept.StaffMember:NNN/_meta/icon"
                   }
                 }
               }
diff --git a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/staff/Staff_IntegTest.each.list_all_staff_members.approved.json b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/staff/Staff_IntegTest.each.list_all_staff_members.approved.json
index b1e8ef5789f..0ab3d94b5df 100644
--- a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/staff/Staff_IntegTest.each.list_all_staff_members.approved.json
+++ b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/staff/Staff_IntegTest.each.list_all_staff_members.approved.json
@@ -15,7 +15,7 @@
                 "get" : {
                   "name" : "StaffMember-photo-Bar.pdf",
                   "mimeType" : "application/pdf",
-                  "bytes" : "///graphql/object/university.dept.StaffMember:NNN/photo/blobBytes"
+                  "bytes" : "/graphql/object/university.dept.StaffMember:NNN/photo/blobBytes"
                 }
               }
             }, {
@@ -29,7 +29,7 @@
                 "get" : {
                   "name" : null,
                   "mimeType" : null,
-                  "bytes" : "///graphql/object/university.dept.StaffMember:NNN/photo/blobBytes"
+                  "bytes" : "/graphql/object/university.dept.StaffMember:NNN/photo/blobBytes"
                 }
               }
             }, {
@@ -43,7 +43,7 @@
                 "get" : {
                   "name" : "StaffMember-photo-Foo.pdf",
                   "mimeType" : "application/pdf",
-                  "bytes" : "///graphql/object/university.dept.StaffMember:NNN/photo/blobBytes"
+                  "bytes" : "/graphql/object/university.dept.StaffMember:NNN/photo/blobBytes"
                 }
               }
             }, {
@@ -57,7 +57,7 @@
                 "get" : {
                   "name" : null,
                   "mimeType" : null,
-                  "bytes" : "///graphql/object/university.dept.StaffMember:NNN/photo/blobBytes"
+                  "bytes" : "/graphql/object/university.dept.StaffMember:NNN/photo/blobBytes"
                 }
               }
             }, {
@@ -71,7 +71,7 @@
                 "get" : {
                   "name" : "StaffMember-photo-Fizz.pdf",
                   "mimeType" : "application/pdf",
-                  "bytes" : "///graphql/object/university.dept.StaffMember:NNN/photo/blobBytes"
+                  "bytes" : "/graphql/object/university.dept.StaffMember:NNN/photo/blobBytes"
                 }
               }
             } ]
diff --git a/viewers/graphql/test/src/test/resources/schema.gql b/viewers/graphql/test/src/test/resources/schema.gql
index 6318d739604..00ef680c853 100644
--- a/viewers/graphql/test/src/test/resources/schema.gql
+++ b/viewers/graphql/test/src/test/resources/schema.gql
@@ -4531,10 +4531,14 @@ type rich__university_dept_StaffMember {
   department: rich__university_dept_StaffMember__department__gqlv_property
   "Grade"
   grade: rich__university_dept_StaffMember__grade__gqlv_property
+  "Hidden Photo"
+  hiddenPhoto: rich__university_dept_StaffMember__hiddenPhoto__gqlv_property
   "Name"
   name: rich__university_dept_StaffMember__name__gqlv_property
   "Photo"
   photo: rich__university_dept_StaffMember__photo__gqlv_property
+  "Profile"
+  profile: rich__university_dept_StaffMember__profile__gqlv_property
 }
 
 "University department specializing in a field of study"
@@ -4570,6 +4574,21 @@ type rich__university_dept_StaffMember__grade__gqlv_property {
   validate(grade: rich__org_apache_causeway_viewer_graphql_viewer_test_domain_dept_Grade__gqlv_enum): String
 }
 
+type rich__university_dept_StaffMember__hiddenPhoto__gqlv_property {
+  datatype: String
+  disabled: String
+  get: rich__university_dept_StaffMember__hiddenPhoto__gqlv_property_lob
+  hidden: Boolean
+  set(hiddenPhoto: String!): rich__university_dept_StaffMember
+  validate(hiddenPhoto: String): String
+}
+
+type rich__university_dept_StaffMember__hiddenPhoto__gqlv_property_lob {
+  bytes: String
+  mimeType: String
+  name: String
+}
+
 type rich__university_dept_StaffMember__name__gqlv_property {
   datatype: String
   disabled: String
@@ -4594,6 +4613,21 @@ type rich__university_dept_StaffMember__photo__gqlv_property_lob {
   name: String
 }
 
+type rich__university_dept_StaffMember__profile__gqlv_property {
+  datatype: String
+  disabled: String
+  get: rich__university_dept_StaffMember__profile__gqlv_property_lob
+  hidden: Boolean
+  set(profile: String!): rich__university_dept_StaffMember
+  validate(profile: String): String
+}
+
+type rich__university_dept_StaffMember__profile__gqlv_property_lob {
+  chars: String
+  mimeType: String
+  name: String
+}
+
 type rich__university_dept_Staff__createStaffMember__department__gqlv_action_parameter {
   choices(name: String): [rich__university_dept_Department]
   datatype: String
@@ -5393,8 +5427,10 @@ type simple__university_dept_StaffMember {
   _meta: simple__university_dept_StaffMember__gqlv_meta
   department: simple__university_dept_Department
   grade: rich__org_apache_causeway_viewer_graphql_viewer_test_domain_dept_Grade__gqlv_enum!
+  hiddenPhoto: simple__university_dept_StaffMember__hiddenPhoto__gqlv_member
   name: String!
   photo: simple__university_dept_StaffMember__photo__gqlv_member
+  profile: simple__university_dept_StaffMember__profile__gqlv_member
 }
 
 type simple__university_dept_StaffMember__gqlv_meta {
@@ -5409,6 +5445,13 @@ type simple__university_dept_StaffMember__gqlv_meta {
   version: String
 }
 
+"Hidden Photo"
+type simple__university_dept_StaffMember__hiddenPhoto__gqlv_member {
+  bytes: String
+  mimeType: String
+  name: String
+}
+
 "Photo"
 type simple__university_dept_StaffMember__photo__gqlv_member {
   bytes: String
@@ -5416,6 +5459,13 @@ type simple__university_dept_StaffMember__photo__gqlv_member {
   name: String
 }
 
+"Profile"
+type simple__university_dept_StaffMember__profile__gqlv_member {
+  chars: String
+  mimeType: String
+  name: String
+}
+
 enum logicalTypeNames__gqlv_enum {
   "causeway.applib.DomainObjectList"
   causeway_applib_DomainObjectList
diff --git a/viewers/graphql/viewer/src/main/java/org/apache/causeway/viewer/graphql/viewer/controller/ResourceController.java b/viewers/graphql/viewer/src/main/java/org/apache/causeway/viewer/graphql/viewer/controller/ResourceController.java
index 5b098808e87..fc4dce31eb9 100644
--- a/viewers/graphql/viewer/src/main/java/org/apache/causeway/viewer/graphql/viewer/controller/ResourceController.java
+++ b/viewers/graphql/viewer/src/main/java/org/apache/causeway/viewer/graphql/viewer/controller/ResourceController.java
@@ -18,8 +18,12 @@
  */
 package org.apache.causeway.viewer.graphql.viewer.controller;
 
+import java.nio.charset.StandardCharsets;
+import java.security.Principal;
+import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
+import java.util.concurrent.Callable;
 
 import jakarta.inject.Inject;
 
@@ -35,199 +39,277 @@
 import org.springframework.web.bind.annotation.RestController;
 
 import org.apache.causeway.applib.annotation.ObjectSupport.IconSize;
+import org.apache.causeway.applib.annotation.Where;
 import org.apache.causeway.applib.services.bookmark.Bookmark;
 import org.apache.causeway.applib.services.bookmark.BookmarkService;
 import org.apache.causeway.applib.services.grid.GridService;
+import org.apache.causeway.applib.services.iactn.InteractionContext;
+import org.apache.causeway.applib.services.iactn.InteractionService;
+import org.apache.causeway.applib.services.user.RoleMemento;
+import org.apache.causeway.applib.services.user.UserMemento;
 import org.apache.causeway.applib.value.Blob;
 import org.apache.causeway.applib.value.Clob;
 import org.apache.causeway.applib.value.NamedWithMimeType.CommonMimeType;
+import org.apache.causeway.commons.collections.Can;
 import org.apache.causeway.core.config.CausewayConfiguration;
+import org.apache.causeway.core.metamodel.consent.InteractionInitiatedBy;
 import org.apache.causeway.core.metamodel.facets.object.grid.GridFacet;
 import org.apache.causeway.core.metamodel.object.ManagedObject;
+import org.apache.causeway.core.metamodel.object.MmVisibilityUtils;
 import org.apache.causeway.core.metamodel.objectmanager.ObjectManager;
 import org.apache.causeway.core.metamodel.spec.feature.OneToOneAssociation;
 
-@RestController()
-@RequestMapping("/graphql/object")
+@RestController
+@RequestMapping("${spring.graphql.http.path:${spring.graphql.path:/graphql}}/object")
 public record ResourceController(
     BookmarkService bookmarkService,
     ObjectManager objectManager,
     GridService gridService,
+    InteractionService interactionService,
     CausewayConfiguration.Viewer.Graphql graphqlConfiguration) {
 
+    private static final String PRIVATE_NO_STORE = "private, no-store";
+    private static final String NOSNIFF = "nosniff";
+
     @Inject
     public ResourceController(
             final BookmarkService bookmarkService,
             final ObjectManager objectManager,
             final GridService gridService,
+            final InteractionService interactionService,
             final CausewayConfiguration causewayConfiguration) {
-        this(bookmarkService, objectManager, gridService, causewayConfiguration.viewer().graphql());
+        this(
+                bookmarkService,
+                objectManager,
+                gridService,
+                interactionService,
+                causewayConfiguration.viewer().graphql());
     }
 
     @GetMapping(value = "/{logicalTypeName}:{id}/{propertyId}/blobBytes")
     public ResponseEntity<byte[]> propertyBlobBytes(
             @PathVariable final String logicalTypeName,
             @PathVariable final String id,
-            @PathVariable final String propertyId
-    ) {
-        var responseType = graphqlConfiguration.resources().responseType();
+            @PathVariable final String propertyId,
+            final Principal principal) {
+        return callWithinInteraction(
+                principal,
+                () -> propertyBlobBytesWithinInteraction(logicalTypeName, id, propertyId));
+    }
 
-        // TODO: perhaps a filter would factor this check out?
+    private ResponseEntity<byte[]> propertyBlobBytesWithinInteraction(
+            final String logicalTypeName,
+            final String id,
+            final String propertyId) {
+        var responseType = graphqlConfiguration.resources().effectiveValueContentResponseType();
         if (responseType == CausewayConfiguration.Viewer.Graphql.ResponseType.FORBIDDEN) {
-            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
+            return forbidden();
         }
 
-        return valueOfProperty(logicalTypeName, id, propertyId)
-                .filter(Blob.class::isInstance)
-                .map(Blob.class::cast)
-                .map(blob -> {
-                    var bodyBuilder = ResponseEntity.ok()
-                            .contentType(MediaType.asMediaType(MimeType.valueOf(blob.mimeType().toString())));
-                    if (responseType == CausewayConfiguration.Viewer.Graphql.ResponseType.ATTACHMENT) {
-                        bodyBuilder
-                                .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(blob.name()).build().toString())
-                                .contentLength(blob.bytes().length);
-                    }
-                    return bodyBuilder.body(blob.bytes());
-                })
-                .orElse(ResponseEntity.notFound().build());
+        return valueOfVisibleProperty(logicalTypeName, id, propertyId, Blob.class)
+                .map(blob -> resourceResponse(
+                        blob.bytes(),
+                        MediaType.asMediaType(MimeType.valueOf(blob.mimeType().toString())),
+                        blob.name(),
+                        responseType))
+                .orElseGet(ResourceController::notFound);
     }
 
     @GetMapping(value = "/{logicalTypeName}:{id}/{propertyId}/clobChars")
-    public ResponseEntity<CharSequence> propertyClobChars(
+    public ResponseEntity<byte[]> propertyClobChars(
             @PathVariable final String logicalTypeName,
             @PathVariable final String id,
-            @PathVariable final String propertyId
-    ) {
-        var responseType = graphqlConfiguration.resources().responseType();
+            @PathVariable final String propertyId,
+            final Principal principal) {
+        return callWithinInteraction(
+                principal,
+                () -> propertyClobCharsWithinInteraction(logicalTypeName, id, propertyId));
+    }
 
-        // TODO: perhaps a filter would factor this check out?
+    private ResponseEntity<byte[]> propertyClobCharsWithinInteraction(
+            final String logicalTypeName,
+            final String id,
+            final String propertyId) {
+        var responseType = graphqlConfiguration.resources().effectiveValueContentResponseType();
         if (responseType == CausewayConfiguration.Viewer.Graphql.ResponseType.FORBIDDEN) {
-            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
+            return forbidden();
         }
 
-        return valueOfProperty(logicalTypeName, id, propertyId)
-                .filter(Clob.class::isInstance)
-                .map(Clob.class::cast)
-                .map(clob -> {
-                    var bodyBuilder = ResponseEntity.ok()
-                            .contentType(MediaType.asMediaType(MimeType.valueOf(clob.mimeType().toString())));
-                    if (responseType == CausewayConfiguration.Viewer.Graphql.ResponseType.ATTACHMENT) {
-                        bodyBuilder.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(clob.name()).build().toString())
-                                .contentLength(clob.chars().length());
-                    }
-                    return bodyBuilder.body(clob.chars());
-                })
-                .orElse(ResponseEntity.notFound().build());
+        return valueOfVisibleProperty(logicalTypeName, id, propertyId, Clob.class)
+                .map(clob -> resourceResponse(
+                        clob.chars().toString().getBytes(StandardCharsets.UTF_8),
+                        MediaType.asMediaType(MimeType.valueOf(clob.mimeType().toString())),
+                        clob.name(),
+                        responseType))
+                .orElseGet(ResourceController::notFound);
     }
 
     @GetMapping(value = "/{logicalTypeName}:{id}/{_meta}/grid")
-    public ResponseEntity<String> grid(
+    public ResponseEntity<byte[]> grid(
             @PathVariable final String logicalTypeName,
             @PathVariable final String id,
-            @PathVariable final String _meta
-    ) {
-        var responseType = graphqlConfiguration.resources().responseType();
+            @PathVariable final String _meta,
+            final Principal principal) {
+        return callWithinInteraction(
+                principal,
+                () -> gridWithinInteraction(logicalTypeName, id, _meta));
+    }
 
-        // TODO: perhaps a filter would factor this check out?
+    private ResponseEntity<byte[]> gridWithinInteraction(
+            final String logicalTypeName,
+            final String id,
+            final String _meta) {
+        var responseType = graphqlConfiguration.resources().effectiveStructuralMetadataResponseType();
         if (responseType == CausewayConfiguration.Viewer.Graphql.ResponseType.FORBIDDEN) {
-            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
+            return forbidden();
         }
-
         if (!_meta.equals(graphqlConfiguration.metaData().fieldName())) {
-            return ResponseEntity.notFound().build();
+            return notFound();
         }
 
-        return lookup(logicalTypeName, id)
-                .map(mo->gridAsXml(mo).orElse(null))
+        return lookupVisible(logicalTypeName, id)
+                .map(managedObject -> gridAsXml(managedObject).orElse(null))
                 .filter(Objects::nonNull)
-                .map(gridText -> {
-                    var bodyBuilder = ResponseEntity.ok()
-                            .contentType(MediaType.APPLICATION_XML);
-                    if (responseType == CausewayConfiguration.Viewer.Graphql.ResponseType.ATTACHMENT) {
-                        bodyBuilder
-                                .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(logicalTypeName + ".layout.xml").build().toString())
-                                .contentLength(gridText.length());
-                    }
-                    return bodyBuilder.body(gridText);
-                })
-                .orElse(ResponseEntity.notFound().build());
+                .map(gridText -> resourceResponse(
+                        gridText.getBytes(StandardCharsets.UTF_8),
+                        MediaType.APPLICATION_XML,
+                        logicalTypeName + ".layout.xml",
+                        responseType))
+                .orElseGet(ResourceController::notFound);
     }
 
     @GetMapping(value = "/{logicalTypeName}:{id}/{_meta}/icon")
     public ResponseEntity<byte[]> icon(
             @PathVariable final String logicalTypeName,
             @PathVariable final String id,
-            @PathVariable final String _meta
-    ) {
-        // TODO: perhaps a filter would factor this check out?
-        var responseType = graphqlConfiguration.resources().responseType();
+            @PathVariable final String _meta,
+            final Principal principal) {
+        return callWithinInteraction(
+                principal,
+                () -> iconWithinInteraction(logicalTypeName, id, _meta));
+    }
+
+    private ResponseEntity<byte[]> iconWithinInteraction(
+            final String logicalTypeName,
+            final String id,
+            final String _meta) {
+        var responseType = graphqlConfiguration.resources().effectiveStructuralMetadataResponseType();
         if (responseType == CausewayConfiguration.Viewer.Graphql.ResponseType.FORBIDDEN) {
-            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
+            return forbidden();
         }
         if (!_meta.equals(graphqlConfiguration.metaData().fieldName())) {
-            return ResponseEntity.notFound().build();
+            return notFound();
         }
 
-        return lookup(logicalTypeName, id)
-                .map(mo->mo.getIcon(IconSize.MEDIUM))
+        return lookupVisible(logicalTypeName, id)
+                .map(managedObject -> managedObject.getIcon(IconSize.MEDIUM))
                 .filter(Objects::nonNull)
-                .map(objectIcon -> {
-                    var bytes = objectIcon.iconData();
-                    var bodyBuilder = ResponseEntity.ok()
-                            .contentType(MediaType.parseMediaType(objectIcon.mediaType()));
-                    if (responseType == CausewayConfiguration.Viewer.Graphql.ResponseType.ATTACHMENT) {
-                        bodyBuilder
-                                .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(logicalTypeName + ".png").build().toString())
-                                .contentLength(bytes.length);
-                    }
-                    return bodyBuilder.body(bytes);
-                })
-                .orElse(ResponseEntity.notFound().build());
+                .map(objectIcon -> resourceResponse(
+                        objectIcon.iconData(),
+                        MediaType.parseMediaType(objectIcon.mediaType()),
+                        logicalTypeName + ".png",
+                        responseType))
+                .orElseGet(ResourceController::notFound);
+    }
+
+    private <T> T callWithinInteraction(
+            final Principal principal,
+            final Callable<T> callable) {
+        var userMemento = userMemento(principal);
+        return userMemento != null
+                ? interactionService.call(
+                        InteractionContext.builder().user(userMemento).build(),
+                        callable)
+                : interactionService.callAnonymous(callable);
+    }
+
+    private UserMemento userMemento(final Principal principal) {
+        if (principal != null) {
+            return UserMemento.builder(principal.getName()).build();
+        }
+        var fallback = graphqlConfiguration.authentication().fallback();
+        if (fallback == null || fallback.username() == null) {
+            return null;
+        }
+        var roleNames = Optional.ofNullable(fallback.roles()).orElseGet(List::of);
+        var roles = Can.ofStream(roleNames.stream()
+                .map(roleName -> RoleMemento.builder().name(roleName).build()));
+        return UserMemento.builder(fallback.username())
+                .roles(roles)
+                .build();
     }
 
     private Optional<String> gridAsXml(final ManagedObject managedObject) {
         return managedObject.objSpec().lookupFacet(GridFacet.class)
-            .map(facet->facet.getGrid(managedObject))
-            .flatMap(grid->gridService().marshaller(CommonMimeType.XML).map(mars->mars.marshal(grid, CommonMimeType.XML)))
-            .map(x -> x.replaceAll("(\r\n)", "\n"));
+            .map(facet -> facet.getGrid(managedObject))
+            .flatMap(grid -> gridService().marshaller(CommonMimeType.XML)
+                    .map(marshaller -> marshaller.marshal(grid, CommonMimeType.XML)))
+            .map(xml -> xml.replace("\r\n", "\n"));
     }
 
-    private Optional<Object> valueOfProperty(final String logicalTypeName, final String id, final String propertyId) {
-        return lookup(logicalTypeName, id)
-                .map(managedObject -> new ManagedObjectAndPropertyIfAny(managedObject, managedObject.objSpec().getProperty(propertyId)))
-                .filter(ManagedObjectAndPropertyIfAny::isPropertyPresent)
-                .map(ManagedObjectAndProperty::of)
-                .map(ManagedObjectAndProperty::value)
-                .map(ManagedObject::getPojo);
+    private <T> Optional<T> valueOfVisibleProperty(
+            final String logicalTypeName,
+            final String id,
+            final String propertyId,
+            final Class<T> requiredType) {
+        return lookupVisible(logicalTypeName, id)
+                .flatMap(managedObject -> managedObject.objSpec().getProperty(propertyId)
+                        .filter(property -> isVisible(property, managedObject))
+                        .map(property -> property.get(managedObject)))
+                .map(ManagedObject::getPojo)
+                .filter(requiredType::isInstance)
+                .map(requiredType::cast);
     }
 
-    private Optional<ManagedObject> lookup(final String logicalTypeName, final String id) {
-        return bookmarkService.lookup(Bookmark.forLogicalTypeNameAndIdentifier(logicalTypeName, id))
-                .map(objectManager::adapt);
+    private static boolean isVisible(
+            final OneToOneAssociation property,
+            final ManagedObject owningObject) {
+        return property.isVisible(owningObject, InteractionInitiatedBy.FRAMEWORK, Where.ANYWHERE).isAllowed()
+                && property.isVisible(owningObject, InteractionInitiatedBy.USER, Where.ANYWHERE).isAllowed();
     }
 
-    private record ManagedObjectAndPropertyIfAny(
-            ManagedObject owningObject,
-            Optional<OneToOneAssociation> propertyIfAny) {
-        boolean isPropertyPresent() {
-            return propertyIfAny.isPresent();
+    private Optional<ManagedObject> lookupVisible(final String logicalTypeName, final String id) {
+        try {
+            return bookmarkService.lookup(Bookmark.forLogicalTypeNameAndIdentifier(logicalTypeName, id))
+                    .map(objectManager::adapt)
+                    .filter(managedObject -> MmVisibilityUtils.isVisible(
+                            managedObject,
+                            InteractionInitiatedBy.FRAMEWORK))
+                    .filter(managedObject -> MmVisibilityUtils.isVisible(
+                            managedObject,
+                            InteractionInitiatedBy.USER));
+        } catch (RuntimeException ex) {
+            return Optional.empty();
         }
     }
 
-    private static class ManagedObjectAndProperty {
-        private static ManagedObjectAndProperty of(final ManagedObjectAndPropertyIfAny tuple) {
-            return new ManagedObjectAndProperty(tuple);
-        }
-        private ManagedObjectAndProperty(final ManagedObjectAndPropertyIfAny tuple) {
-            this.owningObject = tuple.owningObject;
-            this.property = tuple.propertyIfAny.orElse(null);
+    private static ResponseEntity<byte[]> resourceResponse(
+            final byte[] content,
+            final MediaType mediaType,
+            final String fileName,
+            final CausewayConfiguration.Viewer.Graphql.ResponseType responseType) {
+        var bodyBuilder = ResponseEntity.ok()
+                .contentType(mediaType)
+                .contentLength(content.length)
+                .header(HttpHeaders.CACHE_CONTROL, PRIVATE_NO_STORE)
+                .header("X-Content-Type-Options", NOSNIFF);
+        if (responseType == CausewayConfiguration.Viewer.Graphql.ResponseType.ATTACHMENT) {
+            bodyBuilder.header(
+                    HttpHeaders.CONTENT_DISPOSITION,
+                    ContentDisposition.attachment().filename(fileName).build().toString());
         }
-        ManagedObject owningObject;
-        OneToOneAssociation property;
+        return bodyBuilder.body(content);
+    }
 
-        ManagedObject value() {
-            return property.get(owningObject);
-        }
+    private static <T> ResponseEntity<T> forbidden() {
+        return ResponseEntity.status(HttpStatus.FORBIDDEN)
+                .header(HttpHeaders.CACHE_CONTROL, PRIVATE_NO_STORE)
+                .build();
+    }
+
+    private static <T> ResponseEntity<T> notFound() {
+        return ResponseEntity.status(HttpStatus.NOT_FOUND)
+                .header(HttpHeaders.CACHE_CONTROL, PRIVATE_NO_STORE)
+                .build();
     }
 }


(causeway) 03/03: archives rich GraphQL resource link safety

Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

danhaywood pushed a commit to branch htmx
in repository https://gitbox.apache.org/repos/asf/causeway.git

commit a6f8242d7cdfb7b9607244e4739159d75f3f797e
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Tue Aug 18 06:45:59 2026 +0100

    archives rich GraphQL resource link safety
---
 .../.openspec.yaml                                                | 0
 .../2026-08-18-fix-rich-graphql-resource-link-safety}/design.md   | 0
 .../2026-08-18-fix-rich-graphql-resource-link-safety}/proposal.md | 0
 .../specs/rich-graphql-resource-link-safety/spec.md               | 0
 .../2026-08-18-fix-rich-graphql-resource-link-safety}/tasks.md    | 0
 .../specs/rich-graphql-resource-link-safety/spec.md               | 8 +++++++-
 6 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/openspec/changes/fix-rich-graphql-resource-link-safety/.openspec.yaml b/openspec/changes/archive/2026-08-18-fix-rich-graphql-resource-link-safety/.openspec.yaml
similarity index 100%
rename from openspec/changes/fix-rich-graphql-resource-link-safety/.openspec.yaml
rename to openspec/changes/archive/2026-08-18-fix-rich-graphql-resource-link-safety/.openspec.yaml
diff --git a/openspec/changes/fix-rich-graphql-resource-link-safety/design.md b/openspec/changes/archive/2026-08-18-fix-rich-graphql-resource-link-safety/design.md
similarity index 100%
rename from openspec/changes/fix-rich-graphql-resource-link-safety/design.md
rename to openspec/changes/archive/2026-08-18-fix-rich-graphql-resource-link-safety/design.md
diff --git a/openspec/changes/fix-rich-graphql-resource-link-safety/proposal.md b/openspec/changes/archive/2026-08-18-fix-rich-graphql-resource-link-safety/proposal.md
similarity index 100%
rename from openspec/changes/fix-rich-graphql-resource-link-safety/proposal.md
rename to openspec/changes/archive/2026-08-18-fix-rich-graphql-resource-link-safety/proposal.md
diff --git a/openspec/changes/fix-rich-graphql-resource-link-safety/specs/rich-graphql-resource-link-safety/spec.md b/openspec/changes/archive/2026-08-18-fix-rich-graphql-resource-link-safety/specs/rich-graphql-resource-link-safety/spec.md
similarity index 100%
copy from openspec/changes/fix-rich-graphql-resource-link-safety/specs/rich-graphql-resource-link-safety/spec.md
copy to openspec/changes/archive/2026-08-18-fix-rich-graphql-resource-link-safety/specs/rich-graphql-resource-link-safety/spec.md
diff --git a/openspec/changes/fix-rich-graphql-resource-link-safety/tasks.md b/openspec/changes/archive/2026-08-18-fix-rich-graphql-resource-link-safety/tasks.md
similarity index 100%
rename from openspec/changes/fix-rich-graphql-resource-link-safety/tasks.md
rename to openspec/changes/archive/2026-08-18-fix-rich-graphql-resource-link-safety/tasks.md
diff --git a/openspec/changes/fix-rich-graphql-resource-link-safety/specs/rich-graphql-resource-link-safety/spec.md b/openspec/specs/rich-graphql-resource-link-safety/spec.md
similarity index 91%
rename from openspec/changes/fix-rich-graphql-resource-link-safety/specs/rich-graphql-resource-link-safety/spec.md
rename to openspec/specs/rich-graphql-resource-link-safety/spec.md
index 2d3bf50b0ac..6acf86ef510 100644
--- a/openspec/changes/fix-rich-graphql-resource-link-safety/specs/rich-graphql-resource-link-safety/spec.md
+++ b/openspec/specs/rich-graphql-resource-link-safety/spec.md
@@ -1,4 +1,10 @@
-## ADDED Requirements
+# Rich GraphQL Resource-Link Safety Specification
+
+## Purpose
+
+Define valid same-origin resource references, independent structural and value-content policies, authorization rechecks, and compatible migration for rich GraphQL resources.
+
+## Requirements
 
 ### Requirement: Valid same-origin resource references
 Every enabled rich GraphQL metadata or value resource reference SHALL resolve against the application's current origin and configured GraphQL path without introducing a network authority.