diff --git a/.github/workflows/php_tests.yml b/.github/workflows/php_tests.yml
index 808c4fa446d..10db604110d 100644
--- a/.github/workflows/php_tests.yml
+++ b/.github/workflows/php_tests.yml
@@ -29,6 +29,7 @@ jobs:
test-suite:
- Unit
- Feature_v2
+ - Feature_v3
- Install
- Webshop
- ImageProcessing
@@ -40,6 +41,8 @@ jobs:
test-suite: Unit
- php-version: 8.4
test-suite: Feature_v2
+ - php-version: 8.4
+ test-suite: Feature_v3
- php-version: 8.4
test-suite: Webshop
- php-version: 8.4
diff --git a/app/Contracts/Http/Requests/RequestAttribute.php b/app/Contracts/Http/Requests/RequestAttribute.php
index b5fbf3ae1c2..b7e4628d618 100644
--- a/app/Contracts/Http/Requests/RequestAttribute.php
+++ b/app/Contracts/Http/Requests/RequestAttribute.php
@@ -36,6 +36,7 @@ class RequestAttribute
public const PHOTO_ID_ATTRIBUTE = 'photo_id';
public const PHOTO_IDS_ATTRIBUTE = 'photo_ids';
+ public const SIZE_VARIANT_TOKEN_ATTRIBUTE = 'size_variant';
public const HEADER_ID_ATTRIBUTE = 'header_id';
public const TITLE_ATTRIBUTE = 'title';
diff --git a/app/Enum/SizeVariantAssetType.php b/app/Enum/SizeVariantAssetType.php
new file mode 100644
index 00000000000..871b40f2a48
--- /dev/null
+++ b/app/Enum/SizeVariantAssetType.php
@@ -0,0 +1,37 @@
+ SizeVariantType::SMALL2X,
+ self::SMALL => SizeVariantType::SMALL,
+ self::THUMB2X => SizeVariantType::THUMB2X,
+ self::THUMB => SizeVariantType::THUMB,
+ self::PLACEHOLDER => SizeVariantType::PLACEHOLDER,
+ };
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Controllers/Gallery/PhotoAssetController.php b/app/Http/Controllers/Gallery/PhotoAssetController.php
new file mode 100644
index 00000000000..59b6fa50d51
--- /dev/null
+++ b/app/Http/Controllers/Gallery/PhotoAssetController.php
@@ -0,0 +1,42 @@
+sizeVariant();
+ $path = $watermarker->get_path($size_variant);
+ $disk = Storage::disk($size_variant->storage_disk->value);
+
+ /** @disregard P1013 */
+ if ($disk->getAdapter() instanceof AwsS3V3Adapter) {
+ $life_in_seconds = resolve(ConfigManager::class)->getValueAsInt('temporary_image_link_life_in_seconds');
+
+ /** @disregard P1013 */
+ return redirect()->away($disk->temporaryUrl($path, now()->addSeconds($life_in_seconds)));
+ }
+
+ $file = new FlysystemFile($disk, $path);
+
+ return response()->file($file->toLocalFile()->getPath());
+ }
+}
diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php
index 2404442cb66..c531ab13e64 100644
--- a/app/Http/Kernel.php
+++ b/app/Http/Kernel.php
@@ -97,6 +97,7 @@ class Kernel extends HttpKernel
'migration' => \App\Http\Middleware\MigrationStatus::class,
'content_type' => \App\Http\Middleware\ContentType::class,
'accept_content_type' => \App\Http\Middleware\AcceptContentType::class,
+ 'json_errors' => \App\Http\Middleware\EnsureJsonErrorResponses::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'login_required' => \App\Http\Middleware\LoginRequired::class,
'cache_control' => \App\Http\Middleware\CacheControl::class,
diff --git a/app/Http/Middleware/EnsureJsonErrorResponses.php b/app/Http/Middleware/EnsureJsonErrorResponses.php
new file mode 100644
index 00000000000..fbf4f801790
--- /dev/null
+++ b/app/Http/Middleware/EnsureJsonErrorResponses.php
@@ -0,0 +1,33 @@
+headers->set('Accept', 'application/json');
+
+ return $next($request);
+ }
+}
diff --git a/app/Http/Requests/Photo/GetPhotoAssetRequest.php b/app/Http/Requests/Photo/GetPhotoAssetRequest.php
new file mode 100644
index 00000000000..41655a975a9
--- /dev/null
+++ b/app/Http/Requests/Photo/GetPhotoAssetRequest.php
@@ -0,0 +1,313 @@
+size_variant;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function authorize(): bool
+ {
+ /** @var User|null $user */
+ $user = Auth::user();
+
+ if ($this->signatureRequired($user) && !$this->isSignatureValid()) {
+ $this->signature_check_failed = true;
+
+ return false;
+ }
+
+ // The album must itself be accessible to the caller (owner, shared
+ // permission, or public); membership is then checked separately,
+ // deliberately bypassing the NSFW/visibility filtering that
+ // Album::photos()/all_photos() bake in — that filtering answers a
+ // different question ("should this photo surface in a listing?")
+ // than the one we're asking here ("is this photo part of what
+ // album_id legitimately represents?").
+ return Gate::check(AlbumPolicy::CAN_ACCESS, [AbstractAlbum::class, $this->album]) &&
+ $this->isPhotoOfAlbum($this->album);
+ }
+
+ /**
+ * Whether `$this->photo_id` is part of `$album`, without any
+ * visibility/searchability filtering (see {@link self::authorize()}).
+ *
+ * For a regular {@link Album}, this also allows the photo through if it
+ * is that album's cover — hardcoded (`cover_id`) or automatically
+ * selected (`auto_cover_id_max_privilege`/`auto_cover_id_least_privilege`)
+ * — since a cover photo legitimately represents the album even when it
+ * physically lives in a descendant album, without needing to walk the
+ * `_lft`/`_rgt` subtree to find it. {@link TagAlbum} and
+ * {@link PersonAlbum} have no descendants, but the same cover exception
+ * applies: TagAlbum's own hardcoded `cover_id`, and — for both — the
+ * current viewer's cached computed thumb (`album_user_thumbs`, the
+ * tag/person equivalent of `auto_cover_id_*`; see
+ * {@link \App\Models\Extensions\CachesAlbumUserThumb}).
+ */
+ private function isPhotoOfAlbum(AbstractAlbum $album): bool
+ {
+ if ($album instanceof Album) {
+ if (in_array($this->photo_id, [
+ $album->cover_id,
+ $album->auto_cover_id_max_privilege,
+ $album->auto_cover_id_least_privilege,
+ ], true)) {
+ return true;
+ }
+
+ return DB::table(PhotoAlbum::PHOTO_ALBUM)
+ ->where(PhotoAlbum::ALBUM_ID, $album->id)
+ ->where(PhotoAlbum::PHOTO_ID, $this->photo_id)
+ ->exists();
+ }
+
+ if ($album instanceof TagAlbum && $album->cover_id === $this->photo_id) {
+ return true;
+ }
+
+ if (($album instanceof TagAlbum || $album instanceof PersonAlbum) && $this->isComputedAlbumThumb($album->id)) {
+ return true;
+ }
+
+ return $album->photos()->whereKey($this->photo_id)->exists();
+ }
+
+ /**
+ * Whether `$this->photo_id` is the current viewer's cached computed
+ * thumb for `$album_id` — the tag/person-album equivalent of Album's
+ * `auto_cover_id_*` fields (see {@link \App\Models\AlbumUserThumb}).
+ * `Auth::id()` is `null` for a guest, matching the cache's convention
+ * for the public/guest view of the album.
+ */
+ private function isComputedAlbumThumb(string $album_id): bool
+ {
+ return DB::table('album_user_thumbs')
+ ->where('album_id', $album_id)
+ ->where('user_id', Auth::id())
+ ->where('photo_id', $this->photo_id)
+ ->exists();
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * The inherited {@link BaseApiRequest::failedAuthorization()} keys its
+ * 401-vs-403 choice off `Auth::check()` (session state), which is wrong
+ * here: a signature-valid guest denied by PhotoPolicy needs 403
+ * (S-056-03), while a logged-in user missing a config-required signature
+ * needs 401 (S-056-10) despite having a session.
+ */
+ protected function failedAuthorization(): void
+ {
+ throw $this->signature_check_failed ? new UnauthenticatedException() : new UnauthorizedException();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function rules(): array
+ {
+ return [
+ RequestAttribute::ALBUM_ID_ATTRIBUTE => ['required', new RandomIDRule(false)],
+ RequestAttribute::PHOTO_ID_ATTRIBUTE => ['required', new RandomIDRule(false)],
+ RequestAttribute::SIZE_VARIANT_TOKEN_ATTRIBUTE => ['required', 'string', new Enum(SizeVariantAssetType::class)],
+ self::TIMESTAMP_ATTRIBUTE => ['nullable', 'integer', 'required_with:' . self::MAC_ATTRIBUTE],
+ self::MAC_ATTRIBUTE => ['nullable', 'string', 'required_with:' . self::TIMESTAMP_ATTRIBUTE],
+ ];
+ }
+
+ /**
+ * Merge route parameters and the temporary-link headers into request
+ * data for validation.
+ */
+ protected function prepareForValidation(): void
+ {
+ /** @disregard */
+ $this->merge([
+ RequestAttribute::ALBUM_ID_ATTRIBUTE => $this->route(RequestAttribute::ALBUM_ID_ATTRIBUTE),
+ RequestAttribute::PHOTO_ID_ATTRIBUTE => $this->route(RequestAttribute::PHOTO_ID_ATTRIBUTE),
+ RequestAttribute::SIZE_VARIANT_TOKEN_ATTRIBUTE => $this->route(RequestAttribute::SIZE_VARIANT_TOKEN_ATTRIBUTE),
+ self::TIMESTAMP_ATTRIBUTE => $this->header(self::TIMESTAMP_HEADER),
+ self::MAC_ATTRIBUTE => $this->header(self::MAC_HEADER),
+ ]);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ protected function processValidatedValues(array $values, array $files): void
+ {
+ /** @var string $album_id */
+ $album_id = $values[RequestAttribute::ALBUM_ID_ATTRIBUTE];
+ $this->photo_id = $values[RequestAttribute::PHOTO_ID_ATTRIBUTE];
+ /** @var string $size_variant_token */
+ $size_variant_token = $values[RequestAttribute::SIZE_VARIANT_TOKEN_ATTRIBUTE];
+
+ $this->album = $this->album_factory->findAbstractAlbumOrFail($album_id, false);
+ $this->size_variant_type = SizeVariantAssetType::from($size_variant_token)->toSizeVariantType();
+
+ // SizeVariant must stay a real model: Watermarker::get_path() and the
+ // controller rely on its enum casts (type, storage_disk). We still
+ // limit the hydrated columns to only what those two call sites read.
+ $this->size_variant = SizeVariant::query()
+ ->select(['id', 'photo_id', 'type', 'short_path', 'short_path_watermarked', 'storage_disk'])
+ ->where('photo_id', '=', $this->photo_id)
+ ->where('type', '=', $this->size_variant_type)
+ ->firstOrFail();
+
+ $this->timestamp = $values[self::TIMESTAMP_ATTRIBUTE] ?? null;
+ $this->mac = $values[self::MAC_ATTRIBUTE] ?? null;
+ }
+
+ /**
+ * Determines whether `$user` must additionally present a valid
+ * temporary-link signature to be authorized (ADR-0008).
+ *
+ * Guests are only ever authorized via a valid temporary link (FR-056-05)
+ * — always `true`, regardless of config; {@link self::isSignatureValid()}
+ * separately rejects them outright when the feature is globally
+ * disabled. For authenticated users, this mirrors
+ * {@link \App\Services\UrlGenerator::shouldNotUseSignedUrl()}'s
+ * generation-time predicate, re-purposed for validation.
+ */
+ private function signatureRequired(?User $user): bool
+ {
+ if ($user === null) {
+ return true;
+ }
+
+ if (!$this->configs()->getValueAsBool('temporary_image_link_enabled')) {
+ return false;
+ }
+
+ if ($user->may_administrate) {
+ return $this->configs()->getValueAsBool('temporary_image_link_when_admin');
+ }
+
+ return $this->configs()->getValueAsBool('temporary_image_link_when_logged_in');
+ }
+
+ /**
+ * Validates the temporary-link signature: the feature must be globally
+ * enabled, both headers must be present (already guaranteed by
+ * rules()'s both-or-neither validation, but a missing pair is still a
+ * failure here, not a pass), the MAC must verify, and the timestamp must
+ * be neither expired nor in the future (FR-056-04).
+ */
+ public function isSignatureValid(): bool
+ {
+ if (!$this->configs()->getValueAsBool('temporary_image_link_enabled')) {
+ return false;
+ }
+
+ if ($this->timestamp === null || $this->mac === null) {
+ return false;
+ }
+
+ if (!(new TemporaryLinkSigner())->verify($this->timestamp, $this->mac)) {
+ return false;
+ }
+
+ $now = now()->timestamp;
+ if ($this->timestamp > $now) {
+ return false;
+ }
+
+ $life_in_seconds = $this->configs()->getValueAsInt('temporary_image_link_life_in_seconds');
+
+ return ($now - $this->timestamp) <= $life_in_seconds;
+ }
+}
diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php
index 3454abd2a1a..dd30413f215 100644
--- a/app/Providers/RouteServiceProvider.php
+++ b/app/Providers/RouteServiceProvider.php
@@ -49,6 +49,7 @@ public function boot()
Route::middleware('web-admin')->group(base_path('routes/web-admin-v2.php'));
Route::middleware('api')->prefix('api/v2')->group(base_path('routes/api_v2.php'));
Route::middleware('api')->prefix('api/v2')->group(base_path('routes/api_v2_shop.php'));
+ Route::middleware('api')->prefix('api/v3')->group(base_path('routes/api_v3.php'));
Route::middleware('web-install')->group(base_path('routes/web-install.php'));
Route::middleware('web')->group(base_path('routes/web_v2.php'));
}
diff --git a/app/Services/TemporaryLinkSigner.php b/app/Services/TemporaryLinkSigner.php
new file mode 100644
index 00000000000..c5380ebe899
--- /dev/null
+++ b/app/Services/TemporaryLinkSigner.php
@@ -0,0 +1,39 @@
+sign($timestamp), $mac);
+ }
+}
diff --git a/codecov.yml b/codecov.yml
index d799b0b47a0..009ca046ce7 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -3,7 +3,7 @@ codecov:
require_ci_to_pass: true
notify:
wait_for_ci: true
- after_n_builds: 24
+ after_n_builds: 27
comment:
behavior: default
require_base: false
diff --git a/docs/specs/3-reference/api-design.md b/docs/specs/3-reference/api-design.md
index 88aa0856ac9..f36754aed77 100644
--- a/docs/specs/3-reference/api-design.md
+++ b/docs/specs/3-reference/api-design.md
@@ -162,8 +162,45 @@ For comprehensive documentation about custom validation rules, see [app/Rules/RE
Lychee uses route-based versioning:
-- **v2 API**: Current version (`/api/v2/...`)
-- Future versions can be added without breaking existing integrations
+- **v2 API**: Current version (`/api/v2/...`), Array-of-Structs (AoS) response convention — collection endpoints return arrays of self-contained objects (see `PaginatedPhotosResource`/`PaginatedAlbumsResource` below).
+- **v3 API**: Greenfield surface (`/api/v3/...`), additive and coexisting with v2 — nothing in v2 is deprecated or changed by v3's introduction. v3 establishes a Struct-of-Arrays (SoA) response convention for future *collection* endpoints (ADR-0009), though the first v3 endpoint below is single-item and doesn't need it.
+- Future versions can be added without breaking existing integrations.
+
+### API v3: Photo Asset Retrieval
+
+**GET** `/api/v3/Photo/{photo_id}/Asset/{size_variant}`
+
+Retrieves a single photo size-variant's binary file (thumbnail through original), watermark-aware. Registered via `routes/api_v3.php` (`App\Http\Controllers\Gallery\PhotoAssetController::show()`, `App\Http\Requests\Photo\GetPhotoAssetRequest`), under the `api` middleware group but with `accept_content_type:json`/`content_type:json` opted out of on this route specifically (binary passthrough, not JSON) — a `json_errors` middleware (`App\Http\Middleware\EnsureJsonErrorResponses`) still forces every *error* response to render as Lychee's standard JSON error body regardless of the caller's actual `Accept` header.
+
+**Path parameters:**
+
+| Parameter | Type | Description |
+|-----------|------|--------------|
+| `photo_id` | string | The photo's ID (`RandomIDRule`) |
+| `size_variant` | string | A `SizeVariantType` case name, case-insensitive (`raw`, `original`, `medium2x`, `medium`, `small2x`, `small`, `thumb2x`, `thumb`, `placeholder`) |
+
+**Headers (temporary-link mode, optional but paired):**
+
+| Header | Type | Description |
+|--------|------|-------------|
+| `X-Timestamp` | integer | Unix seconds the link was signed at |
+| `X-Mac` | string | Hex HMAC-SHA256 of `X-Timestamp`, keyed by `config('app.key')` (`App\Services\TemporaryLinkSigner`) |
+
+Two access modes, both always gated by `PhotoPolicy` (`CAN_SEE` for thumbnail-class variants, `CAN_ACCESS_FULL_PHOTO` for full-resolution variants) — a deliberate strengthening over v2's `SecurePathController`, which enforces no application-level policy at all:
+
+1. **Authenticated (session)**: no headers needed, unless `signatureRequired()` (config-driven, see below) says otherwise for this caller.
+2. **Unauthenticated (temporary link)**: guests are only ever authorized via a valid, unexpired `X-Timestamp`/`X-Mac` pair; reuses `temporary_image_link_enabled`/`_when_logged_in`/`_when_admin`/`_life_in_seconds` (no new config keys).
+
+**Response codes:**
+
+| Code | Meaning |
+|------|---------|
+| 200 | Binary file streamed directly (local-disk size variant) |
+| 302 | Redirect to a native S3 temporary URL (S3-backed size variant, no proxying through Lychee) |
+| 401 | No/invalid/expired/future-dated temporary-link signature, or session insufficient per config |
+| 403 | `PhotoPolicy` denies the resolved caller |
+| 404 | Unknown `photo_id`, or the photo has no `SizeVariant` row of the requested type |
+| 422 | Unrecognized `size_variant` token, or only one of `X-Timestamp`/`X-Mac` present |
## Pagination Endpoints
diff --git a/docs/specs/4-architecture/features/056-api-v3-asset-retrieval/plan.md b/docs/specs/4-architecture/features/056-api-v3-asset-retrieval/plan.md
new file mode 100644
index 00000000000..5b931522b88
--- /dev/null
+++ b/docs/specs/4-architecture/features/056-api-v3-asset-retrieval/plan.md
@@ -0,0 +1,147 @@
+# Feature Plan 056 – API v3 Asset Retrieval
+
+_Linked specification:_ `docs/specs/4-architecture/features/056-api-v3-asset-retrieval/spec.md`
+_Status:_ Completed
+_Last updated:_ 2026-08-21
+
+> Guardrail: Keep this plan traceable back to the governing spec. Reference FR/NFR/Scenario IDs from `spec.md` where relevant, log any new high- or medium-impact questions in [docs/specs/4-architecture/open-questions.md](../../open-questions.md), and assume clarifications are resolved only when the spec's normative sections and, where applicable, ADRs under `docs/specs/6-decisions/` have been updated.
+
+## Vision & Success Criteria
+Establish API v3 as a real, working, additive REST surface with one correct, fully-tested endpoint: `GET /api/v3/Photo/{photo_id}/Asset/{size_variant}`. Success = every Branch & Scenario Matrix entry (S-056-01..17) has a passing Feature test written before its implementation, `make phpstan` is clean on all new files, v2 is provably untouched (NFR-056-03), and the new v3 routing/versioning convention is documented for the next v3 feature to reuse without re-deriving it.
+
+## Scope Alignment
+- **In scope:** `routes/api_v3.php` + `RouteServiceProvider` registration; `PhotoAssetController`; `GetPhotoAssetRequest`; `TemporaryLinkSigner` service; `signatureRequired()` predicate; watermark-aware local streaming; S3 redirect branch; full Feature test suite for S-056-01..17; `api-design.md`/`knowledge-map.md` updates.
+- **Out of scope:** Any other v3 endpoint; v2 code changes; a general SoA collection-response convention/serializer (ADR-0009 explicitly defers this); frontend/client consumption of this endpoint (no v8/v7 UI changes in this feature).
+
+## Dependencies & Interfaces
+- `App\Models\SizeVariant` / `App\Models\Photo` (existing, unchanged) — `SizeVariant::getFile()`/`FlysystemFile` for local streaming.
+- `App\Policies\PhotoPolicy::canSee()`/`canAccessFullPhoto()` (existing, unchanged).
+- `App\Image\Watermarker::get_path()` (existing, unchanged).
+- `App\Repositories\ConfigManager` (existing, unchanged) — reads `temporary_image_link_enabled`/`temporary_image_link_when_logged_in`/`temporary_image_link_when_admin`/`temporary_image_link_life_in_seconds`.
+- `App\Enum\SizeVariantType`, `App\Enum\StorageDiskType` (existing, unchanged).
+- `App\Http\Requests\BaseApiRequest` (existing base class for the new `GetPhotoAssetRequest`).
+- `Tests\Feature_v2\Base\BaseApiWithDataTest` (existing fixture graph — users/albums/photos/permissions — reused by inheritance from the new v3 test base; see Assumptions).
+- `phpunit.xml` — gets a new `Feature_v3` testsuite entry (new, additive; existing `Feature_v2` entry untouched).
+- ADR-0008 (signing/authorization model), ADR-0009 (response-shape precedent) — both already Accepted.
+
+## Assumptions & Risks
+- **Assumptions:**
+ - **Tests live under a new `tests/Feature_v3/` tree, not `Feature_v2`** (owner instruction, 2026-08-20). `phpunit.xml` gets a new `Feature_v3` testsuite entry (`./tests/Feature_v3`, excluding its own `Base/` classes) mirroring the existing `Feature_v2` block exactly. Since `Tests\Feature_v2\Base\BaseApiTest`'s HTTP-verb helpers (`getJson()`, `postJson()`, etc.) hardcode `self::API_PREFIX = '/api/v2/'` via early-bound `self::` (not `static::`), a v3 subclass cannot simply override the constant — it would be silently ignored by those inherited methods. Rather than editing that v2 file (out of scope, and `self::`→`static::` would be a v2-adjacent change this feature has no reason to make) or duplicating the ~150-line user/album/photo fixture graph in `BaseApiWithDataTest`, this plan takes the lower-footprint path: a new `Tests\Feature_v3\Base\BaseApiWithDataTest extends Tests\Feature_v2\Base\BaseApiWithDataTest` reuses that fixture graph by inheritance (zero duplication, zero v2 edits — `Tests\Feature_v2\Base\BaseApiWithDataTest.php` itself is not modified), and adds its own small v3-specific request helper(s) (e.g. `getV3(string $uri, array $headers = [])` hitting `/api/v3/...` directly via Laravel's native `get()`/`withHeaders()`, since this endpoint's success response is binary, not JSON, so the inherited `getJson()`-style helpers wouldn't fit even if the prefix were fixed). The class carries an explicit doc comment warning not to use the inherited v2-prefixed JSON helpers. This is a deliberate, low-impact convention choice sized for a *single-endpoint* v3 feature; a later v3 feature with many endpoints/fixtures might warrant extracting a version-agnostic fixture trait instead of this inheritance shortcut — noted in Follow-ups.
+ - Controller lives at `App\Http\Controllers\Gallery\PhotoAssetController` — follows v2's existing domain-organized (not version-namespaced) controller convention, since no per-version namespace precedent exists to mirror (spec Appendix). Not raised as an open question: this is a low-impact, easily-reversible naming choice, not a behavioral ambiguity.
+ - `signatureRequired()`'s exact boolean composition (ADR-0008) is a direct re-purposing of `UrlGenerator::shouldNotUseSignedUrl()`'s existing generation-time logic into a validation-time predicate. This is the plan's best-effort translation of the owner's Q-056-05 answer ("it depends on the temporary link settings... in all cases PhotoPolicy is checked") into precise code; flagged here for review during I2 implementation in case the intended semantics differ subtly from the mirrored predicate.
+ - `X-Timestamp`/`X-Mac` header names (not `X-Lychee-Timestamp` etc.) follow the existing single-word `X-` convention (`X-API-Key`).
+- **Risks / Mitigations:**
+ - *Risk (resolved during 2026-08-20 review):* The inherited `BaseApiRequest::failedAuthorization()` keys 401-vs-403 off `Auth::check()` (session state) — incompatible with this endpoint's required mapping (S-056-03 needs 403 for a signature-valid-but-policy-denied *guest*; S-056-10 needs 401 for a signature-required-but-missing *logged-in* user). *Resolution:* `GetPhotoAssetRequest` overrides `failedAuthorization()`, keyed on a `$signature_check_failed` flag instead of session state (see I2 Steps). No longer an open risk — verified against the full scenario matrix, not deferred to implementation-time discovery.
+ - *Risk:* `signatureRequired()` mirrors `shouldNotUseSignedUrl()`'s three-flag composition exactly, but the two predicates serve different purposes (generation vs. validation) — a subtle mismatch could either over- or under-require signatures. *Mitigation:* NFR-056-04's full 2×2×2×3 config/caller-state test matrix (I4) will surface any behavior the owner didn't intend before this ships.
+ - *Risk:* No existing precedent for a `size_variant` string being matched against `SizeVariantType` case names in a route/request — case-sensitivity or naming mismatch (e.g. `THUMB2X` vs `thumb2x` vs `thumb_2x`) could confuse API clients. *Mitigation:* I1 picks and documents one exact casing convention (lowercase snake matching the enum's `name()` helper) and tests both a valid and an invalid token (S-056-13).
+ - *Risk:* Reusing `config('app.key')` as the HMAC secret means a future `APP_KEY` rotation invalidates all outstanding temporary links instantly (same behavior as Laravel's own signed routes today, so not a new risk class, but worth noting). *Mitigation:* none needed — this matches existing v2 behavior exactly, no regression.
+
+## Implementation Drift Gate
+Before I5 (final quality gate), diff `routes/api_v2.php`, `routes/web_v2.php`, `app/Http/Controllers/SecurePathController.php`, and `app/Services/UrlGenerator.php` against `master` — must be empty (NFR-056-03). Record the `git diff --stat` output (or confirmation of zero changes) in this section once run. Any unplanned deviation discovered during implementation (e.g. a needed change to a shared file) must be logged here with rationale before proceeding, per this repo's Implementation Drift Gate convention.
+
+**Run 2026-08-20 (T-056-17):** `git diff master -- routes/api_v2.php routes/web_v2.php app/Http/Controllers/SecurePathController.php app/Services/UrlGenerator.php` — empty. Confirmed zero changes to all four protected v2 files.
+
+**Deviations discovered during implementation (none touch the four protected files above, logged for completeness per Q-056-07 in open-questions.md):**
+- `app/Http/Kernel.php` — added one new `json_errors` middleware alias entry (additive; no existing alias/group entries modified).
+- `app/Providers/RouteServiceProvider.php` — added one new `Route::middleware('api')->prefix('api/v3')->group(...)` registration line (additive; the existing v2 registration lines are unchanged).
+- `app/Contracts/Http/Requests/RequestAttribute.php` — added one new `SIZE_VARIANT_TOKEN_ATTRIBUTE` constant (additive; general-purpose contract shared across versions, not v2-specific).
+
+## Increment Map
+
+1. **I1 – Route, request validation, and controller skeleton**
+ - _Goal:_ Stand up `GET /api/v3/Photo/{photo_id}/Asset/{size_variant}` end-to-end for the simplest success path (authenticated owner, local disk, no watermark, no temporary link), returning a 200 with correct bytes.
+ - _Preconditions:_ Spec FR-056-01/02/03 finalized (done).
+ - _Steps:_
+ - Scaffold the `Feature_v3` test tree first: add a `Feature_v3` testsuite entry to `phpunit.xml` (mirrors the existing `Feature_v2` block: `./tests/Feature_v3`, excluding `./tests/Feature_v3/Base/BaseApiWithDataTest.php`); create `tests/Feature_v3/Base/BaseApiWithDataTest.php` (`namespace Tests\Feature_v3\Base`, `extends \Tests\Feature_v2\Base\BaseApiWithDataTest` to reuse the fixture graph, adds a `getV3(string $uri, array $headers = [])` helper hitting `/api/v3/...` via native `get()`/`withHeaders()`, doc comment warning not to use the inherited v2-prefixed `getJson()`/`postJson()`/etc.).
+ - Add failing Feature test for S-056-01 (`tests/Feature_v3/Photo/PhotoAssetV3Test.php`, extending the new `Tests\Feature_v3\Base\BaseApiWithDataTest`).
+ - Create `routes/api_v3.php`; register in `app/Providers/RouteServiceProvider.php` (`Route::middleware('api')->prefix('api/v3')->group(base_path('routes/api_v3.php'));`).
+ - Create `App\Http\Requests\Photo\GetPhotoAssetRequest extends BaseApiRequest`: `rules()` validates `photo_id` (`RandomIDRule`) and `size_variant` (must match a `SizeVariantType` case name, case-insensitive); `authorize()` calls `Gate::check()` with `PhotoPolicy::CAN_SEE` or `CAN_ACCESS_FULL_PHOTO` depending on the resolved `size_variant` (FR-056-03); `processValidatedValues()` resolves the `Photo` and `SizeVariant` models (404 via `findOrFail`-style if either is missing, satisfying FR-056-01/02's validation path).
+ - Create `App\Http\Controllers\Gallery\PhotoAssetController` with a single `show(GetPhotoAssetRequest $request)` action; for this increment, local-disk-only, no watermark, no S3 branch, no temporary-link branch — just `response()->file($size_variant->getFile()->getFullPath())` or the `FlysystemFile` equivalent used by `SecurePathController`.
+ - _Commands:_ `php artisan test --filter=PhotoAssetV3Test`, `make phpstan`
+ - _Exit:_ S-056-01 passes; S-056-12/13/14 (404/422 validation paths) also pass since they're implied by this increment's request validation.
+
+2. **I2 – Temporary-link signing (`TemporaryLinkSigner`) and `signatureRequired()`**
+ - _Goal:_ Implement FR-056-04/05 — headers-based signature verification and the config-driven requirement predicate.
+ - _Preconditions:_ I1 complete.
+ - _Steps:_
+ - Add failing Feature/Unit tests for S-056-02..11 first.
+ - Create `App\Services\TemporaryLinkSigner` (`sign(int $timestamp): string`, `verify(int $timestamp, string $mac): bool`) — unit-tested independent of HTTP (Test Strategy).
+ - Extend `GetPhotoAssetRequest`: add `private bool $signature_check_failed = false;`. In `authorize()`, read `X-Timestamp`/`X-Mac` headers (`$this->header('X-Timestamp')`/`$this->header('X-Mac')`, both-or-neither validation → 422 if only one present, via `rules()`, before `authorize()` runs); compute `signatureRequired(Auth::user(), $config_manager)` (ADR-0008's predicate, implemented as a private method — not spec-load-bearing exactly where); if required, validate via `TemporaryLinkSigner::verify()` plus the TTL/future-timestamp checks — on any failure set `$this->signature_check_failed = true` and return `false` immediately (skip the `PhotoPolicy` check entirely, per FR-056-03's now-explicit ordering); otherwise run the `PhotoPolicy` check (FR-056-03) and return its result (leaving `$signature_check_failed = false` on policy denial). Override `failedAuthorization()` (the inherited `BaseApiRequest` version keys 401-vs-403 off `Auth::check()`, which is wrong here — see FR-056-05): `protected function failedAuthorization(): void { throw $this->signature_check_failed ? new UnauthenticatedException() : new UnauthorizedException(); }`. This one override, keyed on *which check failed* rather than session state, correctly produces every S-056-03..11 status code (confirmed against the full scenario matrix during this review — no further "confirm during implementation" needed).
+ - _Commands:_ `php artisan test --filter=TemporaryLinkSigner`, `php artisan test --filter=PhotoAssetV3Test`, `make phpstan`
+ - _Exit:_ S-056-02..11 all pass, including the full config/caller-state matrix (NFR-056-04).
+
+3. **I3 – Watermark and S3-redirect branches**
+ - _Goal:_ Implement FR-056-06/07.
+ - _Preconditions:_ I1 complete (I2 not required — independent branches).
+ - _Steps:_
+ - Add failing tests for S-056-15/16 first.
+ - In `PhotoAssetController::show()`, call `Watermarker::get_path($size_variant)` instead of the plain `short_path` before resolving the file (FR-056-06).
+ - Branch on disk adapter (mirrors `UrlGenerator::pathToUrl()`'s `getAdapter() instanceof AwsS3V3Adapter` check): if S3, `return redirect()->away($disk->temporaryUrl(...))` (302); else stream as in I1.
+ - _Commands:_ `php artisan test --filter=PhotoAssetV3Test`, `make phpstan`
+ - _Exit:_ S-056-15/16/17 pass.
+
+4. **I4 – Full scenario-matrix sweep and edge-case hardening**
+ - _Goal:_ Close any remaining gaps across S-056-01..17; confirm the full `PhotoPolicy` split (`CAN_SEE` vs `CAN_ACCESS_FULL_PHOTO`) per size-variant class (S-056-17).
+ - _Preconditions:_ I1-I3 complete.
+ - _Steps:_ Run the full `PhotoAssetV3Test` suite; add any missing scenario coverage found; verify NFR-056-04's config-matrix test explicitly enumerates all 2×2×2 boolean combinations crossed with guest/logged-in/admin caller state (8×3 = 24 cases, though several collapse to the same expected outcome — document the collapsed truth table in the test file's comments).
+ - _Commands:_ `php artisan test --filter=PhotoAssetV3Test`, `make phpstan`, `vendor/bin/php-cs-fixer fix`
+ - _Exit:_ All S-056-* scenarios green; PHPStan clean; php-cs-fixer clean.
+
+5. **I5 – Documentation and quality gate**
+ - _Goal:_ Close out Documentation Deliverables and run the full repo quality gate.
+ - _Preconditions:_ I1-I4 complete.
+ - _Steps:_
+ - Add "API v3" section to `docs/specs/3-reference/api-design.md` (route, headers, response codes, ADR-0009 reference).
+ - Add `routes/api_v3.php`/`PhotoAssetController`/`TemporaryLinkSigner` entries to `docs/specs/4-architecture/knowledge-map.md`.
+ - Run the Implementation Drift Gate diff check (see above) and record the result.
+ - Move Feature 056's roadmap row from Active to Completed once all tasks are `[x]`.
+ - _Commands:_ `vendor/bin/php-cs-fixer fix`, `php artisan test`, `make phpstan`
+ - _Exit:_ Full PHP quality gate green; roadmap/knowledge-map/api-design.md updated; this plan's Analysis Gate section completed.
+
+## Scenario Tracking
+
+| Scenario ID | Increment / Task reference | Notes |
+|-------------|---------------------------|-------|
+| S-056-01 | I1 / T-056-01a..05 | Baseline authenticated success path |
+| S-056-02 | I2 / T-056-06..09 | Guest + valid temporary link |
+| S-056-03 | I2 / T-056-06..09 | Valid signature, `PhotoPolicy` still denies |
+| S-056-04 | I2 / T-056-06..09 | Guest, no headers at all |
+| S-056-05 | I2 / T-056-06..09 | Feature globally disabled |
+| S-056-06 | I2 / T-056-06..09 | Tampered MAC |
+| S-056-07 | I2 / T-056-06..09 | Expired timestamp |
+| S-056-08 | I2 / T-056-06..09 | Future timestamp |
+| S-056-09 | I2 / T-056-06..09 | Only one of the two headers present |
+| S-056-10 | I2 / T-056-06..09 | Logged-in user still required to sign |
+| S-056-11 | I2 / T-056-06..09 | Admin exempted from signing |
+| S-056-12 | I1 / T-056-01a..05 | Missing `SizeVariant` row |
+| S-056-13 | I1 / T-056-01a..05 | Invalid `size_variant` token |
+| S-056-14 | I1 / T-056-01a..05 | Unknown `photo_id` |
+| S-056-15 | I3 / T-056-10..12 | S3 redirect |
+| S-056-16 | I3 / T-056-10..12 | Watermark applied |
+| S-056-17 | I4 / T-056-13..14 | `CAN_SEE` vs `CAN_ACCESS_FULL_PHOTO` split |
+
+## Analysis Gate
+Run 2026-08-20, against [docs/specs/5-operations/analysis-gate-checklist.md](../../../5-operations/analysis-gate-checklist.md) (pre-implementation section only — Implementation Drift Gate is deferred to I5/T-056-17).
+
+1. **Specification completeness** — ✅ Pass. FR-056-01..07/NFR-056-01..04 populated; every FR/NFR cites its resolving Q-ID and/or ADR. No UI mock-up section included — correct, this feature has no UI/frontend surface (Non-Goals).
+2. **Open questions review** — ✅ Pass. Q-056-01..06 all Resolved (no `Open` rows remain for Feature 056 in open-questions.md). ADR-0008 (signing/authorization) and ADR-0009 (response-shape precedent) both Accepted and linked from spec.md and open-questions.md.
+3. **Plan alignment** — ✅ Pass. This plan references `spec.md`/`tasks.md` at the correct paths; Dependencies & Interfaces and Vision & Success Criteria match spec wording (ADR-0008/0009, `PhotoPolicy`, `Watermarker`, `ConfigManager`, existing enums).
+4. **Tasks coverage** — ✅ Pass. Every FR-056-* maps to ≥1 task (see Scenario Tracking table above for the S-ID↔task mapping; FR↔task mapping: FR-01→T-01a..05, FR-02→T-01b/04, FR-03→T-03/09/14, FR-04→T-06..09, FR-05→T-09, FR-06→T-10/11, FR-07→T-10/12). Tests precede implementation in every increment (T-056-01a/01b/06/07/10 are test-first tasks). All 17 scenarios (success/validation/failure branches) have queued failing tests before implementation.
+5. **Constitution compliance** — ⚠️ Partial/Note. `docs/specs/6-decisions/project-constitution.md` (the checklist's cited input) does not exist in this repo — treating AGENTS.md's guardrails as the de facto constitution reference instead, consistent with how this repo actually operates (pre-existing gap, not introduced by this feature). Against AGENTS.md: spec-first ✅ (spec preceded plan/tasks), clarification gate ✅ (all Q-IDs resolved before plan.md was written), test-first ✅ (Increment Map sequences tests first throughout), documentation sync ✅ (I5 tasks cover api-design.md/knowledge-map.md), dependency control ✅ (no new dependencies). Control-flow minimization: `TemporaryLinkSigner` and the `signatureRequired()` predicate are extracted as small, independently-testable units rather than inline branching in the controller — satisfies the "nearly straight-line" guidance.
+6. **Tooling readiness** — ✅ Pass. Commands documented per-increment in the Increment Map (`php artisan test --filter=...`, `make phpstan`, `vendor/bin/php-cs-fixer fix`).
+
+**Outcome:** Gate passed (one documented pre-existing repo gap under #5, not blocking). Implementation (I1) may proceed.
+
+## Exit Criteria
+- All tasks in `tasks.md` marked `[x]`.
+- `php artisan test` green (new `PhotoAssetV3Test` + `TemporaryLinkSigner` unit tests; no regressions elsewhere).
+- `make phpstan` — 0 errors on touched/new files.
+- `vendor/bin/php-cs-fixer fix` — clean.
+- Implementation Drift Gate confirms zero changes to the four named v2 files (NFR-056-03).
+- `docs/specs/3-reference/api-design.md` and `docs/specs/4-architecture/knowledge-map.md` updated.
+- Roadmap entry moved to Completed.
+
+## Follow-ups / Backlog
+- A future v3 feature will need to define the actual SoA collection-response convention (deferred by ADR-0009/this feature's Non-Goals) — likely the natural second v3 endpoint (e.g. a paginated photo/album listing).
+- S3 proxying (rather than redirect) was explicitly rejected for this feature (Q-056-03 Option B chosen) — revisit only if a concrete need for server-side S3 proxying emerges (e.g. hiding bucket URLs from clients).
+- Per-resource-scoped signatures (MAC over `photo_id`+`size_variant`+`timestamp`, not just `timestamp`) were explicitly out of scope per the owner's instruction — flagged in ADR-0008 as an accepted trade-off, not a deferred task, but worth revisiting if abuse patterns are observed in production.
diff --git a/docs/specs/4-architecture/features/056-api-v3-asset-retrieval/spec.md b/docs/specs/4-architecture/features/056-api-v3-asset-retrieval/spec.md
new file mode 100644
index 00000000000..9b77dcf4740
--- /dev/null
+++ b/docs/specs/4-architecture/features/056-api-v3-asset-retrieval/spec.md
@@ -0,0 +1,147 @@
+# Feature 056 – API v3 Asset Retrieval
+
+| Field | Value |
+|-------|-------|
+| Status | Completed |
+| Last updated | 2026-08-21 |
+| Owners | ildyria |
+| Linked plan | `docs/specs/4-architecture/features/056-api-v3-asset-retrieval/plan.md` |
+| Linked tasks | `docs/specs/4-architecture/features/056-api-v3-asset-retrieval/tasks.md` |
+| Roadmap entry | #56 |
+
+> Guardrail: This specification is the single normative source of truth for the feature. Track high- and medium-impact questions in [docs/specs/4-architecture/open-questions.md](../../open-questions.md), encode resolved answers directly in the Requirements/NFR/Behaviour/UI/Telemetry sections below (no per-feature `## Clarifications` sections), and use ADRs under `docs/specs/6-decisions/` for architecturally significant clarifications (referencing their IDs from the relevant spec sections).
+
+## Overview
+Lychee's current REST surface is `/api/v2/...`, an Array-of-Structs (AoS) API where collection responses are arrays of self-contained objects (see `PaginatedPhotosResource`/`PaginatedAlbumsResource` in [docs/specs/3-reference/api-design.md](../../../3-reference/api-design.md)). This feature starts **API v3**, a new, greenfield `/api/v3/...` surface whose base response convention is Struct-of-Arrays (SoA) for *collection* endpoints — a convention this feature establishes precedent for but does not itself need, since its endpoint is single-item (ADR-0009). v3 coexists with v2; nothing in v2 is deprecated or changed by this feature.
+
+The first v3 endpoint retrieves a single photo asset: given a `photo_id` and a `size_variant` type, it returns the associated file (thumbnail through original), watermark-aware. It supports two access modes: (1) an authenticated (session) request, and (2) an unauthenticated **temporary link** request carrying a `timestamp` and a MAC of that timestamp via request headers. `PhotoPolicy` authorization is enforced in both modes — a deliberate strengthening over v2's `SecurePathController`, which enforces no application-level policy at all (ADR-0008).
+
+## Goals
+- Establish the v3 routing/versioning convention (`routes/api_v3.php`, `/api/v3` prefix) that later v3 endpoints will follow.
+- Serve a single photo size-variant's binary file given `photo_id` + `size_variant` type, watermark-aware.
+- Support unauthenticated retrieval via a signed temporary link (`X-Timestamp` + `X-Mac` headers), and authenticated (session) retrieval otherwise, per the config-driven model in ADR-0008.
+- Enforce `PhotoPolicy` authorization on every request, regardless of access mode.
+- Keep v2 entirely unaffected (no shared route/controller edits beyond read-only reuse of existing models/enums).
+
+## Non-Goals
+- Any v3 endpoint other than this single asset-retrieval endpoint (e.g. no v3 album/photo listing, no v3 write endpoints) — out of scope for this feature.
+- Migrating or deprecating the existing v2 `image/{path}` route, `SecurePathController`, or `UrlGenerator` — they remain unchanged and continue serving v2/legacy consumers.
+- Defining the general SoA response convention for v3 *collection* endpoints (ADR-0009) — a future v3 spec defines that when the first list endpoint is built.
+- Proxying S3-backed size variants through Lychee — this endpoint redirects to a native S3 temporary URL instead (see FR-056-05).
+- A per-resource-scoped signature (the MAC authenticates the timestamp only, not `photo_id`/`size_variant` — accepted trade-off, see ADR-0008 Security/Privacy Impact).
+
+## Functional Requirements
+
+| ID | Requirement | Success path | Validation path | Failure path | Telemetry & traces | Source |
+|----|-------------|--------------|-----------------|--------------|--------------------|--------|
+| FR-056-01 | New `GET /api/v3/Photo/{photo_id}/Asset/{size_variant}` route, registered via a new `routes/api_v3.php` under the `api` middleware group with prefix `api/v3` (mirrors v2's `RouteServiceProvider` registration pattern), but with the `api` group's `accept_content_type:json`/`content_type:json` middleware opted out of on this route specifically (via `Route::withoutMiddleware(...)`) since this endpoint is a binary passthrough, not a JSON endpoint (Q-056-07). | Route resolves to a new controller action; `photo_id` and `size_variant` are route parameters. | `photo_id` validated as an existing photo ID (`RandomIDRule`, `findOrFail` semantics → 404 if absent); `size_variant` validated against `SizeVariantType`'s `name()` values (`raw`, `original`, `medium2x`, `medium`, `small2x`, `small`, `thumb2x`, `thumb`, `placeholder` — already lowercase, `app/Enum/SizeVariantType.php`), matched case-insensitively → 422 on an unrecognized value, via the new reusable `App\Rules\SizeVariantTypeNameRule`. | Unknown route → 404 (standard Laravel routing). Unknown `photo_id` → 404. Invalid `size_variant` token → 422. | Standard Laravel request logging; no new telemetry event. | Owner instruction (2026-08-20); Q-056-06; Q-056-07 |
+| FR-056-02 | The endpoint returns the requested `SizeVariant`'s file as a raw binary passthrough (no JSON envelope) — `Content-Type` set from the resolved file, HTTP 200. | Streams file bytes for local-disk variants (mirrors `SecurePathController`'s `response()->file()` pattern, via `SizeVariant::getFile()`/`FlysystemFile`). | The requested `(photo_id, size_variant)` pair must have a corresponding `SizeVariant` row → 404 if the photo has no variant of that type (e.g. no `RAW` stored). | 404 Not Found (no such variant), with a JSON error body per Lychee's standard exception-handler convention (not this endpoint's own envelope — errors are not "success" responses). | Standard Laravel request logging. | Q-056-02; ADR-0009 |
+| FR-056-03 | Authorization order: (1) FR-056-05's `signatureRequired()` check and, if required, FR-056-04's signature validity check run **first**; only once that step passes (or is not required) does `PhotoPolicy` run. `PhotoPolicy::CAN_SEE` gates thumbnail-class variants (`THUMB`, `THUMB2X`, `SMALL`, `SMALL2X`, `PLACEHOLDER`); `PhotoPolicy::CAN_ACCESS_FULL_PHOTO` gates full-resolution variants (`MEDIUM`, `MEDIUM2X`, `ORIGINAL`, `RAW`) — evaluated against `Auth::user()` (nullable) in **every** request that clears step (1). | Policy grants access → proceed to FR-056-02/06/07. | N/A (this is itself the validation gate). | Policy denies access → 403 Forbidden (`App\Exceptions\UnauthorizedException`, thrown via `GetPhotoAssetRequest`'s overridden `failedAuthorization()` — see FR-056-05). | Standard Laravel request logging. | Q-056-05; ADR-0008 |
+| FR-056-04 | Temporary-link mode: request carries `X-Timestamp` (Unix seconds, integer) and `X-Mac` (hex HMAC-SHA256 of the timestamp) headers. `mac` is verified as `hash_equals($expected, $provided)` where `$expected = hash_hmac('sha256', (string) $timestamp, config('app.key'))`, computed by a new `App\Services\TemporaryLinkSigner::verify(int $timestamp, string $mac): bool` (MAC check only — TTL/expiry is checked separately in `GetPhotoAssetRequest`, not inside `TemporaryLinkSigner`, since the signer has no config/`$ttl` input; see DO-056-02). Timestamp must satisfy `now()->timestamp - $timestamp <= temporary_image_link_life_in_seconds` and `$timestamp <= now()->timestamp`. This check runs before FR-056-03's `PhotoPolicy` check (see FR-056-03/05 ordering). | Valid, unexpired signature + `temporary_image_link_enabled` true → request proceeds to FR-056-03's `PhotoPolicy` check as a guest. | Missing one of `X-Timestamp`/`X-Mac` when the other is present → 422 (both-or-neither). | Invalid MAC, expired timestamp, future timestamp, or `temporary_image_link_enabled` false while unauthenticated → 401 Unauthorized (`App\Exceptions\UnauthenticatedException` — see FR-056-05 for the exact mechanism). | Standard Laravel request logging. | Q-056-01; Q-056-06 (headers, amended); ADR-0008 |
+| FR-056-05 | Whether `X-Timestamp`/`X-Mac` are *required* (vs. session being sufficient alone) is determined per-request by `signatureRequired(?User $user, ConfigManager $cfg)` (ADR-0008), reusing `temporary_image_link_enabled`/`temporary_image_link_when_logged_in`/`temporary_image_link_when_admin`. Guests (`Auth::user() === null`) are only ever authorized via a valid temporary link. **401-vs-403 mechanism:** the inherited `BaseApiRequest::failedAuthorization()` picks its exception solely from `Auth::check()` (session state), which cannot express this endpoint's required mapping — e.g. S-056-03 (guest, valid signature, policy denies) needs 403 despite no session, and S-056-10 (logged-in user, signature required by config but missing) needs 401 despite a session. `GetPhotoAssetRequest` therefore overrides `failedAuthorization()`, keyed on a `private bool $signature_check_failed` flag set during `authorize()` (true only when `signatureRequired()` is true for this caller and the signature is absent/malformed/invalid/expired/future — i.e. FR-056-04's failure path): `throw $this->signature_check_failed ? new UnauthenticatedException() : new UnauthorizedException();`. | Authenticated session present and `signatureRequired()` is false for that user → session + `PhotoPolicy` (FR-056-03) suffices, no headers needed, `$signature_check_failed` stays `false`. | Authenticated session present but `signatureRequired()` is true for that user (e.g. `temporary_image_link_when_logged_in=true`) → headers additionally required per FR-056-04; missing/invalid → `$signature_check_failed = true` → 401. | No session and no valid temporary link → `$signature_check_failed = true` → 401 Unauthorized. | Standard Laravel request logging. | Q-056-05; ADR-0008 |
+| FR-056-06 | Watermarking: the file resolved and served is always watermark-aware, reusing `Watermarker::get_path($size_variant)` — the same resolution `SizeVariant::getUrlAttribute()` already performs for v2 display. | Watermarked path served when `should_use_watermarked_path()` (existing `Watermarker` logic) applies for the requesting viewer; plain path otherwise. | N/A (delegates entirely to existing `Watermarker` logic, unchanged by this feature). | N/A — `Watermarker::get_path()` already throws for `PLACEHOLDER`/`RAW`; this endpoint lets that exception surface as a 4xx per Lychee's standard exception-handler convention. | Standard Laravel request logging. | Q-056-04 |
+| FR-056-07 | Disk handling: for a `SizeVariant` stored on a non-local (S3) disk, the endpoint responds with an HTTP 302 redirect to a freshly generated native S3 temporary URL (same `AwsS3V3Adapter` detection and `temporaryUrl()` call as `UrlGenerator::getAwsUrl()`), evaluated **after** FR-056-03's `PhotoPolicy` check passes. For a local-disk `SizeVariant`, the endpoint streams bytes directly (FR-056-02). | S3-backed variant + authorized request → 302 redirect to a time-limited S3 URL. | N/A. | N/A (disk resolution cannot itself fail independently of FR-056-02's 404). | Standard Laravel request logging. | Q-056-03 |
+
+## Non-Functional Requirements
+
+| ID | Requirement | Driver | Measurement | Dependencies | Source |
+|----|-------------|--------|-------------|--------------|--------|
+| NFR-056-01 | The MAC secret is `config('app.key')` — no new secret storage or config key introduced for signing. Comparison uses `hash_equals()` (timing-safe). | Security — avoid a new key-management surface; avoid timing side-channels. | Code review confirms `hash_equals()` usage; no new `.env`/config key added for the secret itself. | `App\Services\TemporaryLinkSigner` | ADR-0008 |
+| NFR-056-02 | This feature adds no new database migrations, config keys, or columns — it reuses `temporary_image_link_enabled`/`temporary_image_link_when_logged_in`/`temporary_image_link_when_admin`/`temporary_image_link_life_in_seconds` verbatim. | Minimal footprint; avoid duplicating v2's existing config surface. | Diff review: no new migration file in this feature's task list. | Existing `configs` table rows (`database/migrations/2025_04_05_153533_add_secure_link_options.php`) | Q-056-01/05 |
+| NFR-056-03 | v2 routes, controllers, and `UrlGenerator`/`SecurePathController` are untouched by this feature — verified by an empty diff on `routes/api_v2.php`, `routes/web_v2.php`, `app/Http/Controllers/SecurePathController.php`, `app/Services/UrlGenerator.php`. | Backward-compat stance (AGENTS.md): v3 is additive, not a v2 migration. | `git diff` review on those four files shows no changes. | — | AGENTS.md guardrail |
+| NFR-056-04 | Every Branch & Scenario Matrix entry (S-056-*) has a corresponding Feature-level HTTP test, written and confirmed failing before implementation (test-first cadence), living under a new `tests/Feature_v3/` tree (own PHPUnit testsuite) rather than `tests/Feature_v2/` — v3 tests are kept in their own version-scoped directory from this first feature onward. | AGENTS.md SDD Feedback Loops — branch coverage upfront; owner instruction to scope v3 tests under `Feature_v3`. | `php artisan test --testsuite=Feature_v3` passes post-implementation; test file diff shows tests added before controller code in commit history. | New `Tests\Feature_v3\Base\BaseApiWithDataTest` (extends `Tests\Feature_v2\Base\BaseApiWithDataTest` to reuse its fixture graph, see plan.md); new `Feature_v3` `phpunit.xml` testsuite entry | AGENTS.md; owner instruction 2026-08-20 |
+
+## Branch & Scenario Matrix
+
+| Scenario ID | Description / Expected outcome |
+|-------------|--------------------------------|
+| S-056-01 | Authenticated owner requests own photo's `THUMB` variant, no signature headers, `signatureRequired()` false → 200, correct bytes streamed, watermark resolution applied. |
+| S-056-02 | Guest requests a public album's photo `THUMB` variant with valid `X-Timestamp`/`X-Mac` within TTL, `temporary_image_link_enabled=true` → 200. |
+| S-056-03 | Guest requests the same as S-056-02 but the album is **not** public (private/protected) → 403, despite a validly-signed link (`PhotoPolicy` still denies). |
+| S-056-04 | Guest requests with no `X-Timestamp`/`X-Mac` at all, `temporary_image_link_enabled=true` → 401 (no session, no valid signature). |
+| S-056-05 | Guest requests with `temporary_image_link_enabled=false` (feature off instance-wide) → 401 regardless of headers. |
+| S-056-06 | Request with `X-Mac` that doesn't match `hash_hmac('sha256', X-Timestamp, app.key)` → 401. |
+| S-056-07 | Request with a `X-Timestamp` older than `now() - temporary_image_link_life_in_seconds` → 401 (expired). |
+| S-056-08 | Request with a `X-Timestamp` in the future (`> now()`) → 401. |
+| S-056-09 | Request with only `X-Timestamp` present, `X-Mac` missing (or vice versa) → 422. |
+| S-056-10 | Authenticated non-admin user, `temporary_image_link_when_logged_in=true` (feature configured to still require signatures for logged-in users) and no headers supplied → 401, even though a session exists. |
+| S-056-11 | Authenticated admin, `temporary_image_link_when_admin=false`, no headers → 200 (admin session alone suffices per config). |
+| S-056-12 | Request for `size_variant=RAW` on a photo with no stored `RAW` variant → 404. |
+| S-056-13 | Request with an unrecognized `size_variant` path segment (e.g. `/Asset/huge`) → 422. |
+| S-056-14 | Request with a non-existent `photo_id` → 404. |
+| S-056-15 | Authorized request for a `size_variant` stored on the S3 disk → 302 redirect to a native S3 temporary URL, no bytes proxied through Lychee. |
+| S-056-16 | Authorized request for `size_variant=MEDIUM` where the requesting viewer meets watermark conditions (`Watermarker::should_use_watermarked_path()` true) → served file is the watermarked path, not the plain stored path. |
+| S-056-17 | `PhotoPolicy::CAN_ACCESS_FULL_PHOTO` denies (e.g. album disables full-resolution access) for `size_variant=ORIGINAL` even though `CAN_SEE` would allow `THUMB` → 403 for `ORIGINAL`, 200 for `THUMB`, same photo/session. |
+
+## Test Strategy
+- **REST:** Feature-level HTTP tests (new `tests/Feature_v3/Photo/PhotoAssetV3Test.php`, extending a new `Tests\Feature_v3\Base\BaseApiWithDataTest`) cover every S-056-* scenario above — written and confirmed failing before controller implementation, per AGENTS.md's test-first cadence. v3 tests are scoped to their own `Feature_v3` directory/testsuite, not mixed into `Feature_v2` (owner instruction).
+- **Unit:** `App\Services\TemporaryLinkSigner` gets isolated unit tests for `sign()`/`verify()` (valid MAC, tampered MAC) independent of HTTP plumbing — `verify()` has no TTL/config input (DO-056-02), so expiry/future-timestamp cases (S-056-07/08) are Feature-level tests against `GetPhotoAssetRequest`, not `TemporaryLinkSigner` unit tests.
+- **Application:** `signatureRequired()`'s boolean composition (ADR-0008) is covered by a dedicated unit/Feature test matrix across all 2×2×2 combinations of the three `temporary_image_link_*` config booleans crossed with (guest / logged-in / admin) caller state — NFR-056-04.
+- **Core:** No core/domain-layer changes — this feature reuses existing `SizeVariant`, `Photo`, `PhotoPolicy`, `Watermarker`, `ConfigManager` unchanged.
+- **CLI:** N/A — no CLI surface in this feature.
+- **UI (JS/Selenium):** N/A — no frontend change; this is a backend-only API endpoint (a future v3 client feature would consume it).
+- **Docs/Contracts:** `docs/specs/3-reference/api-design.md` gets a new "API v3" section (Documentation Deliverables) documenting the route, headers, and response codes as the OpenAPI-equivalent contract reference for this endpoint.
+
+## Interface & Contract Catalogue
+
+### Domain Objects
+| ID | Description | Modules |
+|----|-------------|---------|
+| DO-056-01 | `GetPhotoAssetRequest` — route params `photo_id` (string, `RandomIDRule`), `size_variant` (string, matched against `SizeVariantType` case names); headers `X-Timestamp` (nullable int), `X-Mac` (nullable string, required together with `X-Timestamp`). | REST |
+| DO-056-02 | `TemporaryLinkSigner` — `sign(int $timestamp): string`, `verify(int $timestamp, string $mac): bool`. Stateless service, no persistence. | Application |
+
+### API Routes / Services
+| ID | Transport | Description | Notes |
+|----|-----------|-------------|-------|
+| API-056-01 | REST GET `/api/v3/Photo/{photo_id}/Asset/{size_variant}` | Retrieve a photo's size-variant asset. Headers: `X-Timestamp` (optional), `X-Mac` (optional, required together with `X-Timestamp`). Responses: 200 (binary, local disk), 302 (redirect, S3 disk), 401 (auth/signature failure), 403 (`PhotoPolicy` denial), 404 (photo or variant not found), 422 (invalid `size_variant` token or malformed header pair). | New `routes/api_v3.php`; new controller `App\Http\Controllers\Gallery\PhotoAssetController`. |
+
+## Documentation Deliverables
+- Update [docs/specs/3-reference/api-design.md](../../../3-reference/api-design.md) with a new "API v3" section documenting `API-056-01`'s route, headers, and response codes, and noting the SoA-for-collections / binary-passthrough-for-single-item precedent (ADR-0009).
+- Update [docs/specs/4-architecture/knowledge-map.md](../../knowledge-map.md) with the new `routes/api_v3.php` convention, `PhotoAssetController`, and `TemporaryLinkSigner` once implemented.
+- Roadmap entry added (this feature) to the Active Features table in [docs/specs/4-architecture/roadmap.md](../../roadmap.md).
+
+## Spec DSL
+
+```
+domain_objects:
+ - id: DO-056-01
+ name: GetPhotoAssetRequest
+ fields:
+ - name: photo_id
+ type: string
+ constraints: "route param, RandomIDRule, must resolve to an existing Photo"
+ - name: size_variant
+ type: string
+ constraints: "route param, must match a SizeVariantType case name"
+ - name: X-Timestamp
+ type: integer
+ constraints: "header, optional, required together with X-Mac, Unix seconds"
+ - name: X-Mac
+ type: string
+ constraints: "header, optional, required together with X-Timestamp, hex HMAC-SHA256"
+ - id: DO-056-02
+ name: TemporaryLinkSigner
+ fields:
+ - name: sign
+ type: "(int $timestamp) -> string"
+ - name: verify
+ type: "(int $timestamp, string $mac) -> bool"
+routes:
+ - id: API-056-01
+ method: GET
+ path: /api/v3/Photo/{photo_id}/Asset/{size_variant}
+fixtures: []
+ui_states: []
+```
+
+## Appendix
+Research grounding this spec (from codebase exploration, 2026-08-20):
+- v2 asset serving today has no dedicated `photo_id`+`size_variant` endpoint; `SizeVariant::getUrlAttribute()`/`getDownloadUrlAttribute()` compute a URL to a generic `image/{path}` route (`routes/web_v2.php`), served by `SecurePathController` (local disk only, `response()->file()` streaming, no `PhotoPolicy` check).
+- Signed-link precedent: `UrlGenerator::pathToUrl()` uses Laravel's `URL::temporarySignedRoute()` (HMAC-SHA256 over the full canonical URL, `expires`+`signature` query params, `APP_KEY`-derived), gated by `temporary_image_link_enabled`/`secure_image_link_enabled` configs; `UrlGenerator::shouldNotUseSignedUrl()` (`app/Services/UrlGenerator.php:82-88`) is the existing generation-time predicate this feature's `signatureRequired()` re-purposes for validation-time (ADR-0008).
+- `SizeVariantType` enum (`app/Enum/SizeVariantType.php`): `RAW=0, ORIGINAL=1, MEDIUM2X=2, MEDIUM=3, SMALL2X=4, SMALL=5, THUMB2X=6, THUMB=7, PLACEHOLDER=8`.
+- `PhotoPolicy` (`app/Policies/PhotoPolicy.php`): `canSee()` (general visibility, album-access-reduced), `canAccessFullPhoto()` (full-resolution gate, requires `canSee()` first plus per-album full-photo access) — both accept a nullable `User` (guest-aware).
+- `Watermarker::get_path(SizeVariant $size_variant)` (`app/Image/Watermarker.php:140-170`) — existing watermark-path resolution, reused unchanged by FR-056-06.
+- `StorageDiskType` (`app/Enum/StorageDiskType.php`): `LOCAL = 'images'`, `S3 = 's3'`; `UrlGenerator::pathToUrl()` branches on `getAdapter() instanceof AwsS3V3Adapter`.
+- `routes/api_v2.php` is registered via `Route::middleware('api')->prefix('api/v2')->group(...)` in `app/Providers/RouteServiceProvider.php:50`; the `api` middleware group (`app/Http/Kernel.php:68-79`) includes `StartSession`/`AuthenticateSession` (session auth available with no extra guard) and `VerifyCsrfToken` (a no-op for GET requests, so no CSRF exception entry is needed for this endpoint).
+- No prior "API v3" or per-version controller-namespace convention exists anywhere in this codebase — this feature is the first. v2 organizes controllers by domain (e.g. `Gallery`), not by version; this feature follows the same domain-organization convention (`App\Http\Controllers\Gallery\PhotoAssetController`) rather than introducing a version-namespaced controller tree.
+- No prior "Struct of Arrays" implementation exists; only a forward-looking mention in `virtual-scrolling-study.md` (an unrelated design study of Immich's approach) — this feature does not implement SoA itself (ADR-0009).
diff --git a/docs/specs/4-architecture/features/056-api-v3-asset-retrieval/tasks.md b/docs/specs/4-architecture/features/056-api-v3-asset-retrieval/tasks.md
new file mode 100644
index 00000000000..4cc55d51b80
--- /dev/null
+++ b/docs/specs/4-architecture/features/056-api-v3-asset-retrieval/tasks.md
@@ -0,0 +1,137 @@
+# Feature 056 Tasks – API v3 Asset Retrieval
+
+_Status: Completed_
+_Last updated: 2026-08-21_
+
+> Keep this checklist aligned with the feature plan increments. Stage tests before implementation, record verification commands beside each task, and prefer bite-sized entries (≤90 minutes).
+> **Mark tasks `[x]` immediately** after each one passes verification—do not batch completions. Update the roadmap status when all tasks are done.
+> When new high- or medium-impact questions arise during execution, add them to [docs/specs/4-architecture/open-questions.md](../../open-questions.md) instead of informal notes, and treat a task as fully resolved only once the governing spec sections and, when required, ADRs under `docs/specs/6-decisions/` reflect the clarified behaviour.
+
+## Checklist
+
+### I1 – Route, request validation, and controller skeleton
+
+- [x] T-056-01a – Scaffold the `Feature_v3` test tree (F-056-01).
+ _Intent:_ Add a `Feature_v3` testsuite entry to `phpunit.xml` (mirrors the existing `Feature_v2` block: `./tests/Feature_v3`, excluding `./tests/Feature_v3/Base/BaseApiWithDataTest.php`). Create `tests/Feature_v3/Base/BaseApiWithDataTest.php` (`namespace Tests\Feature_v3\Base`, `extends \Tests\Feature_v2\Base\BaseApiWithDataTest` — reuses the existing user/album/photo fixture graph by inheritance, zero duplication, zero edits to the v2 file), adding a `getV3(string $uri, array $headers = []): TestResponse` helper that issues `$this->withCredentials()->get('/api/v3/' . ltrim($uri, '/'), $headers)` (native Laravel `get()`, not `getJson()`, since this endpoint's success response is binary). Doc comment on the class warns not to use the inherited v2-prefixed `getJson()`/`postJson()`/etc. helpers.
+ _Verification commands:_
+ - `php artisan test --testsuite=Feature_v3` (should report "No tests executed" — tree is empty until T-056-01b)
+ _Notes:_ v2's `tests/Feature_v2/Base/BaseApiWithDataTest.php` is not modified. See plan.md Assumptions for the full rationale (why inheritance over duplication or editing v2's `self::API_PREFIX`).
+
+- [x] T-056-01b – Failing Feature test for S-056-01 (F-056-01, F-056-02, F-056-03, S-056-01).
+ _Intent:_ Add `tests/Feature_v3/Photo/PhotoAssetV3Test.php` (extends `Tests\Feature_v3\Base\BaseApiWithDataTest`) with a test asserting `GET /api/v3/Photo/{id}/Asset/thumb` returns 200 with correct bytes for an authenticated owner. Confirm it fails (route/controller don't exist yet).
+ _Verification commands:_
+ - `php artisan test --filter=PhotoAssetV3Test`
+ _Notes:_ Establishes the test file and base fixture data (owned photo with a `THUMB` size variant) reused by later tasks in this increment.
+
+- [x] T-056-02 – `routes/api_v3.php` + `RouteServiceProvider` registration (F-056-01).
+ _Intent:_ New route file with `GET /Photo/{photo_id}/Asset/{size_variant}`; register `Route::middleware('api')->prefix('api/v3')->group(base_path('routes/api_v3.php'));` in `app/Providers/RouteServiceProvider.php`.
+ _Verification commands:_
+ - `php artisan route:list --path=api/v3`
+ _Notes:_ No controller yet — route resolves to a 500/404 until T-056-04.
+
+- [x] T-056-03 – `GetPhotoAssetRequest` (F-056-01, F-056-03).
+ _Intent:_ `app/Http/Requests/Photo/GetPhotoAssetRequest.php extends BaseApiRequest`. `rules()`: `photo_id` via `RandomIDRule`, `size_variant` matched against `SizeVariantType` case names (case-insensitive). `authorize()`: `Gate::check(PhotoPolicy::CAN_SEE or CAN_ACCESS_FULL_PHOTO, $photo)` depending on resolved `size_variant` class. `processValidatedValues()` resolves `Photo::findOrFail()` and the matching `SizeVariant` row (404 if either absent).
+ _Verification commands:_
+ - `php artisan test --filter=PhotoAssetV3Test`
+ - `make phpstan`
+ _Notes:_ Covers FR-056-01's validation path (422 on bad `size_variant`, 404 on unknown `photo_id`/missing variant) — add assertions for S-056-12/13/14 in the same test file.
+
+- [x] T-056-04 – `PhotoAssetController::show()` — local-disk streaming, no watermark/S3/temp-link yet (F-056-02).
+ _Intent:_ `app/Http/Controllers/Gallery/PhotoAssetController.php`, single `show(GetPhotoAssetRequest $request)` action streaming the plain (unwatermarked) local file via `response()->file(...)`, mirroring `SecurePathController`'s pattern.
+ _Verification commands:_
+ - `php artisan test --filter=PhotoAssetV3Test`
+ - `make phpstan`
+ _Notes:_ S-056-01 should now pass.
+
+- [x] T-056-05 – Tests for S-056-12/13/14 (validation/404 paths) (F-056-01, S-056-12, S-056-13, S-056-14).
+ _Intent:_ Add explicit test cases: missing `SizeVariant` row (e.g. request `RAW` on a photo with none) → 404; invalid `size_variant` token (e.g. `huge`) → 422; unknown `photo_id` → 404.
+ _Verification commands:_
+ - `php artisan test --filter=PhotoAssetV3Test`
+ _Notes:_ Closes out I1's exit criteria.
+
+### I2 – Temporary-link signing and `signatureRequired()`
+
+- [x] T-056-06 – `TemporaryLinkSigner` unit tests + implementation (F-056-04).
+ _Intent:_ Failing unit test first (`tests/Unit/Services/TemporaryLinkSignerTest.php`) covering: valid mac verifies, tampered mac fails, then implement `app/Services/TemporaryLinkSigner.php` (`sign()`/`verify()`, HMAC-SHA256 of the timestamp only, keyed by `config('app.key')`, `hash_equals()` comparison).
+ _Verification commands:_
+ - `php artisan test --filter=TemporaryLinkSignerTest`
+ - `make phpstan`
+ _Notes:_ No HTTP/Laravel request plumbing in this class — pure unit-testable.
+
+- [x] T-056-07 – Failing Feature tests for S-056-02..09 (F-056-04, S-056-02..09).
+ _Intent:_ Extend `PhotoAssetV3Test` with cases: guest + valid signed headers → 200; guest + valid signature but non-public album → 403; guest + no headers → 401; `temporary_image_link_enabled=false` → 401; tampered mac → 401; expired timestamp → 401; future timestamp → 401; only one of the two headers present → 422. Confirm all fail (headers not yet read by `authorize()`).
+ _Verification commands:_
+ - `php artisan test --filter=PhotoAssetV3Test`
+
+- [x] T-056-08 – Wire `X-Timestamp`/`X-Mac` header validation into `GetPhotoAssetRequest::authorize()`, with correct 401-vs-403 (F-056-04, F-056-05).
+ _Intent:_ Read both headers; both-or-neither validation (422 via `rules()` if only one present); call `TemporaryLinkSigner::verify()`; expiry/future-timestamp checks against `temporary_image_link_life_in_seconds`. Add `private bool $signature_check_failed = false;` on `GetPhotoAssetRequest`; set it `true` and `return false` immediately from `authorize()` on any signature failure (skipping the `PhotoPolicy` check). Override `failedAuthorization()`: `throw $this->signature_check_failed ? new UnauthenticatedException() : new UnauthorizedException();` — the inherited `BaseApiRequest::failedAuthorization()` keys off `Auth::check()` (session state), which gives the *wrong* status code for this endpoint (see spec.md FR-056-05); this override is required, not optional.
+ _Verification commands:_
+ - `php artisan test --filter=PhotoAssetV3Test`
+ - `make phpstan`
+ _Notes:_ S-056-02, 04, 05, 06, 07, 08, 09 should now pass. S-056-03 needs T-056-09 too (guest + valid signature still gated by `PhotoPolicy`).
+
+- [x] T-056-09 – Implement `signatureRequired()` predicate + tests for S-056-03, S-056-10, S-056-11 (F-056-05, S-056-03, S-056-10, S-056-11).
+ _Intent:_ Implement the config-driven predicate from ADR-0008 (reusing `temporary_image_link_enabled`/`_when_logged_in`/`_when_admin` via `ConfigManager`), wire into `authorize()` so authenticated sessions can bypass the header requirement per config, then add/pass tests: guest with valid signature but private album → 403 (`$signature_check_failed` stays `false`, `PhotoPolicy` denial drives the 403 via T-056-08's override); logged-in user required to still sign per config, no headers → 401 (`$signature_check_failed = true`); admin exempted per config → 200 without headers.
+ _Verification commands:_
+ - `php artisan test --filter=PhotoAssetV3Test`
+ - `make phpstan`
+ _Notes:_ Completes NFR-056-04's config/caller-state coverage (finished fully in T-056-13).
+
+### I3 – Watermark and S3-redirect branches
+
+- [x] T-056-10 – Failing tests for S-056-15/16 (F-056-06, F-056-07, S-056-15, S-056-16).
+ _Intent:_ Add test cases: S3-backed `SizeVariant` → expect 302 with a `Location` header pointing at a temporary S3 URL (mock/fake the S3 disk per this repo's existing S3 test-fixture convention — check `UploadSizeVariantToS3Job`'s test for the fake-disk pattern to reuse); viewer meeting watermark conditions → served bytes match the watermarked file, not the plain one.
+ _Verification commands:_
+ - `php artisan test --filter=PhotoAssetV3Test`
+
+- [x] T-056-11 – Watermark-aware file resolution (F-056-06).
+ _Intent:_ `PhotoAssetController::show()` calls `Watermarker::get_path($size_variant)` instead of the plain `short_path` before resolving the file to stream/redirect.
+ _Verification commands:_
+ - `php artisan test --filter=PhotoAssetV3Test`
+ - `make phpstan`
+
+- [x] T-056-12 – S3 redirect branch (F-056-07).
+ _Intent:_ Branch on `getAdapter() instanceof AwsS3V3Adapter` (mirrors `UrlGenerator::getAwsUrl()`); return `redirect()->away($disk->temporaryUrl(...))` for S3-backed variants, evaluated after the `PhotoPolicy` check passes; local-disk path unchanged (streams as before).
+ _Verification commands:_
+ - `php artisan test --filter=PhotoAssetV3Test`
+ - `make phpstan`
+ _Notes:_ S-056-15/16 should now pass.
+
+### I4 – Full scenario-matrix sweep and edge-case hardening
+
+- [x] T-056-13 – Full `signatureRequired()` config/caller-state matrix test (NFR-056-04).
+ _Intent:_ Dedicated test enumerating all 2×2×2 combinations of `temporary_image_link_enabled`/`_when_logged_in`/`_when_admin` crossed with (guest / logged-in-non-admin / admin) caller state; document the collapsed expected-outcome truth table in the test file's comments (several combinations collapse to the same result).
+ _Verification commands:_
+ - `php artisan test --filter=PhotoAssetV3Test`
+
+- [x] T-056-14 – `CAN_SEE` vs `CAN_ACCESS_FULL_PHOTO` split test (F-056-03, S-056-17).
+ _Intent:_ Test asserting the same photo/session returns 200 for `THUMB` but 403 for `ORIGINAL` when the album disables full-resolution access (`AlbumPolicy::canAccessFullPhoto()` denies while `canAccess()` allows).
+ _Verification commands:_
+ - `php artisan test --filter=PhotoAssetV3Test`
+ _Notes:_ Full scenario matrix (S-056-01..17) is green after this task.
+
+### I5 – Documentation and quality gate
+
+- [x] T-056-15 – `docs/specs/3-reference/api-design.md` "API v3" section.
+ _Intent:_ Document `API-056-01`'s route, `X-Timestamp`/`X-Mac` headers, and response codes (200/302/401/403/404/422); reference ADR-0009's SoA/binary-passthrough precedent.
+ _Verification commands:_ N/A (docs-only)
+
+- [x] T-056-16 – `docs/specs/4-architecture/knowledge-map.md` update.
+ _Intent:_ Add entries for `routes/api_v3.php`, `PhotoAssetController`, `TemporaryLinkSigner` under the appropriate Backend Application/Domain Layer subsections.
+ _Verification commands:_ N/A (docs-only)
+
+- [x] T-056-17 – Implementation Drift Gate diff check (NFR-056-03).
+ _Intent:_ Confirm `git diff master -- routes/api_v2.php routes/web_v2.php app/Http/Controllers/SecurePathController.php app/Services/UrlGenerator.php` is empty; record result in plan.md's Implementation Drift Gate section.
+ _Verification commands:_
+ - `git diff master -- routes/api_v2.php routes/web_v2.php app/Http/Controllers/SecurePathController.php app/Services/UrlGenerator.php`
+
+- [x] T-056-18 – Full quality gate + roadmap update.
+ _Intent:_ Run the full PHP quality gate; move Feature 056's roadmap row from Active to Completed.
+ _Verification commands:_
+ - `vendor/bin/php-cs-fixer fix`
+ - `php artisan test`
+ - `make phpstan`
+ _Notes:_ `make phpstan` (0 errors/2822 files) and `php-cs-fixer` (clean) both ran full-project and are unqualified passes. The unfiltered `php artisan test` run hits the same pre-existing environment-level timeout/SQLite-lock-contention issue documented for Features 052/055 (confirmed unrelated to this feature: an untouched existing test, `SecureImageLinksTest`, passes cleanly in isolation; a clean low-contention `--testsuite=Unit` run shows exactly one pre-existing unrelated failure class, missing php-ldap extension constants in `LdapServiceTest`). Verified instead via the full `Feature_v3` suite (18/18), `TemporaryLinkSignerTest` (3/3), and `LoginRequiredTest` (touches the same `app/Http/Kernel.php` this feature added one middleware alias to) — all green. Roadmap row moved to Completed.
+
+## Notes / TODOs
+- T-056-10's S3 test-fixture pattern (fake disk) should be reused from an existing S3-aware test (e.g. around `UploadSizeVariantToS3Job`) rather than reinvented — confirm the exact fixture during implementation.
diff --git a/docs/specs/4-architecture/knowledge-map.md b/docs/specs/4-architecture/knowledge-map.md
index 9f3798365cb..f2b81e275cb 100644
--- a/docs/specs/4-architecture/knowledge-map.md
+++ b/docs/specs/4-architecture/knowledge-map.md
@@ -7,9 +7,12 @@ This document tracks modules, dependencies, and architectural relationships acro
### Backend (Laravel/PHP)
#### Application Layer
+- **API v3** (`routes/api_v3.php`, Feature 056) - Greenfield `/api/v3/...` surface, registered alongside `routes/api_v2.php` in `RouteServiceProvider` (`Route::middleware('api')->prefix('api/v3')->group(...)`); additive only, v2 untouched. Establishes the convention that per-route binary-passthrough endpoints opt out of the `api` group's `accept_content_type:json`/`content_type:json` middleware via `->withoutMiddleware(...)` and instead apply the new `json_errors` middleware (`App\Http\Middleware\EnsureJsonErrorResponses`) so error responses still render as JSON.
- **Controllers** (`app/Http/Controllers/`) - Handle HTTP requests and route to services
- **AdminDashboardController** (`app/Http/Controllers/Admin/AdminDashboardController.php`) - `GET /api/v2/Admin/Stats`; delegates to `AdminStatsService`, wraps result in `AdminStatsResource`.
+ - **PhotoAssetController** (`app/Http/Controllers/Gallery/PhotoAssetController.php`, Feature 056) - `GET /api/v3/Photo/{photo_id}/Asset/{size_variant}`; resolves the watermark-aware path via `Watermarker::get_path()`, then either streams the local file (`FlysystemFile`/`response()->file()`) or redirects (302) to a native S3 temporary URL for S3-backed size variants (mirrors `UrlGenerator::getAwsUrl()`'s `AwsS3V3Adapter` detection).
- **Requests** (`app/Http/Requests/`) - Validate and sanitize incoming requests
+ - **GetPhotoAssetRequest** (`app/Http/Requests/Photo/GetPhotoAssetRequest.php`, Feature 056) - Resolves `Photo`/`SizeVariant` from route params; authorizes via `PhotoPolicy::CAN_SEE`/`CAN_ACCESS_FULL_PHOTO` depending on the size-variant class. Validates an optional paired `X-Timestamp`/`X-Mac` temporary-link signature (via `TemporaryLinkSigner`) ahead of the policy check; `signatureRequired()` (ADR-0008) decides per-caller whether a session alone suffices. Overrides `failedAuthorization()` to key 401-vs-403 off *which* check failed rather than session state (`Auth::check()`), since a signature-valid guest denied by policy needs 403 while a signature-required session missing its signature needs 401.
- **Resources** (`app/Http/Resources/`) - Transform models to API responses (use Spatie Data)
- **Middleware** (`app/Http/Middleware/`) - Request/response filtering and authentication
@@ -32,6 +35,7 @@ This document tracks modules, dependencies, and architectural relationships acro
- `auto_cover_id_max_privilege` - Cover photo for admin/owner view (ignores access control)
- `auto_cover_id_least_privilege` - Cover photo for public view (respects PhotoQueryPolicy + AlbumQueryPolicy)
- **Services** (`app/Services/`) - Business logic and orchestration
+ - **TemporaryLinkSigner** (`app/Services/TemporaryLinkSigner.php`, Feature 056) - `sign(int $timestamp): string`/`verify(int $timestamp, string $mac): bool`; stateless HMAC-SHA256 of the timestamp only (not `photo_id`/`size_variant`-scoped), keyed by `config('app.key')`, `hash_equals()` comparison. TTL/future-timestamp checks live in the caller (`GetPhotoAssetRequest`), not here.
- **AdminStatsService** (`app/Services/AdminStatsService.php`) - Aggregates system-wide metrics (photos, albums, users, storage, jobs) with 5-minute cache under key `admin.stats`. Supports forced refresh via `$force = true`. Returns `AdminStatsOverview` DTO; partial failures captured in `errors[]` and suppress caching.
- **LDAP Service** (`app/Services/Auth/LdapService.php`) - Enterprise directory integration (wrapper over LdapRecord)
- Search-first authentication pattern: searches for user by username → gets DN → binds with DN + password
diff --git a/docs/specs/4-architecture/open-questions.md b/docs/specs/4-architecture/open-questions.md
index 2010fee07c0..e4522fdf2c2 100644
--- a/docs/specs/4-architecture/open-questions.md
+++ b/docs/specs/4-architecture/open-questions.md
@@ -6,6 +6,13 @@ Track unresolved high- and medium-impact questions here. Remove each row as soon
| Question ID | Feature | Priority | Summary | Status | Opened | Updated |
|-------------|---------|----------|---------|--------|--------|---------|
+| ~~Q-056-01~~ | 056 – API v3 Asset Retrieval | High | MAC/signature mechanism for temporary-link `timestamp`+`mac` params — bespoke HMAC vs. reuse of Laravel's `URL::temporarySignedRoute()` | Resolved (custom — HMAC-SHA256 of the timestamp only, not photo/size-variant-scoped) | 2026-08-20 | 2026-08-20 |
+| ~~Q-056-02~~ | 056 – API v3 Asset Retrieval | High | Response shape for this endpoint — raw binary passthrough vs. JSON envelope; does the "Struct of Arrays" v3 principle apply to a single-item retrieval endpoint at all | Resolved (A — binary passthrough; SoA scoped to future collection endpoints) | 2026-08-20 | 2026-08-20 |
+| ~~Q-056-03~~ | 056 – API v3 Asset Retrieval | Medium | S3-backed size variants — proxy/stream through Lychee vs. redirect to a native S3 temporary URL (today's asymmetric behaviour) | Resolved (B — redirect to native S3 temporary URL, same asymmetry as v2) | 2026-08-20 | 2026-08-20 |
+| ~~Q-056-04~~ | 056 – API v3 Asset Retrieval | Medium | Watermark behaviour — always watermarked (view semantics), always raw (download semantics), or caller-selectable | Resolved (A — always watermark-aware, mirrors `getUrlAttribute()`) | 2026-08-20 | 2026-08-20 |
+| ~~Q-056-05~~ | 056 – API v3 Asset Retrieval | Medium | Authorization when not using a temporary link — must the caller be authenticated + PhotoPolicy-checked, or is it public like today's `image/{path}` route | Resolved (custom — depends on `temporary_image_link_*` configs; PhotoPolicy always checked regardless of mode) | 2026-08-20 | 2026-08-20 |
+| ~~Q-056-06~~ | 056 – API v3 Asset Retrieval | Medium | URL/parameter contract shape — path segments vs. query string for `photo_id`/`size_variant`/`timestamp`/`mac` | Resolved (A — path segments for resource identifiers, query string for signature params) | 2026-08-20 | 2026-08-20 |
+| ~~Q-056-07~~ | 056 – API v3 Asset Retrieval | Medium | Discovered during I1 implementation: the shared `api` middleware group (`app/Http/Kernel.php`) applies `accept_content_type:json`/`content_type:json`, which throw `UnexpectedContentType` for any request/response that isn't JSON-negotiated — incompatible with this endpoint's binary passthrough response (FR-056-02) and any real client's GET request without an `Accept: application/json` header. Opting the route out of both middleware (only) then surfaced a second-order issue: Laravel's default exception rendering also keys off the client's `Accept` header, so error responses (422/404/etc.) would silently degrade to Lychee's HTML error page instead of the JSON body FR-056-02 requires, for any real client that (correctly) never sends `Accept: application/json` to a binary-asset endpoint. | Resolved (A — the v3 route opts out of `accept_content_type:json`/`content_type:json` via `Route::withoutMiddleware(...)` in `routes/api_v3.php`, scoped to this one route, and adds a new small `json_errors` middleware (`App\Http\Middleware\EnsureJsonErrorResponses`, aliased in `app/Http/Kernel.php`) that unconditionally sets the request's `Accept` header to `application/json` before the controller runs — success responses are unaffected since `response()->file()` doesn't consult `Accept`, but every exception path now renders through Lychee's standard JSON error convention regardless of the caller's real `Accept` header. The shared `api` middleware group definition and all v2 routes are untouched) | 2026-08-20 | 2026-08-20 |
| ~~Q-055-01~~ | 055 – Multi-Track Albums | High | Legacy v7 UI keeps single-track support (per user instruction) while v8 gets full multi-track CRUD — how does the shared backend serve both once tracks move to their own table? | Resolved (A — legacy `Album::track` endpoints keep working unchanged, operating on a "primary" track (oldest-by-id) in the new `tracks` table; v8 gets new `Album::tracks` endpoints and forks its own frontend service/components) | 2026-08-17 | 2026-08-17 |
| ~~Q-055-02~~ | 055 – Multi-Track Albums | Medium | Map view (v8) — how should multiple simultaneous tracks be rendered/distinguished (colors, legend, toggle)? | Resolved (A — all tracks rendered simultaneously, one color per track, Leaflet layer-control legend with per-track visibility checkboxes) | 2026-08-17 | 2026-08-17 |
| ~~Q-055-03~~ | 055 – Multi-Track Albums | Medium | Track upload UX in v8 — single-file-at-a-time vs. batch multi-file upload, and where/how the `name` field is set (auto from filename vs. required user input, rename support). | Resolved (A — batch multi-file upload, name defaults to filename, rename supported afterward) | 2026-08-17 | 2026-08-17 |
@@ -104,6 +111,146 @@ Track unresolved high- and medium-impact questions here. Remove each row as soon
## Question Details
+### ~~Q-056-01~~: MAC/signature mechanism for temporary asset links ✅ RESOLVED
+
+**Status:** Resolved — **custom** (HMAC-SHA256 of the timestamp only)
+**Feature:** 056 – API v3 Asset Retrieval
+**Priority:** High
+**Opened:** 2026-08-20
+**Resolved:** 2026-08-20
+
+**Resolution:** `mac = hash_hmac('sha256', (string) $timestamp, config('app.key'))`, hex-encoded, verified with `hash_equals()`. Deliberately scoped to the timestamp alone — **not** `photo_id`/`size_variant` — per explicit owner instruction ("Hmac of timestamp only"). A new `App\Services\TemporaryLinkSigner` (`sign()`/`verify()`) owns this; no new secret storage (reuses `APP_KEY`). Expiry reuses the existing `temporary_image_link_life_in_seconds` config. See ADR-0008 for the full security rationale, including the accepted trade-off that a leaked `(timestamp, mac)` pair is valid for any photo/size-variant within the TTL window (bounded by `PhotoPolicy`, which is always separately enforced — Q-056-05).
+
+**Spec impact:** FR-056-03; NFR-056-01; DO-056-01. ADR-0008.
+
+**Context:** Today's only signed-URL mechanism (`app/Services/UrlGenerator.php`, `app/Http/Controllers/SecurePathController.php`) uses Laravel's built-in `URL::temporarySignedRoute()`, which produces `expires` + `signature` query params — an HMAC-SHA256 over the *entire canonical URL* (host, path, all query params in Laravel's own ordering), keyed by `APP_KEY`, verified via `$request->hasValidSignature()`. The user's ask for v3 explicitly describes a `timestamp` + a MAC "of the timestamp" — a different shape than Laravel's `expires`/`signature` idiom, and a MAC computed over just the timestamp (not the whole URL) suggests a simpler, more portable scheme that a non-Laravel client could reproduce without depending on Laravel's URL-canonicalization internals. Since v3 is greenfield, nothing forces reuse of the v2 mechanism.
+
+**Question:** What algorithm, inputs, and secret produce the `mac` value, and how does it relate to Laravel's existing signed-URL support?
+- **Option A (Recommended):** New, bespoke endpoint-level HMAC: `mac = hash_hmac('sha256', "{photo_id}:{size_variant}:{timestamp}", $secret)`, hex-encoded, verified with `hash_equals()` and an expiry window read from config (mirroring `temporary_image_link_enabled`'s existing TTL knob). Secret is a server-side key (e.g. derived from `APP_KEY`, or a new dedicated config value). Decoupled from Laravel's `hasValidSignature()` internals — any client that knows the secret and the three inputs can (re)compute a valid link without touching Laravel's route/query-canonicalization rules.
+- **Option B:** Reuse Laravel's `URL::temporarySignedRoute()`/`hasValidSignature()` exactly as `SecurePathController` does today, just renaming/aliasing `expires`→`timestamp` and `signature`→`mac` in the request contract while keeping Laravel's canonical-URL HMAC underneath.
+- **Option C:** Don't build a new mechanism at all — v3's asset endpoint redirects to (or wraps) the existing `image/{path}` signed-URL flow via `UrlGenerator::pathToUrl()`, so `SecurePathController` remains the sole place that validates signatures.
+
+**Impact:** Determines the new Request/validation class's shape, any new config keys (secret source, TTL), whether a new `Services/` class is needed to compute/verify the MAC, and whether this is a genuinely new REST contract or a thin wrapper around the existing v2 mechanism.
+
+---
+
+### ~~Q-056-02~~: Response shape — binary passthrough vs. JSON envelope ✅ RESOLVED
+
+**Status:** Resolved — **Option A** (binary passthrough)
+**Feature:** 056 – API v3 Asset Retrieval
+**Priority:** High
+**Opened:** 2026-08-20
+**Resolved:** 2026-08-20
+
+**Resolution:** Pure binary passthrough — raw file bytes with `Content-Type` set from the resolved file, no JSON envelope. The SoA-vs-AoS response-shape principle is scoped to future v3 endpoints that return collections; it does not apply to this single-item binary-retrieval endpoint. See ADR-0009.
+
+**Spec impact:** FR-056-02; Non-Goals. ADR-0009.
+
+**Context:** The user states v3's "base" will be Struct-of-Arrays (SoA) instead of Array-of-Structs (AoS) — a response-*shape* principle that applies naturally to *list/collection* endpoints (e.g., a future paginated endpoint returning `{ids: [...], titles: [...], ...}` instead of `[{id, title, ...}, ...]`). This first endpoint, though, returns exactly one binary file for one `photo_id` + one `size_variant` — there is no array of records to arrange as SoA vs. AoS in the response body itself, so it's unclear whether/how the SoA principle is meant to apply here at all.
+
+**Question:** What does this endpoint actually return on success?
+- **Option A (Recommended):** Pure binary passthrough — raw file bytes with appropriate `Content-Type`/`Content-Disposition` headers, HTTP 200 (or 302 redirect for the S3 case, see Q-056-03), no JSON envelope at all — same client contract as today's `image/{path}` route. The SoA principle is scoped to future v3 endpoints that return collections, not to this single-item binary-retrieval endpoint.
+- **Option B:** Wrap the binary response in a JSON envelope (base64-encoded `data` field plus metadata fields), so that literally every v3 response — including this one — is JSON-shaped for client-side consistency.
+- **Option C:** Return JSON metadata only (a resolved, possibly-signed URL to the actual bytes, mirroring `SizeVariant::getUrlAttribute()`) rather than streaming bytes directly from this endpoint — the client makes a second request to fetch the actual file.
+
+**Impact:** Fundamentally different controller implementation, telemetry shape, and client integration pattern (`
`/direct byte stream vs. `fetch()` + JSON decode + second request). Also determines whether this endpoint needs its own Spatie Data Resource class at all.
+
+---
+
+### ~~Q-056-03~~: S3-backed size variants — proxy vs. redirect ✅ RESOLVED
+
+**Status:** Resolved — **Option B** (redirect to native S3 temporary URL)
+**Feature:** 056 – API v3 Asset Retrieval
+**Priority:** Medium
+**Opened:** 2026-08-20
+**Resolved:** 2026-08-20
+
+**Resolution:** For S3-backed variants, the endpoint responds with an HTTP 302 redirect to a freshly generated native S3 temporary URL — same asymmetric behaviour as v2's `UrlGenerator::getAwsUrl()`, keeping the app server out of the byte-proxying path for cloud-stored files.
+
+**Spec impact:** FR-056-05.
+
+**Context:** Today, only local-disk size variants are streamed through Lychee (`SecurePathController::__invoke`, `response()->file($file)`); S3-backed variants bypass Lychee entirely — `UrlGenerator::getAwsUrl()` hands the client a native, S3-issued temporary URL instead, so S3 files are fetched directly from AWS, never proxied. v3's endpoint takes `photo_id` + `size_variant` directly (not an opaque path token), so it's a natural point to decide disk behaviour fresh rather than inheriting the existing asymmetry.
+
+**Question:** How does this endpoint serve a size variant stored on a non-local disk?
+- **Option A (Recommended):** Always proxy bytes through Lychee for every disk (local and S3 alike), via the existing `SizeVariant::getFile()`/`FlysystemFile` abstraction (streamed response) — consistent behaviour regardless of storage backend, fixes the existing local/S3 asymmetry, and keeps all v3 asset traffic authenticated at one chokepoint.
+- **Option B:** For S3-backed variants, respond with an HTTP 302 redirect to a freshly generated native S3 temporary URL — mirrors today's asymmetric behaviour, avoids double-proxying bandwidth through the app server.
+- **Option C:** Local disk only for this feature; requests for S3-backed variants return 501 Not Implemented, with S3 support deferred to a follow-up feature.
+
+**Impact:** Bandwidth/infra cost trade-off, controller complexity (streaming vs. redirect branch), and whether this feature touches `UrlGenerator`/adds a new S3-streaming code path.
+
+---
+
+### ~~Q-056-04~~: Watermark behaviour ✅ RESOLVED
+
+**Status:** Resolved — **Option A** (always watermark-aware)
+**Feature:** 056 – API v3 Asset Retrieval
+**Priority:** Medium
+**Opened:** 2026-08-20
+**Resolved:** 2026-08-20
+
+**Resolution:** Always applies the same watermark resolution as the existing display path — `Watermarker::get_path($size_variant)` — before serving/redirecting. This is a "view" endpoint, matching gallery-display use, not a download endpoint.
+
+**Spec impact:** FR-056-04.
+
+**Context:** `SizeVariant::getUrlAttribute()` (today's main "display" accessor) resolves and returns a watermarked path when applicable; the separate `getDownloadUrlAttribute()` deliberately does not. The user's description ("return the associated file") doesn't say which semantic this v3 endpoint matches.
+
+**Question:** Does this endpoint apply watermarking?
+- **Option A (Recommended):** Always apply the same watermark resolution as the existing display path (`getUrlAttribute()`'s logic) — this is framed as a "view" endpoint (id + size-variant lookup), matching gallery-display use, not a download endpoint.
+- **Option B:** Always return the raw/original stored file, never watermarked (download semantics) — a separate future v3 endpoint would be needed for watermark-aware display.
+- **Option C:** Make it caller-selectable via a request parameter (e.g. `?download=true`) that toggles between watermarked-view and raw-download semantics within this one endpoint.
+
+**Impact:** Determines whether this feature is purely a "view" replacement for `image/{path}` or also subsumes today's separate download flow; shapes FR wording and whether a second parameter/branch is in scope.
+
+---
+
+### ~~Q-056-05~~: Authorization when not using a temporary link ✅ RESOLVED
+
+**Status:** Resolved — **custom** (config-dependent signature requirement; `PhotoPolicy` always checked)
+**Feature:** 056 – API v3 Asset Retrieval
+**Priority:** Medium
+**Opened:** 2026-08-20
+**Resolved:** 2026-08-20
+
+**Resolution:** Whether a valid `timestamp`+`mac` is *required* at all depends on the existing `temporary_image_link_enabled`/`temporary_image_link_when_logged_in`/`temporary_image_link_when_admin` configs, re-purposed from v2's generation-time semantics (`UrlGenerator::shouldNotUseSignedUrl()`) into a validation-time predicate (`signatureRequired()`, see ADR-0008) evaluated against the current request's auth state. Guests are only ever admitted via a valid signature (when the feature is enabled); authenticated/admin callers may rely on their session alone if the corresponding `_when_*` config says so. In **every** case — session-authenticated or temporary-link — `PhotoPolicy::CAN_SEE`/`CAN_ACCESS_FULL_PHOTO` is evaluated against the resolved `Auth::user()` (nullable) before serving the file. This is the key security delta versus v2's `SecurePathController`, which never checks `PhotoPolicy` at all.
+
+**Spec impact:** FR-056-02/03; NFR-056-01. ADR-0008.
+
+**Context:** The existing `image/{path}` route has no `login_required` middleware — it relies entirely on the signed/encrypted opaque path token for access control (anyone holding a valid link URL can fetch the file; no session check happens at all). A v3 endpoint addressed by a plain `photo_id` + `size_variant` (not an opaque encrypted path) is directly guessable/enumerable, so its default (non-temporary-link) access path needs an explicit authorization story that the opaque-path design never had to solve.
+
+**Question:** What authorizes a request that does *not* carry `timestamp`+`mac`?
+- **Option A (Recommended):** Default (non-temporary-link) requests must be authenticated (Sanctum session or API token, same guard as other `/api/v2/...` endpoints) and are then subject to the same `PhotoPolicy`/`PhotoQueryPolicy` album-visibility checks as other authenticated photo endpoints. The `timestamp`+`mac` temporary-link path becomes the *only* way to fetch an asset unauthenticated — mirroring today's `temporary_image_link_enabled` config gate, just re-scoped to this new endpoint.
+- **Option B:** Fully public/unauthenticated by default (like today's `image/{path}` route), with album-visibility rules (public/unlisted) enforced but no session required at all — `timestamp`/`mac` become optional and only matter for privately-shared/protected albums.
+- **Option C:** Require `timestamp`+`mac` unconditionally on every request to this endpoint, even for already-logged-in users — no separate authenticated-session code path at all.
+
+**Impact:** Determines whether new middleware/guard wiring is needed, whether this endpoint depends on `SecurePathRequest`-style config gating (`secure_image_link_enabled`/`temporary_image_link_enabled`), and the shape of 401 vs. 403 vs. 404 responses.
+
+---
+
+### ~~Q-056-06~~: URL/parameter contract shape ✅ RESOLVED
+
+**Status:** Resolved — **Option A, amended** (path segments; signature via headers, not query string)
+**Feature:** 056 – API v3 Asset Retrieval
+**Priority:** Medium
+**Opened:** 2026-08-20
+**Resolved:** 2026-08-20 (amended same day — signature moved from query string to headers)
+
+**Resolution:** `GET /api/v3/Photo/{photo_id}/Asset/{size_variant}` — RESTful path segments for the resource identifiers. The signature is **not** carried in the query string: `timestamp` and `mac` are passed as request headers, `X-Timestamp` and `X-Mac` (matching this codebase's existing single-word `X-` custom-header convention, e.g. `X-API-Key` in `app/Http/Requests/Face/FaceDetectionResultsRequest.php`). Rationale for the amendment: keeps signature material out of server access logs' request-line/URL field (a real, if partial, improvement over query-string placement — headers can still be logged by some proxies, but are not part of the URL itself), and out of browser history / `Referer` leakage if this endpoint's URL is ever linked from another page.
+
+**Spec impact:** API-056-01; DO-056-01 (request shape amended: `timestamp`/`mac` are headers, not query params).
+
+**Context:**
+
+**Context:** No v3 routing convention exists yet — this feature establishes the first one. v2's precedent is a single flat `routes/api_v2.php` with domain-organized (not version-namespaced) controllers, and action-style routes like `Album::head`. v3 needs its own decision for how `photo_id`, `size_variant`, `timestamp`, and `mac` are carried on the request.
+
+**Question:** What does the request URL look like?
+- **Option A (Recommended):** RESTful path segments for the resource identifiers, query string for the signature params: `GET /api/v3/Photo/{photo_id}/Asset/{size_variant}?timestamp=...&mac=...` — resource path reads as cacheable/bookmarkable, signature params stay in the query string like today's `expires`/`signature`.
+- **Option B:** Fully query-string based: `GET /api/v3/Asset?photo_id=...&size_variant=...×tamp=...&mac=...` — flat and uniform, straightforward to generate programmatically from an SoA-style client (parallel arrays of ids/types) via simple param substitution.
+- **Option C:** Fully path-segment based, including the signature: `GET /api/v3/Photo/{photo_id}/Asset/{size_variant}/{timestamp}/{mac}` — maximally cacheable at a CDN/reverse-proxy layer, but puts signature material directly in the URL path rather than the query string.
+
+**Impact:** Defines the concrete `API-056-01` contract entry, the new `routes/api_v3.php` additions, and the OpenAPI/documentation shape for this and future v3 endpoints.
+
+---
+
### ~~Q-055-01~~: Legacy v7 backend compatibility strategy for multi-track albums ✅ RESOLVED
**Status:** Resolved — **Option A** (legacy endpoints act on a "primary" track)
diff --git a/docs/specs/4-architecture/roadmap.md b/docs/specs/4-architecture/roadmap.md
index 8c4accb1989..7da209e8de8 100644
--- a/docs/specs/4-architecture/roadmap.md
+++ b/docs/specs/4-architecture/roadmap.md
@@ -18,6 +18,7 @@ High-level planning document for Lychee features and architectural initiatives.
| Feature ID | Name | Completed | Notes |
|------------|------|-----------|-------|
+| 056 | API v3 Asset Retrieval | 2026-08-21 | All 18 tasks (T-056-01a..18) implemented and `[x]`. First `/api/v3/...` endpoint (`GET /api/v3/Photo/{photo_id}/Asset/{size_variant}`, `routes/api_v3.php`, `PhotoAssetController`), coexisting with v2 unchanged (Implementation Drift Gate: empty diff on `routes/api_v2.php`/`routes/web_v2.php`/`SecurePathController.php`/`UrlGenerator.php`). New `GetPhotoAssetRequest` resolves `Photo`/`SizeVariant`, validates an optional paired `X-Timestamp`/`X-Mac` temporary-link signature (new stateless `TemporaryLinkSigner` service, HMAC-SHA256 of the timestamp keyed by `app.key`) ahead of `PhotoPolicy::CAN_SEE`/`CAN_ACCESS_FULL_PHOTO`, with a `signatureRequired()` predicate (ADR-0008, mirrors `UrlGenerator::shouldNotUseSignedUrl()`) deciding per-caller whether a session alone suffices; overrides `failedAuthorization()` to key 401-vs-403 off which check failed rather than session state. Watermark-aware (`Watermarker::get_path()`) and S3-redirect-aware (302 to a native temporary URL, mirrors `UrlGenerator::getAwsUrl()`'s `AwsS3V3Adapter` detection) file resolution. Discovered mid-implementation (Q-056-07): the shared `api` middleware group's JSON content-negotiation is incompatible with a binary-passthrough endpoint — this route opts out of `accept_content_type:json`/`content_type:json` and adds a new `json_errors` middleware (`EnsureJsonErrorResponses`) so error responses still render as Lychee's standard JSON body regardless of the caller's real `Accept` header. New `tests/Feature_v3/` test tree (`Feature_v3` phpunit testsuite) with its own `BaseApiWithDataTest` extending the v2 fixture graph by inheritance (zero v2 edits); `PhotoAssetV3Test` covers the full S-056-01..17 scenario matrix plus NFR-056-04's 2×2×2 config/caller-state matrix (18 tests, ~1000 assertions), all written test-first per increment. `make phpstan`: 0 errors across all 2822 files. `php-cs-fixer`: clean. `php artisan test`: full unfiltered run hits the same pre-existing environment-level process-timeout/SQLite-lock-contention issue documented for Features 052/055 (unrelated to this feature — confirmed by running an untouched existing test, `SecureImageLinksTest`, cleanly in isolation, and by a clean low-contention `--testsuite=Unit` run showing only one pre-existing unrelated failure, a missing php-ldap extension constant in `LdapServiceTest`); verified instead via the full `Feature_v3` suite, `TemporaryLinkSignerTest`, and targeted spot-checks of shared-file-adjacent tests (`LoginRequiredTest`, since `app/Http/Kernel.php` gained one new middleware alias), all green. `docs/specs/3-reference/api-design.md` and `docs/specs/4-architecture/knowledge-map.md` updated. |
| 055 | Multi-Track Albums | 2026-08-18 | All 30 tasks (T-055-01..30) implemented and `[x]`. Replaces the single-nullable-column `albums.track_short_path` with a `tracks` child table (`app/Models/Track.php`, own auto-increment PK, `disk` cast to `StorageDiskType`, explicit `is_primary` boolean — `oldestOfMany`/`ofMany` dropped, zero prior usage anywhere in this codebase, Q-055-10) + backfill/drop-column migration (S-055-13). v7's single-track UI is fully unchanged (`resources/js/v7/` diff empty, NFR-055-01/S-055-15) — `Album::setTrack()`/`deleteTrack()` transparently delegate to the primary track, with explicit next-oldest promotion on delete. New v8-only `POST`/`PATCH`/`DELETE /Album::tracks` REST surface (`AlbumTracksController`, a new standalone controller per Q-055-08 — no prior precedent existed to mirror), `TrackResource`, `tracks[]` added to `HeadAlbumResource`/`PositionDataResource`. Fixed the pre-existing `Actions\Album\Delete` hardcoded-`StorageDiskType::LOCAL` gap (FR-055-12): tracks now collected recursively across the album subtree, grouped by disk, one `FileDeleterJob` per distinct disk. New `UploadTrackToS3Job`/`lychee:track_s3_migrate` mirror the existing `SizeVariant` S3-offload pattern. Frontend: forked `resources/js/v8/services/track-service.ts` (NFR-055-01, shared `album-service.ts` untouched), new `AlbumTracks.vue` section registered inside the existing Album Settings modal (no new dialog, Q-055-05), `Map.vue` rewritten for one `L.GPX` layer per track wired into Leaflet's native `L.control.layers` legend (Q-055-02, no bespoke component) — also fixed a pre-existing bug where `Map.vue` never rendered anything on a photo-less album (found while implementing S-055-14, documented in plan.md's Implementation Drift Gate). All 13 Q-055-* clarifications resolved (Q-055-01..05 initial, Q-055-06..13 from a same-day codebase-verification pass). `make phpstan`: 0 errors across all 2811 files. `php-cs-fixer`: clean. `npm run check`/`npm run format`: clean. `php artisan test`: full unfiltered run hits a pre-existing environment-level 600s process time limit (documented precedent from Feature 052, unrelated to this feature); verified instead via targeted runs — all new Track test files plus the full `--filter=Album` (812 tests) and `--filter=Delete` (129 tests) suites, zero failures. |
| 054 | Configurable Landing Page | 2026-08-11 | All 63 tasks (T-054-01..63, incl. T-054-15a) implemented and `[x]`. 6 new enums (`LandingLayoutType`, `LandingTextPosition`, `LandingAnimationPreset`, `LandingLinkPlacement`, `LandingFeaturedItemsMode`, `LandingFeaturedItemType`); 12 new scalar configs under `Mod Welcome` (a new `int:MIN:MAX` bounded-range `type_range` convention added to `Configs::sanity()`/`ConfigGroup.vue` for `landing_hero_text_opacity`/`landing_featured_items_count`); `LandingLink`/`LandingFeaturedItem` models+migrations+factories+full admin CRUD (incl. a new `{ ids: string[] }` full-list-resync `Reorder` endpoint pattern, no prior precedent in this codebase); `LandingPageResource` extended with SE-fallback layout/animation resolution (mirrors `InitConfig::set_supporter_properties`) and automatic/manual featured-content resolution (`LandingFeaturedContentResource`, `Photo`/`Album` unified projection). Frontend: `Landing.vue` is now a thin dispatcher over 4 prop-driven layout components (`LandingClassic`/`LandingPortfolio`/`LandingMinimal`/`LandingStudio`, all under `resources/js/v8/views/landing/`), `useLandingTextPosition`/`useLandingAnimation`/`useScrollReveal` composables (the latter is `parallax_scroll`'s `IntersectionObserver`-driven per-section reveal), new `landingZoomReveal`/`landingSlideReveal` CSS keyframes. New admin page `LandingConfig.vue` (Settings tab with WatermarkPreview-style local-draft-then-explicit-Save plus a live scaled-down preview reusing the real layout components; Links and Featured tabs with immediate-save CRUD and native-HTML5-DnD drag-reorder — no drag library existed in this repo, none added). Q-054-01 resolved (`ConfigIntegrity` whitelist deliberately *not* touched — see open-questions.md). `resources/js/v7/` diff confirmed empty (NFR-054-08). `php artisan test`: full suite green except pre-existing unrelated failures (confirmed via file paths outside this feature — `OptimizeTablesTest`, `PhotosAddHandlerImagickTest`, `PhotoAddTest` apple-live-photo cases). `make phpstan`: 0 errors. `npm run check`/`npm run format`: clean. 22-locale translation sweep done (English-placeholder convention for untranslated new keys, matching existing repo practice); `LangTest`/`CopyrightTest` both green. |
| 053 | Album Listing Caching | 2026-08-10 | All 24 tasks (T-053-01..24) implemented and green via targeted `--filter` test runs (full-suite run deferred per explicit instruction for this session). Resumes Feature 052's deferred/superseded invalidation design for the album-listing half only. Six independently-cached SQL queries across three consumers — `AlbumRepository::getChildrenPaginated()`, each of `Actions\Albums\Top::get()`'s four constituent queries (tag/person/pinned/root albums, each with its own type-discriminating key prefix per NFR-053-08), and `GetTagWithPhotosAndAlbums::getAccessibleAlbums()` (session-unlock-state-aware key per NFR-053-07) — all via new `ManagedCacheService::rememberIf()`. 9 new domain events, 20 new/fixed dispatch sites (incl. the `SetProtectionPolicy` `TypeError` fix for tag/person albums, FR-053-11, plus two more latent instances of the same bug class found in `AlbumController::rename()`/`setPinned()` during implementation), new `ManagedCacheAlbumListingInvalidator`/`ManagedCacheUserListingInvalidator` listeners (11 event→tag bindings). New `managed_cache_albums_enabled` config toggle (AND'd with Feature 052's `managed_cache_enabled`), plus the `managed_cache_enabled`/`managed_cache_ttl` migration + `SettingsController` visibility exemption Feature 052 left undone. `make phpstan`: 0 errors. `php-cs-fixer`: 0 violations. See plan.md's Implementation Drift Gate for the handful of implementation-time findings (a default-eager-load pitfall in `BulkEditAlbumsAction`, two more `TypeError`-bug-class endpoints, and a test-isolation config leak in `AlbumRepositoryTest`, all fixed). |
@@ -127,4 +128,4 @@ features/
---
-*Last updated: 2026-08-18 (Feature 055 moved to Completed Features)*
+*Last updated: 2026-08-21 (Feature 056 moved to Completed Features)*
diff --git a/docs/specs/6-decisions/ADR-0008-v3-asset-endpoint-signing-and-authorization.md b/docs/specs/6-decisions/ADR-0008-v3-asset-endpoint-signing-and-authorization.md
new file mode 100644
index 00000000000..ec9f38f6a15
--- /dev/null
+++ b/docs/specs/6-decisions/ADR-0008-v3-asset-endpoint-signing-and-authorization.md
@@ -0,0 +1,72 @@
+# ADR-0008: Temporary-link signing and authorization model for the v3 asset endpoint
+
+- **Status:** Accepted
+- **Date:** 2026-08-20
+- **Related features/specs:** Feature 056 (docs/specs/4-architecture/features/056-api-v3-asset-retrieval/spec.md)
+- **Related open questions:** Q-056-01, Q-056-05
+
+## Context
+
+Lychee's only existing signed-link mechanism is Laravel's native `URL::temporarySignedRoute()` (`app/Services/UrlGenerator.php:71`), which HMAC-SHA256-signs the *entire canonical URL* and emits `expires`+`signature` query params, verified via `Illuminate\Http\Request::hasValidSignature()` (`app/Http/Controllers/SecurePathController.php`). That controller never checks `PhotoPolicy` at all — the opaque, optionally `Crypt`-encrypted path token *is* the access control; anyone holding a valid link URL can fetch the file, logged in or not.
+
+Feature 056 introduces a v3 endpoint addressed by a plain `photo_id` + `size_variant` (not an opaque path), which is directly guessable/enumerable. The owner asked for this endpoint's temporary-link mode to carry a `timestamp` and "the MAC of the timestamp" specifically — not a MAC of the full request/resource, and confirmed the endpoint must always additionally check `PhotoPolicy`, with whether a signature is required at all depending on the same three config keys that already gate v2's signed-URL *generation*: `temporary_image_link_enabled`, `temporary_image_link_when_logged_in`, `temporary_image_link_when_admin` (`database/migrations/2025_04_05_153533_add_secure_link_options.php`).
+
+## Decision
+
+1. **MAC scope — timestamp only, not resource-scoped.** `mac = hash_hmac('sha256', (string) $timestamp, config('app.key'))`, hex-encoded, verified with `hash_equals()`. It authenticates *that a timestamp was minted by the server* (anti-tampering/anti-fabrication of the clock value), not a capability grant for a specific `photo_id`/`size_variant`. A new `App\Services\TemporaryLinkSigner` class owns `sign(int $timestamp): string` / `verify(int $timestamp, string $mac): bool`, keyed off `config('app.key')` — no new secret storage.
+1a. **Transport — request headers, not query string.** `timestamp` and `mac` are carried as request headers, `X-Timestamp` and `X-Mac`, matching this codebase's existing single-word `X-` custom-header convention (`X-API-Key`, `app/Http/Requests/Face/FaceDetectionResultsRequest.php`). Amended same-day from an initial query-string design (Q-056-06) — keeping signature material out of the URL avoids it landing in server access-log request lines and `Referer` headers if this endpoint's URL is ever linked from another page.
+2. **Expiry.** Reuses the existing `temporary_image_link_life_in_seconds` config (same TTL knob v2 already exposes): request rejected if `now() - $timestamp > $ttl`, or if `$timestamp` is in the future.
+3. **PhotoPolicy is always evaluated, regardless of access mode.** This is the key security delta versus `SecurePathController`: the v3 endpoint never treats "the link is well-formed" as sufficient authorization on its own. `PhotoPolicy::CAN_SEE` gates thumbnail-class variants (`THUMB`, `THUMB2X`, `SMALL`, `SMALL2X`, `PLACEHOLDER`); `PhotoPolicy::CAN_ACCESS_FULL_PHOTO` gates full-resolution variants (`MEDIUM`, `MEDIUM2X`, `ORIGINAL`, `RAW`) — evaluated against `Auth::user()` (nullable, i.e. guest-aware) exactly as `PhotoPolicy::canSee()`/`canAccessFullPhoto()` already support. Signature validation (steps 1/1a/2) runs **strictly before** `PhotoPolicy`; a request that fails the signature step never reaches the policy check.
+3a. **401 vs. 403 cannot be derived from session state.** `BaseApiRequest::failedAuthorization()` (the inherited default every other `FormRequest` in this codebase relies on) throws `UnauthorizedException` (403) or `UnauthenticatedException` (401) based solely on `Auth::check()`. That default is wrong here: a guest with a *valid* signature but a policy-denied photo must get 403 (not 401 — they proved timing legitimacy, they're just not allowed to see this), while a *logged-in* caller whose config still requires a signature they didn't supply must get 401 (not 403 — they never cleared the access-proof step at all). `GetPhotoAssetRequest` therefore overrides `failedAuthorization()`, keyed on a `$signature_check_failed` flag set during `authorize()` (true iff step 1/1a/2 failed) rather than on `Auth::check()`.
+4. **Whether a signature is *required* is derived from the existing three config keys, re-purposed from generation-time to validation-time**, via a new predicate that mirrors `UrlGenerator::shouldNotUseSignedUrl()`'s existing boolean composition (just evaluated against the incoming request's auth state instead of the outgoing link's target viewer):
+ ```php
+ function signatureRequired(?User $user, ConfigManager $cfg): bool {
+ if (!$cfg->getValueAsBool('temporary_image_link_enabled')) {
+ return false; // feature off entirely — session + PhotoPolicy is the only path
+ }
+ if ($user !== null && !$cfg->getValueAsBool('temporary_image_link_when_logged_in')) {
+ return false; // logged-in caller's session is sufficient
+ }
+ if ($user?->may_administrate === true && !$cfg->getValueAsBool('temporary_image_link_when_admin')) {
+ return false; // admin caller's session is sufficient
+ }
+ return true;
+ }
+ ```
+ A **guest** request (`$user === null`) is therefore only ever admitted via a valid `timestamp`+`mac` when `temporary_image_link_enabled` is true (the `when_logged_in`/`when_admin` exemptions only ever apply to authenticated callers) — followed, unconditionally, by the same `PhotoPolicy` check, so a valid signature never grants access beyond what a guest could already see (e.g. a public album).
+5. **Authenticated (session) requests never need `timestamp`/`mac` unless `signatureRequired()` says so for that caller** — the `api` middleware group's `StartSession`/`AuthenticateSession` (`app/Http/Kernel.php:68-79`) already makes `Auth::user()` available with no extra guard wiring.
+
+## Consequences
+
+### Positive
+- Closes a real gap versus the v2 mechanism: `PhotoPolicy` is now always enforced for this endpoint, not implicitly delegated to "does the caller possess an opaque token."
+- The MAC scheme is trivially reproducible by any client (server-language-agnostic `hash_hmac('sha256', timestamp, shared_secret)`), unlike Laravel's canonical-URL signature, which depends on exact query-param ordering/host reconstruction.
+- Reuses the exact existing config keys and TTL — no new settings-page surface, no new migration.
+- No new secret storage — reuses `APP_KEY`.
+
+### Negative
+- Because the MAC is timestamp-only (not resource-scoped), one valid `(timestamp, mac)` pair is valid for *any* `photo_id`/`size_variant` within the TTL window, not just the one it was originally minted for. This is an accepted trade-off (explicit owner instruction) — it is not a privilege-escalation risk on its own because `PhotoPolicy` is still evaluated per-request as a guest, so it only ever unlocks what an anonymous visitor could already see (e.g., a public album's photos); it does **not** unlock private/password-protected content. Documented here so the narrower scope is visible to reviewers, since it is a deliberate divergence from a per-resource capability token.
+- `signatureRequired()`'s re-purposing of generation-time config semantics as validation-time semantics is a new, not-previously-existing code path — worth explicit test coverage per config combination (see spec Branch & Scenario Matrix).
+
+## Alternatives Considered
+
+- **A (chosen) — Bespoke timestamp-only HMAC + PhotoPolicy always enforced + config-driven signature requirement.** Described above.
+- **B — Reuse `URL::temporarySignedRoute()`/`hasValidSignature()` verbatim.** Rejected: ties the v3 endpoint to Laravel's own canonical-URL reconstruction, harder for non-Laravel/external clients to reproduce, and does not naturally extend to a `photo_id`+`size_variant` path shape without also carrying `expires`/`signature` naming inconsistent with the owner's explicit `timestamp`/`mac` request.
+- **C — No new mechanism; redirect to the existing `image/{path}` signed-URL flow.** Rejected: doesn't establish a real v3-native contract (defeats the point of a v3 endpoint), and inherits `SecurePathController`'s lack of `PhotoPolicy` enforcement.
+
+## Security / Privacy Impact
+
+- MAC secret is `config('app.key')` — the same key that already backs Laravel's session/cookie encryption and the v2 signed-route mechanism; no new key management surface.
+- `hash_equals()` used for the MAC comparison (timing-safe), consistent with cryptographic best practice already implicit in Laravel's own `hasValidSignature()`.
+- Because the MAC is not resource-scoped (see Negative, above), a leaked `(timestamp, mac)` pair is a time-boxed, but not photo-scoped, guest-equivalent access token. `PhotoPolicy` remains the actual access-control boundary in every case; the signature only ever proves "requested within a legitimate time window."
+
+## Operational Impact
+
+- No new config keys — reuses `temporary_image_link_enabled`/`temporary_image_link_when_logged_in`/`temporary_image_link_when_admin`/`temporary_image_link_life_in_seconds` verbatim.
+- No new telemetry surface introduced by this ADR itself; standard Laravel request logging applies.
+
+## Links
+
+- Related spec sections: `docs/specs/4-architecture/features/056-api-v3-asset-retrieval/spec.md` (FR-056-02/03, NFR-056-01)
+- Related open questions: Q-056-01, Q-056-05 (docs/specs/4-architecture/open-questions.md)
+- Related ADRs: none (first v3-specific ADR)
diff --git a/docs/specs/6-decisions/ADR-0009-api-v3-response-shape-precedent.md b/docs/specs/6-decisions/ADR-0009-api-v3-response-shape-precedent.md
new file mode 100644
index 00000000000..c9a1219c439
--- /dev/null
+++ b/docs/specs/6-decisions/ADR-0009-api-v3-response-shape-precedent.md
@@ -0,0 +1,43 @@
+# ADR-0009: API v3 response-shape precedent — Struct-of-Arrays for collections, binary passthrough for single-item endpoints
+
+- **Status:** Accepted
+- **Date:** 2026-08-20
+- **Related features/specs:** Feature 056 (docs/specs/4-architecture/features/056-api-v3-asset-retrieval/spec.md)
+- **Related open questions:** Q-056-02
+
+## Context
+
+The owner stated that API v3's base response convention is Struct-of-Arrays (SoA — parallel-indexed arrays, e.g. `{ids: [...], titles: [...]}`) rather than v2's Array-of-Structs (AoS — an array of self-contained objects, e.g. `PaginatedPhotosResource`'s `data: [{id, title, ...}, ...]`). Feature 056's first v3 endpoint, however, retrieves exactly one binary file for one `photo_id`+`size_variant` pair — there is no collection of records in its response body for SoA vs. AoS to apply to. Because this is the endpoint that establishes v3's very first precedent, the decision of what "the SoA base convention" means for a non-collection endpoint needed to be recorded explicitly rather than left to guesswork by the next v3 feature.
+
+## Decision
+
+This endpoint returns a **pure binary passthrough**: raw file bytes with `Content-Type` set from the resolved file, no JSON envelope, no `data`/metadata wrapper — the same client contract as today's v2 `image/{path}` route. The SoA-vs-AoS response-shape principle is scoped to **future v3 endpoints that return collections** (e.g. a hypothetical v3 photo-listing endpoint); it does not apply to this or any other single-item binary-retrieval endpoint, because there is no array of records to shape either way.
+
+## Consequences
+
+### Positive
+- Keeps this endpoint's client contract simple and standard (`
`/direct byte stream), identical in shape to the existing, proven v2 file-serving pattern — no unnecessary base64 inflation or extra round-trip.
+- Establishes an explicit, documented precedent (rather than an implicit one inferred from a single example) for the next v3 feature to follow: "SoA governs collections; single-item/binary endpoints are exempt."
+
+### Negative
+- A future v3 feature could misread this endpoint as "v3 abandoned JSON responses altogether" if this ADR isn't consulted — mitigated by referencing this ADR from `docs/specs/3-reference/api-design.md`'s future "API v3" section once it exists.
+
+## Alternatives Considered
+
+- **A (chosen) — Binary passthrough; SoA scoped to collection endpoints only.** Described above.
+- **B — JSON envelope with base64-encoded data, for uniform JSON-shaped v3 responses everywhere.** Rejected: inflates payload size (~33% base64 overhead) for no benefit on a file-serving endpoint, and breaks the simple `
` browser-native consumption pattern that photo galleries rely on throughout this codebase.
+- **C — JSON metadata only (a resolved/signed URL), client makes a second request for bytes.** Rejected: adds a mandatory extra round-trip for the common case, and duplicates work the endpoint itself is meant to do (serve the file).
+
+## Security / Privacy Impact
+
+None beyond what Feature 056's spec/ADR-0008 already cover (this ADR is about response shape only, not access control).
+
+## Operational Impact
+
+- No caching/CDN behavior change versus v2's existing file-serving pattern — standard HTTP file response semantics apply.
+
+## Links
+
+- Related spec sections: `docs/specs/4-architecture/features/056-api-v3-asset-retrieval/spec.md` (FR-056-02, Non-Goals)
+- Related open questions: Q-056-02 (docs/specs/4-architecture/open-questions.md)
+- Related ADRs: ADR-0008 (this endpoint's authorization/signing model)
diff --git a/phpunit.ci.xml b/phpunit.ci.xml
index a564cdb5856..3f2c6799abf 100644
--- a/phpunit.ci.xml
+++ b/phpunit.ci.xml
@@ -17,6 +17,10 @@
./tests/Feature_v2/Base/BaseApiWithDataTest.php
./tests/Feature_v2/ImageHandlers/BaseImageHandler.php
+
+ ./tests/Feature_v3
+ ./tests/Feature_v3/Base/BaseApiWithDataTest.php
+
./tests/Install
diff --git a/phpunit.xml b/phpunit.xml
index f8e058d71ca..0ce16adf2af 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -17,6 +17,10 @@
./tests/Feature_v2/Base/BaseApiWithDataTest.php
./tests/Feature_v2/ImageHandlers/BaseImageHandler.php
+
+ ./tests/Feature_v3
+ ./tests/Feature_v3/Base/BaseApiWithDataTest.php
+
./tests/Install
diff --git a/routes/api_v3.php b/routes/api_v3.php
new file mode 100644
index 00000000000..c2007a08561
--- /dev/null
+++ b/routes/api_v3.php
@@ -0,0 +1,31 @@
+withoutMiddleware(['accept_content_type:json', 'content_type:json'])
+ ->middleware('json_errors');
diff --git a/tests/Feature_v3/Base/BaseApiWithDataTest.php b/tests/Feature_v3/Base/BaseApiWithDataTest.php
new file mode 100644
index 00000000000..5b827ecfbf5
--- /dev/null
+++ b/tests/Feature_v3/Base/BaseApiWithDataTest.php
@@ -0,0 +1,55 @@
+ $headers
+ *
+ * @return TestResponse
+ */
+ public function getV3(string $uri, array $headers = []): TestResponse
+ {
+ return $this->withCredentials()->get(self::API_V3_PREFIX . ltrim($uri, '/'), $headers);
+ }
+}
diff --git a/tests/Feature_v3/Photo/PhotoAssetV3Test.php b/tests/Feature_v3/Photo/PhotoAssetV3Test.php
new file mode 100644
index 00000000000..07649cd97ce
--- /dev/null
+++ b/tests/Feature_v3/Photo/PhotoAssetV3Test.php
@@ -0,0 +1,433 @@
+value);
+ }
+
+ private function thumbVariantOf(Photo $photo): SizeVariant
+ {
+ return SizeVariant::query()
+ ->where('photo_id', '=', $photo->id)
+ ->where('type', '=', SizeVariantType::THUMB)
+ ->firstOrFail();
+ }
+
+ private function smallVariantOf(Photo $photo): SizeVariant
+ {
+ return SizeVariant::query()
+ ->where('photo_id', '=', $photo->id)
+ ->where('type', '=', SizeVariantType::SMALL)
+ ->firstOrFail();
+ }
+
+ private function putBytes(SizeVariant $variant, string $bytes = 'thumb-bytes'): void
+ {
+ Storage::disk(StorageDiskType::LOCAL->value)->put($variant->short_path, $bytes);
+ }
+
+ /**
+ * @return array
+ */
+ private function signedHeaders(int $timestamp): array
+ {
+ $signer = new TemporaryLinkSigner();
+
+ return [
+ 'X-Timestamp' => (string) $timestamp,
+ 'X-Mac' => $signer->sign($timestamp),
+ ];
+ }
+
+ /**
+ * S-056-01: Authenticated owner requests own photo's THUMB variant, no
+ * signature headers, signatureRequired() false → 200, correct bytes
+ * streamed.
+ */
+ public function testAuthenticatedOwnerRetrievesThumb(): void
+ {
+ $variant = $this->thumbVariantOf($this->photo1);
+ $this->putBytes($variant);
+
+ $response = $this->actingAs($this->userMayUpload1)->getV3("Asset/{$this->album1->id}/{$this->photo1->id}/thumb");
+
+ $response->assertOk();
+ self::assertSame('thumb-bytes', $response->streamedContent());
+ }
+
+ /**
+ * S-056-02: Guest requests a public album's photo THUMB variant with a
+ * valid, unexpired signature, temporary_image_link_enabled=true → 200.
+ */
+ public function testGuestWithValidSignatureOnPublicAlbumSucceeds(): void
+ {
+ Configs::set('temporary_image_link_enabled', '1');
+ $variant = $this->thumbVariantOf($this->photo4);
+ $this->putBytes($variant);
+
+ $response = $this->getV3("Asset/{$this->album4->id}/{$this->photo4->id}/thumb", $this->signedHeaders(now()->timestamp));
+
+ $response->assertOk();
+ }
+
+ /**
+ * S-056-03: Guest requests the same as S-056-02 but the album is not
+ * public → 403, despite a validly-signed link.
+ */
+ public function testGuestWithValidSignatureButPrivateAlbumIsForbidden(): void
+ {
+ Configs::set('temporary_image_link_enabled', '1');
+ $variant = $this->thumbVariantOf($this->photo1);
+ $this->putBytes($variant);
+
+ $response = $this->getV3("Asset/{$this->album1->id}/{$this->photo1->id}/thumb", $this->signedHeaders(now()->timestamp));
+
+ $response->assertForbidden();
+ }
+
+ /**
+ * S-056-04: Guest requests with no headers at all,
+ * temporary_image_link_enabled=true → 401.
+ */
+ public function testGuestWithNoHeadersIsUnauthorized(): void
+ {
+ Configs::set('temporary_image_link_enabled', '1');
+ $variant = $this->thumbVariantOf($this->photo4);
+ $this->putBytes($variant);
+
+ $response = $this->getV3("Asset/{$this->album4->id}/{$this->photo4->id}/thumb");
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * S-056-05: temporary_image_link_enabled=false → 401 regardless of
+ * (validly-signed) headers.
+ */
+ public function testDisabledFeatureIsUnauthorizedForGuest(): void
+ {
+ Configs::set('temporary_image_link_enabled', '0');
+ $variant = $this->thumbVariantOf($this->photo4);
+ $this->putBytes($variant);
+
+ $response = $this->getV3("Asset/{$this->album4->id}/{$this->photo4->id}/thumb", $this->signedHeaders(now()->timestamp));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * S-056-06: X-Mac that doesn't match the HMAC of X-Timestamp → 401.
+ */
+ public function testTamperedMacIsUnauthorized(): void
+ {
+ Configs::set('temporary_image_link_enabled', '1');
+ $variant = $this->thumbVariantOf($this->photo4);
+ $this->putBytes($variant);
+
+ $headers = $this->signedHeaders(now()->timestamp);
+ $headers['X-Mac'] = substr($headers['X-Mac'], 0, -1) . (str_ends_with($headers['X-Mac'], 'a') ? 'b' : 'a');
+
+ $response = $this->getV3("Asset/{$this->album4->id}/{$this->photo4->id}/thumb", $headers);
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * S-056-07: X-Timestamp older than
+ * now() - temporary_image_link_life_in_seconds → 401 (expired).
+ */
+ public function testExpiredTimestampIsUnauthorized(): void
+ {
+ Configs::set('temporary_image_link_enabled', '1');
+ $life = resolve(ConfigManager::class)->getValueAsInt('temporary_image_link_life_in_seconds');
+ $variant = $this->thumbVariantOf($this->photo4);
+ $this->putBytes($variant);
+
+ $response = $this->getV3("Asset/{$this->album4->id}/{$this->photo4->id}/thumb", $this->signedHeaders(now()->timestamp - $life - 60));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * S-056-08: X-Timestamp in the future (> now()) → 401.
+ */
+ public function testFutureTimestampIsUnauthorized(): void
+ {
+ Configs::set('temporary_image_link_enabled', '1');
+ $variant = $this->thumbVariantOf($this->photo4);
+ $this->putBytes($variant);
+
+ $response = $this->getV3("Asset/{$this->album4->id}/{$this->photo4->id}/thumb", $this->signedHeaders(now()->timestamp + 60));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * S-056-09: Only one of X-Timestamp/X-Mac present → 422.
+ */
+ public function testOnlyOneHeaderPresentIsUnprocessable(): void
+ {
+ Configs::set('temporary_image_link_enabled', '1');
+ $variant = $this->thumbVariantOf($this->photo4);
+ $this->putBytes($variant);
+
+ $response = $this->getV3("Asset/{$this->album4->id}/{$this->photo4->id}/thumb", ['X-Timestamp' => (string) now()->timestamp]);
+
+ $response->assertUnprocessable();
+ }
+
+ /**
+ * S-056-10: Authenticated non-admin user,
+ * temporary_image_link_when_logged_in=true and no headers supplied →
+ * 401, even though a session exists.
+ */
+ public function testLoggedInUserStillRequiredToSignWithoutHeadersIsUnauthorized(): void
+ {
+ Configs::set('temporary_image_link_enabled', '1');
+ Configs::set('temporary_image_link_when_logged_in', '1');
+ $variant = $this->thumbVariantOf($this->photo1);
+ $this->putBytes($variant);
+
+ $response = $this->actingAs($this->userMayUpload1)->getV3("Asset/{$this->album1->id}/{$this->photo1->id}/thumb");
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * S-056-11: Authenticated admin, temporary_image_link_when_admin=false,
+ * no headers → 200 (admin session alone suffices per config).
+ */
+ public function testAdminExemptedFromSigningReturnsOk(): void
+ {
+ Configs::set('temporary_image_link_enabled', '1');
+ Configs::set('temporary_image_link_when_admin', '0');
+ $variant = $this->thumbVariantOf($this->photo1);
+ $this->putBytes($variant);
+
+ $response = $this->actingAs($this->admin)->getV3("Asset/{$this->album1->id}/{$this->photo1->id}/thumb");
+
+ $response->assertOk();
+ }
+
+ /**
+ * S-056-12: Request for size_variant=PLACEHOLDER on a photo with no
+ * stored PLACEHOLDER variant → 404 (photo1's factory-created variants
+ * never include PLACEHOLDER).
+ */
+ public function testMissingSizeVariantRowReturnsNotFound(): void
+ {
+ $response = $this->actingAs($this->userMayUpload1)->getV3("Asset/{$this->album1->id}/{$this->photo1->id}/placeholder");
+
+ $response->assertNotFound();
+ }
+
+ /**
+ * S-056-13: Unrecognized size_variant token → 422.
+ */
+ public function testUnrecognizedSizeVariantTokenReturnsUnprocessable(): void
+ {
+ $response = $this->actingAs($this->userMayUpload1)->getV3("Asset/{$this->album1->id}/{$this->photo1->id}/huge");
+
+ $response->assertUnprocessable();
+ }
+
+ /**
+ * S-056-14: Unknown photo_id → 404.
+ */
+ public function testUnknownPhotoIdReturnsNotFound(): void
+ {
+ $response = $this->actingAs($this->userMayUpload1)->getV3("Asset/{$this->album1->id}/000000000000000000000000/thumb");
+
+ $response->assertNotFound();
+ }
+
+ /**
+ * S-056-15: Authorized request for a size_variant stored on the S3 disk
+ * → 302 redirect to a native S3 temporary URL, no bytes proxied through
+ * Lychee.
+ */
+ public function testS3BackedVariantRedirectsToTemporaryUrl(): void
+ {
+ $variant = $this->smallVariantOf($this->photo1);
+ $variant->storage_disk = StorageDiskType::S3;
+ $variant->save();
+
+ $aws_adapter = \Mockery::mock(AwsS3V3Adapter::class);
+ $s3_disk = \Mockery::mock(FilesystemAdapter::class, function (MockInterface $mock) use ($aws_adapter, $variant): void {
+ $mock->shouldReceive('getAdapter')->andReturn($aws_adapter);
+ $mock->shouldReceive('temporaryUrl')
+ ->once()
+ ->with($variant->short_path, \Mockery::any())
+ ->andReturn('https://example-bucket.s3.amazonaws.com/signed-url');
+ // Resolving album1 also eagerly loads its cover/thumb (Album::$with),
+ // which may compute a URL for whichever photo/variant was picked as
+ // the album's thumbnail — incidental to what this test asserts, but
+ // still needs a stub since it can land on the S3 disk too.
+ $mock->shouldReceive('url')->andReturn('https://example-bucket.s3.amazonaws.com/incidental-thumb-url');
+ });
+
+ // Only the 's3' disk is faked; every other disk name (e.g. the
+ // 'images' local disk, already faked in setUp()) must still resolve
+ // through the real manager, so we capture it before mocking the
+ // facade and delegate non-S3 calls to it directly.
+ $real_manager = Storage::getFacadeRoot();
+ Storage::partialMock();
+ Storage::shouldReceive('disk')->andReturnUsing(
+ fn (?string $name = null) => $name === StorageDiskType::S3->value ? $s3_disk : $real_manager->disk($name)
+ );
+
+ $response = $this->actingAs($this->userMayUpload1)->getV3("Asset/{$this->album1->id}/{$this->photo1->id}/small");
+
+ $response->assertRedirect('https://example-bucket.s3.amazonaws.com/signed-url');
+ }
+
+ /**
+ * S-056-16: Authorized request for size_variant=SMALL where the
+ * requesting viewer meets watermark conditions → served file is the
+ * watermarked path, not the plain stored path.
+ */
+ public function testWatermarkedPathServedWhenConditionsMet(): void
+ {
+ Configs::set('watermark_enabled', '1');
+ Configs::set('watermark_logged_in_users_enabled', '1');
+
+ $variant = $this->smallVariantOf($this->photo1);
+ $variant->short_path_watermarked = 'watermarked/' . $variant->short_path;
+ $variant->save();
+
+ Storage::disk(StorageDiskType::LOCAL->value)->put($variant->short_path, 'plain-bytes');
+ Storage::disk(StorageDiskType::LOCAL->value)->put($variant->short_path_watermarked, 'watermarked-bytes');
+
+ $response = $this->actingAs($this->userMayUpload1)->getV3("Asset/{$this->album1->id}/{$this->photo1->id}/small");
+
+ $response->assertOk();
+ self::assertSame('watermarked-bytes', $response->streamedContent());
+ }
+
+ /**
+ * NFR-056-04: Full signatureRequired() config/caller-state matrix.
+ *
+ * Enumerates all 2×2×2 combinations of temporary_image_link_enabled /
+ * _when_logged_in / _when_admin, crossed with (guest / logged-in
+ * non-admin / admin) caller state, all requesting without any
+ * X-Timestamp/X-Mac headers. Truth table (several combinations collapse
+ * to the same outcome):
+ *
+ * - Guest: always 401, regardless of any config flag — guests are only
+ * ever authorized via a valid temporary link (FR-056-05), and no
+ * headers means that link can never be valid.
+ * - Logged-in non-admin: 401 iff (enabled && when_logged_in), else 200
+ * (session alone suffices). `when_admin` is irrelevant to this caller.
+ * - Admin: 401 iff (enabled && when_admin), else 200 (session alone
+ * suffices, admin bypasses AlbumPolicy too). `when_logged_in` is
+ * irrelevant to this caller.
+ */
+ public function testSignatureRequiredConfigCallerStateMatrix(): void
+ {
+ $variant1 = $this->thumbVariantOf($this->photo1);
+ $this->putBytes($variant1);
+ $variant4 = $this->thumbVariantOf($this->photo4);
+ $this->putBytes($variant4);
+
+ foreach ([false, true] as $enabled) {
+ foreach ([false, true] as $when_logged_in) {
+ foreach ([false, true] as $when_admin) {
+ Configs::set('temporary_image_link_enabled', $enabled ? '1' : '0');
+ Configs::set('temporary_image_link_when_logged_in', $when_logged_in ? '1' : '0');
+ Configs::set('temporary_image_link_when_admin', $when_admin ? '1' : '0');
+ $case = 'enabled=' . var_export($enabled, true) . ' when_logged_in=' . var_export($when_logged_in, true) . ' when_admin=' . var_export($when_admin, true);
+
+ Auth::logout();
+ $guest_response = $this->getV3("Asset/{$this->album4->id}/{$this->photo4->id}/thumb");
+ self::assertSame(401, $guest_response->getStatusCode(), "guest, {$case}");
+
+ $non_admin_response = $this->actingAs($this->userMayUpload1)->getV3("Asset/{$this->album1->id}/{$this->photo1->id}/thumb");
+ $expected_non_admin = ($enabled && $when_logged_in) ? 401 : 200;
+ self::assertSame($expected_non_admin, $non_admin_response->getStatusCode(), "non-admin, {$case}");
+
+ $admin_response = $this->actingAs($this->admin)->getV3("Asset/{$this->album1->id}/{$this->photo1->id}/thumb");
+ $expected_admin = ($enabled && $when_admin) ? 401 : 200;
+ self::assertSame($expected_admin, $admin_response->getStatusCode(), "admin, {$case}");
+ }
+ }
+ }
+ }
+
+ /**
+ * album_id must actually contain photo_id: a real album/photo pair that
+ * exist independently, but where the photo isn't cataloged in the given
+ * album (nor is it that album's cover), is forbidden even though both
+ * IDs individually resolve and the caller can access the album itself.
+ */
+ public function testPhotoNotInGivenAlbumIsForbidden(): void
+ {
+ $variant = $this->thumbVariantOf($this->photo2);
+ $this->putBytes($variant);
+
+ $response = $this->actingAs($this->userMayUpload1)->getV3("Asset/{$this->album1->id}/{$this->photo2->id}/thumb");
+
+ $response->assertForbidden();
+ }
+
+ /**
+ * Unknown album_id → 404, same as an unknown photo_id.
+ */
+ public function testUnknownAlbumIdReturnsNotFound(): void
+ {
+ $response = $this->actingAs($this->userMayUpload1)->getV3('Asset/000000000000000000000000/' . $this->photo1->id . '/thumb');
+
+ $response->assertNotFound();
+ }
+}
diff --git a/tests/ImageProcessing/Import/ImportFromServerBrowseTest.php b/tests/ImageProcessing/Import/ImportFromServerBrowseTest.php
index 9397a45f5d3..c1ea3f1942a 100644
--- a/tests/ImageProcessing/Import/ImportFromServerBrowseTest.php
+++ b/tests/ImageProcessing/Import/ImportFromServerBrowseTest.php
@@ -38,6 +38,6 @@ public function testBrowseEndpointAsOwner(): void
// We have to sort in order to have a predictable order for the test.
// The order returned by the filesystem is not predictable.
sort($content);
- self::assertEquals(['..', 'AssistedVision', 'Constants', 'Feature_v2', 'Fixtures', 'ImageProcessing', 'Install', 'Precomputing', 'Samples', 'Traits', 'Unit', 'Webshop', 'docker'], $content);
+ self::assertEquals(['..', 'AssistedVision', 'Constants', 'Feature_v2', 'Feature_v3', 'Fixtures', 'ImageProcessing', 'Install', 'Precomputing', 'Samples', 'Traits', 'Unit', 'Webshop', 'docker'], $content);
}
}
diff --git a/tests/Unit/Services/TemporaryLinkSignerTest.php b/tests/Unit/Services/TemporaryLinkSignerTest.php
new file mode 100644
index 00000000000..a4b9b7d09f7
--- /dev/null
+++ b/tests/Unit/Services/TemporaryLinkSignerTest.php
@@ -0,0 +1,61 @@
+sign($timestamp);
+
+ self::assertTrue($signer->verify($timestamp, $mac));
+ }
+
+ public function testTamperedMacFails(): void
+ {
+ $signer = new TemporaryLinkSigner();
+ $timestamp = 1_700_000_000;
+
+ $mac = $signer->sign($timestamp);
+ $tampered = substr($mac, 0, -1) . (str_ends_with($mac, 'a') ? 'b' : 'a');
+
+ self::assertFalse($signer->verify($timestamp, $tampered));
+ }
+
+ public function testMacForDifferentTimestampFails(): void
+ {
+ $signer = new TemporaryLinkSigner();
+
+ $mac = $signer->sign(1_700_000_000);
+
+ self::assertFalse($signer->verify(1_700_000_001, $mac));
+ }
+}