feat(posts): GREEN cycle 2 — a post's media follows the post master
Task 6 GREEN cycle 2. Implementation only; no test file touched. All eight blob pins green, and `make ci` passes at 5093 tests with 0 skips. WRITE SIDE — the blob goes where the record went blobs.Service.FetchImageForURL UploadBlobFromURL's first half on its own: the bounded fetch, the image/* allowlist and the 6MB cap, touching no PDS. UploadBlobFromURL now calls it, so the community path is unchanged and the guard has exactly one implementation. SPLIT RATHER THAN COPIED because the uploader changed, not the guard. An author authenticates with a DPoP-signed OAuth session, which cannot be expressed as the bearer token blobs.BlobOwner carries, so the caller does the upload leg itself — and a second copy of a guard on an attacker-influenced fetch is a guard that will drift. AuthorRepo.UploadBlob the author's own storage, satisfied by pds.Client unchanged. A blob ref names a CID and not a repository, so a thumbnail left behind in the community's storage produces a record that looks identical to a correct one and resolves for nobody, is garbage-collectable by a repo referencing it nowhere, and is not the author's to release when they delete the post. postService.uploadThumbnail fetch-and-validate, THEN upload, and only if the first passed. Discarding a bad blob after uploading it would pay the entire cost of not having a cap and merely decline to show the result. One 15s budget covers both legs, as the single call it replaces did. Both producers move together: the unfurled thumbnail and the trusted aggregator's supplied ThumbnailURL. READ SIDE — the owner is a property of the record, not of the deployment blobOwnerOf chooses the repository per view from the URI's COLLECTION, which is the only honest signal: it says which repo the record is in, which is exactly the question. postv2 resolves against the author, deprecated community.post against the community, and one table holding both kinds is why the choice cannot be a constant. Getting it wrong does not crash — it builds a well-formed URL naming a repo that has never held the blob, which is a broken image for every reader and a silent success on the server. FAILS CLOSED: a postv2 view with no author is left unprojected rather than falling back to the community, because the fallback is a confident claim about a repo that has never held the blob while an unprojected ref is visibly wrong to the client. AuthorView.PDSURL carried out of the repository scan. The column has always been SELECTed — it hydrates the author's avatar — and always dropped here, which would have left correct owner selection addressing every author-owned post's media to an empty host. NOT DONE, AND WHY The community-token refresh stays on the create path. It genuinely has no consumer left — both writes that used it have moved, and the refreshed value is not read below — but service_admission_test.go's token-refresh case still requires a failed refresh to fail the submission, and that test is not mine to retire. Verified by deletion: the test fails on exactly that assertion. The call site now says so, including the cost of leaving it — a community whose stored refresh token has rotted blocks its authors from posting for a reason no longer present in the code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>