feat(portal): scoped document download (portal-document-download) - #6
Merged
Merged
Conversation
Closes the portal's upload/download asymmetry: a subject who could already
attach a file to their own row (filesUpload) can now retrieve one back.
- GET /portal/api/collections/{register}/{schema}/{id}/files/{fileId} streams
a file attached to an owned row after the SAME ownership + tenant + trust
re-verification as the scoped read (ContributionController::downloadFile()).
- Opt-in `filesDownload: true` per collection, normalised fail-closed exactly
like the existing `filesUpload` flag.
- Identical-404 discipline: non-opted-in, foreign-owned, and non-existent all
return the same body — no existence oracle, and the raw stored path is
never exposed (PortalFileReader delegates streaming to OpenRegister's own
FileService::streamFile() rather than re-implementing header sanitisation).
- Audit hook placed on the successful-download path (PortalAuditHook),
resolved by class name so it is a documented no-op until
portal-session-hardening-v2 registers the real AuditTrailService.
- SPA: object() attaches a safe `_files` listing when a collection opts in;
the detail view renders a download list (src/portal/components/PageView.jsx,
portalApi.js) fetched with the bearer auth header and saved via a Blob.
- Unit tests for the normaliser, the file reader, the audit hook, and the
controller's download path (opt-in gate, ownership-before-stream, identical
404s, audit invocation); Playwright e2e spec for the live download + 404
scenarios.
- Archived to openspec/specs/supplier-portal/spec.md (new canonical file).
Contributor
Quality Report — ConductionNL/portaliq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ❌ | ||||
| phpmd | ❌ | ||||
| psalm | ❌ | ||||
| phpstan | ❌ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ❌ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 746/746 | |||
| PHPUnit | ⏭️ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
Quality workflow — 2026-07-23 18:19 UTC
Download the full PDF report from the workflow artifacts.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
GET /portal/api/collections/{register}/{schema}/{id}/files/{fileId}— streams a file attached to an owned row after the SAME ownership + tenant + trust re-verification as the scoped single-object read (ContributionController::downloadFile()), the read-side counterpart of the existinguploadFile().filesDownload: trueper collection, normalised fail-closed exactly likefilesUpload(PortalManifestNormaliser).fileIdall return the exact same{"error":"not_found"}body — no existence oracle.PortalFileReaderresolves the file strictly within the owned object's own OpenRegister folder and delegates the actual stream +Content-Dispositionheader to OpenRegister's ownFileService::streamFile()(never re-implements that sanitisation).PortalAuditHook) placed on the successful-download path — resolvesAuditTrailServiceby class name via the container (mirrors howPortalFileWriterresolves OpenRegister'sFileService), so it is a documented no-op today and starts recording the momentportal-session-hardening-v2registers the real service.object()attaches a safe_fileslisting (id/name/size only) when the collection opts in; the detail view (PageView.jsx) renders a download list, fetched with the bearer auth header (a plain<a href>can't carry it) and saved via a Blob object URL (portalApi.js).exampleCollection(PortalContributionProvider) now also declaresfilesDownload: trueso the feature is exercisable end-to-end alongside the existingfilesUploadopt-in.openspec/specs/supplier-portal/spec.mdcreated (canonical file did not exist yet) with this change's 4 requirements synced in; change dir moved toopenspec/changes/archive/2026-07-23-portal-document-download/.Test plan
phpunit -c phpunit-unit.xml— 190 tests (21 new:PortalFileReaderTest,PortalAuditHookTest,PortalManifestNormaliserTest::testFilesDownloadIsCoercedToAStrictBoolean, 8 newContributionControllerTestdownload/file-list cases). All 21 new tests pass. The suite's pre-existing 3 errors + 6 failures (PortalSessionServiceTestnamed-parameter drift,PortalPageControllerTestCSP/template assertions) are verified identical on anorigin/developmentbaseline worktree — unrelated to this change, in files not touched here.phpcs— 0 errors/warnings on every touched/new file (fixed two pre-existing-adjacent issues surfaced by the diff: array=>re-alignment inPortalContributionProvider.phpafter adding a longer key, and a missing//end foreachinPortalManifestNormaliser.php). Remaining 11 errors / 27 warnings are all in files this change never touches — verified identical in scope tolib/'s existing debt.phpstan analyse --memory-limit=1G— 0 new errors. The 10 pre-existing errors inPortalSessionService.php(untouched) are verified identical against theorigin/developmentbaseline.psalm --no-cache— 0 new errors (12 pre-existing errors inPortalSessionService.php, verified identical against baseline). +2 INFO-levelMissingClassConstTypenotices on the two new services' constants, consistent with the existing untyped-const style already used fleet-wide (e.g.PortalFileWriter::FILE_SERVICE).npm run lint— 0 errors (28 warnings, 4 new — missing JSDoc@paramondownloadFile(), matching the pre-existing style gap on sibling methods likeuploadFile()).npm run build:portal— compiles cleanly, no warnings.npm run check:specs(json-strict / manifest-v2 / register / registry) — all PASS.openspec validate --all— 28 passed / 3 failed, identical pre-existing failures to baseline (contribution-manifest-v3,portal-contribution-contract,portal-status-transitions— none touched by this change);spec/supplier-portal(this change) is ✓.tests/e2e/portal-document-download.spec.ts) — written, not live-run against a running dev instance in this pass (requiresdebug: truedev-login on a live 8080); see the spec file's header for the manual run command.🤖 Generated with Claude Code