Operator-uploaded avatar served from the identity's URL #15

Open
opened 2026-05-12 22:57:06 +02:00 by arne · 1 comment
Owner

Problem Statement

The actor doc has an avatar field (SPEC §5.2, render-only metadata), but
posta-server today gives the operator no way to actually host an avatar
image. The identity row can carry an avatar URL string — set via
PATCH /api/v1/identity — but the operator has to host the image
themselves on an unrelated CDN or web server, then paste the URL in.

This is a blocker for the iOS app: in-app avatar editing is a baseline
user-facing feature, and the app has no business uploading anywhere
except the user's own posta-server. Without server-hosted avatars the
iOS UX collapses to "upload your avatar to S3 and paste the URL into
Settings" — not viable.

Solution

Add a per-identity avatar upload to the REST surface, store the image on
disk under the per-identity data directory, and serve it as a static
file from the same host the identity's actor doc lives on. The avatar's
URL is content-addressed (/avatar-<sha256-hex>.<ext>) and is
written into the identity.avatar column so the actor doc's avatar
field renders it automatically on next GET. Content-addressed naming
lets us ship the avatar as immutable (max-age=1y); when Alice uploads
a new avatar the URL itself changes, peers fetch the new URL after their
actor-doc cache refreshes (≤5 min), and the old URL becomes unreferenced.

End-to-end:

  • iOS / TUI sends PUT /api/v1/identity/avatar with raw image bytes and a
    Content-Type header. Server sniffs magic bytes, validates against
    the declared type, rejects mismatches and unsupported types with
    400. On success: hashes the bytes, writes the file atomically as
    avatar-<hash>.<ext> under the per-identity directory, deletes any
    prior avatar-* file, sets the identity row's avatar to
    <canonical-URL>/avatar-<hash>.<ext>.
  • Peers fetch the actor doc (≤5 min cached per §5.3), see the new
    avatar URL, and GET it directly from the daemon. The daemon serves
    it public (no auth), with Cache-Control: max-age=31536000, immutable
    and ETag. Clients cache it effectively forever; propagation of a
    new avatar happens automatically because the URL changes.
  • DELETE /api/v1/identity/avatar removes the file and clears the column.

User Stories

  1. As an iOS user, I want to tap my profile picture and pick a new image from the photo library, so my avatar shows up across all my contacts within minutes.
  2. As an iOS user, I want my uploaded avatar served from my own posta-server (not S3, not an external CDN), so I don't have to manage a second service to use posta.
  3. As an iOS user uploading a HEIC photo from camera roll, I want the iOS app to convert to JPEG before sending — the server will reject HEIC — and the upload to succeed.
  4. As an iOS user, I want the upload to fail visibly with a specific reason (file too large, unsupported type) so I can correct the input instead of staring at a spinner.
  5. As an iOS user, I want a Remove photo action that deletes my server-side avatar without replacing it.
  6. As a TUI user, I want the same upload affordance once the TUI grows beyond MVP — no separate ops workflow.
  7. As an operator, I want avatar files to live under the same per-identity directory as keys and the SQLite DB (/var/lib/posta/<slug>/), so backup and restore of one identity is still one rsync.
  8. As an operator viewing the data directory, I want avatars on disk with the original image format intact (no re-encoding), so I can file avatar-*.png and have it show as a real PNG.
  9. As an operator with a misbehaving avatar route, I want a clear failure mode (404 no-avatar-set vs 500 read-failed) so I can tell whether the file is missing or the disk is unhappy.
  10. As a contact of Alice, I want my client to render Alice's new avatar within roughly the actor-doc cache TTL (≤5 min) of her uploading it — the content-addressed URL changes, so peers refetch automatically, no avatar-cache staleness window on top.
  11. As a contact of Alice, I want her avatar URL (once embedded in the actor doc) to be cached effectively forever by my browser / app, so I'm not paying network cost on every render.
  12. As a peer scraping the public web, I want avatar URLs to be unauthenticated (same as actor docs), so the rendering layer works without bearer tokens.
  13. As the daemon, I want avatar uploads larger than the size cap (1 MiB) to be rejected at the body-read stage with 413 payload-too-large, not buffered in memory.
  14. As the daemon, I want Content-Type validation to be magic-byte-based, not trust the header, so a Content-Type: image/png body that's actually a shell script can't get written to disk under a .png name.
  15. As an operator changing my avatar, I want the previous avatar-* file cleaned up on upload so the per-identity directory doesn't accumulate stale files indefinitely.
  16. As an operator, I want the upload to be atomic — a crash mid-write must leave either the old avatar or the new one fully on disk, never a half-written file at the served path.
  17. As an operator running the daemon, I want the avatar feature to require zero new configuration: it should Just Work given the existing per-identity data directory and the existing bearer-token auth.
  18. As an iOS user pairing a new device with an invite link, I want my server-hosted avatar to remain valid across pairings — it's identity-scoped, not device-scoped.
  19. As an iOS user revoking a stolen token, I don't want the avatar deleted — the avatar belongs to the identity, not to any particular token.
  20. As a developer reading the actor doc, I want the avatar field to be either a fully-qualified URL or absent — never a relative path — so spec-compliant verifiers can consume it without rewriting.

Implementation Decisions

Module A — image sniff (internal/imagesniff, new, deep)

A pure module that takes raw bytes (or an io.Reader-with-limit) and the
declared Content-Type, and returns either the validated content type +
canonical file extension or a typed rejection. v0 accepts only PNG and
JPEG; WEBP and HEIC are out of scope. Magic-byte detection is
authoritative — the request Content-Type is cross-checked but the
sniffed type wins, so a mismatch is rejected as content-type-mismatch
rather than silently saved under the wrong extension.

Rejection categories:

  • unsupported-media-type — declared type isn't image/png or image/jpeg
  • content-type-mismatch — bytes don't match the declared type
  • not-an-image — magic bytes don't match any supported type
  • truncated — body shorter than the magic-byte header

Module B — avatar storage (internal/avatarstore or in internal/store, new)

Filesystem-backed per-identity storage with content-addressed filenames.
Public surface (TBD names):

  • Write the new avatar atomically: hash the bytes (SHA-256), write to
    avatar-<hash>.<ext>.tmp, os.WriteFile, os.Rename to
    avatar-<hash>.<ext>. Then delete any other avatar-* files in the
    directory so each identity has at most one avatar on disk.
  • Locate the current avatar file (returns path + extension + hash). The
    hash is recoverable from the filename; no sibling metadata file.
  • Remove the avatar (delete the file; tolerate "already absent").

The module owns the directory contract: callers pass the per-identity
data dir, the module handles the rest. Idempotency: uploading the same
bytes twice produces the same filename and is a no-op (the rename
succeeds atomically over the identical file). ETag is the hex digest
in the filename, so it's free to compute.

Module C — API avatar handlers (internal/api)

Two new routes on the authed mux:

  • PUT /api/v1/identity/avatar — body is raw image bytes. Body cap is
    enforced via http.MaxBytesReader at 1 MiB so oversized uploads
    fail at read time with 413 payload-too-large. Sniff via Module A,
    store via Module B, then UPDATE identity SET avatar = ? with the
    canonical content-addressed avatar URL. Publish an identity-changed
    SSE event (same as PATCH /api/v1/identity does today). Returns
    200 with a small JSON body carrying the new avatar URL and the
    hash (also derivable from the URL).
  • DELETE /api/v1/identity/avatar — Module B removes the file,
    UPDATE identity SET avatar = '' clears the column. Publish
    identity-changed. Returns 204.

The Server struct gains an AvatarStore field, wired from
internal/daemon/runner.go (per-identity data dir → per-identity store
instance).

Module D — per-identity GET route

The per-identity mux (built in daemon/runner.go) currently mounts
/api/v1/, /setup, and / (the inbox/actor handler). Add a new
public route that handles GET /avatar-<hash>.{png,jpg} (pattern, not
fixed path). The handler asks the AvatarStore for the current file; if
the requested filename doesn't match the on-disk file (e.g. a stale
URL after a re-upload) or no avatar is set, returns 404 no-avatar
with a small text body. Otherwise serves the file bytes with
Content-Type: image/png|jpeg, Cache-Control: max-age=31536000, immutable, ETag: "<hash>". Honours If-None-Match for 304 Not Modified (cheap for clients with HTTP caching disabled).

Auth bypass: the avatar route joins /api/v1/invite/* and /setup in
the public-prefix carve-out (PublicAuthBypassPrefixes in internal/api).

Module E — PATCH /api/v1/identity behavior

The existing PATCH endpoint accepts name, about, avatar. v0
decision: drop avatar from the PATCH allowed fields. The only way to
set the avatar URL is via the new PUT endpoint, which guarantees the
URL points at a real file the daemon serves AND uses a content-addressed
shape that immutable caching depends on. Operators with external avatar
URLs (CDN, etc.) lose that path in v0; if reinstated later it should be
a separate external-avatar-url field that PATCH accepts, not a
re-overload of the same field.

Actor doc wiring

No code change needed. dynamicActor() in internal/inbox/inbox.go
already reads identity.avatar on every GET; once the PUT handler
writes the URL into that column, the actor doc reflects it
automatically. Peers see the change within their actor-doc cache TTL
(≤5 min, §5.3).

Avatar URL shape

<canonical-actor-URL>/avatar-<sha256-hex>.<ext>. For
https://arne.posta.no and SHA-256 prefix abc123... of a PNG, that's
https://arne.posta.no/avatar-abc123....png. For multi-tenant
path-based hosts (https://posta.no/u/arne) it's
https://posta.no/u/arne/avatar-abc123....png. The mux must route
both shapes; existing per-identity dispatch already keys on Host, and
pathOf(opts.URL) in the inbox covers the path prefix.

The hash is the full 64-hex-char SHA-256. Truncation (e.g. first 16
chars) would shorten URLs but trade off integrity; v0 ships the full
digest.

Cache and propagation

Avatar URL: Cache-Control: max-age=31536000, immutable + ETag.
Because the URL is content-addressed, the file at that URL never
changes — clients can cache forever, no revalidation needed.
Propagation of a new avatar happens via the actor doc: the URL inside
the actor doc changes, peers see the new URL after their ≤5 min
actor-doc cache (§5.3) refreshes, fetch the new avatar with a fresh
URL that bypasses their HTTP cache entirely.

The actor-doc cache itself remains capped at 5 min per §5.3 — no
change required.

Size cap

1 MiB. Covers the photographic-PNG case (a portrait shot at 512×512
can run 300–800 KB as PNG), JPEG of any reasonable resolution, and
leaves headroom for slightly larger uploads from clients that don't
aggressively downscale. iOS guidance (Further Notes) recommends
JPEG for photographs to stay well under the cap; the 1 MiB ceiling is
the server's safety net, not the expected upload size.

Testing Decisions

What makes a good test here. Tests exercise external behavior at
module seams. Module A's contract is "bytes → (extension, error)"; no
HTTP, no disk. Module B's contract is "directory + payload → atomic
file replace with content-addressed name"; tested against a
t.TempDir(). Modules C and D test through the HTTP surface (existing
newRig pattern in internal/api/api_test.go).

Modules under test:

  • Module A — image sniff. Magic-byte fixtures for valid PNG (8-byte
    signature \x89PNG\r\n\x1a\n) and valid JPEG (\xff\xd8\xff\xe0,
    \xff\xd8\xff\xe1, \xff\xd8\xff\xdb). Rejection cases: HEIC
    (ftyp brand), GIF (GIF87a/GIF89a), WEBP (RIFF/WEBP),
    truncated PNG (under 8 bytes), Content-Type mismatch (PNG bytes
    declared as image/jpeg). Table-driven, no I/O.
  • Module B — avatar storage. t.TempDir() rig. Write PNG → read
    returns the same bytes + extension + hash. Write JPEG over the
    existing PNG → only the new avatar-<newhash>.jpg survives. Write
    identical PNG twice → same filename, no-op. Remove → file gone,
    second remove is a no-op. Crash-simulation by failing the Rename
    → assert the prior file is untouched.
  • Module C — API handlers. Against newRig (existing test rig in
    internal/api/api_test.go). PUT with a valid PNG returns 200,
    identity.avatar column carries the canonical content-addressed URL,
    response body's hash matches the URL. PUT with a JPEG declared as
    PNG returns 400 with error: content-type-mismatch. PUT exceeding
    1 MiB returns 413. DELETE returns 204 and clears the column.
    SSE subscriber receives one identity-changed event per mutation.
  • Module D — GET route. Behaviour test wires a runner-like mux with
    a pre-seeded AvatarStore. GET on the current filename returns 200
    with correct Content-Type and Cache-Control: max-age=31536000, immutable. GET with matching If-None-Match returns 304. GET on
    a stale (no-longer-current) filename returns 404. GET with no
    avatar set returns 404. Auth bypass works (no bearer token).

Prior art for the rig: internal/api/api_test.go newRig already wires
in-memory SQLite + httptest.NewServer; same pattern for C and D. Module
A is pure and tested in isolation. Module B uses t.TempDir() with no
other dependencies.

Out of Scope

  • WEBP and HEIC support. Defer; iOS clients can convert HEIC to JPEG
    before upload, and WEBP/AVIF adoption can wait until the spec library
    has a chance to weigh in on the canonical set of accepted types.
  • Server-side image resizing or re-encoding. The server stores
    whatever bytes the client sent. Clients are responsible for sending
    appropriately-sized images. Defer until usage shows the bandwidth
    cost matters.
  • Multiple resolutions (avatar@2x.png for retina). Defer; clients
    resize at render time.
  • Animated avatars (GIF, animated WEBP). Not in v0.
  • Avatar metadata in the actor doc (dimensions, file size). Defer;
    consumers fetch and inspect locally.
  • External avatar URLs via PATCH. v0 dropping the field is a
    scope-cut. If operators need to host elsewhere later, add an explicit
    external-avatar-url field to PATCH at that point.
  • Migration of existing identities with PATCH-set external avatar
    URLs.
    None exist in production at the time of writing (the field
    has been wired but never used by clients); any test data with an
    externally-pointed identity.avatar value remains as-is and the
    daemon will keep serving the actor doc with that URL until the
    operator overwrites it via PUT or DELETE.

Further Notes

  • iOS upload size from camera roll can be tens of MB. The iOS app
    SHOULD downscale to ~512×512 and re-encode as JPEG before upload.
    PNG is the wrong format for photographic content — a portrait shot
    saved as PNG at 512×512 typically lands 300–800 KB, vs ~30–120 KB
    as JPEG quality-80. PNG is appropriate for logo/graphic-style
    avatars (flat colors, transparency). The server's 1 MiB cap is the
    safety net, not the expected upload size.
  • The avatar URL embedded in the actor doc must be the §4.1-canonical
    URL (PRD #10). The identity's URL is already canonical at runtime
    (per #12); appending /avatar-<hash>.<ext> preserves canonicality
    because the lowercase hex hash + lowercase .png / .jpg
    path-segment satisfies §4.1 (no userinfo, no query, no fragment, no
    dot-segments).
  • The actor-doc-cache cap (Cache-Control: max-age=300) on the actor
    doc itself stays unchanged — the avatar's separate immutable caching
    works in concert with it: actor doc TTL bounds how fast a new avatar
    URL propagates; immutable caching means once propagated the file is
    fetched at most once per client.
  • Coordination with iOS: this PRD's PUT endpoint is the minimum
    surface iOS needs. The iOS app's avatar-edit screen depends on this
    shipping; track that dependency on the iOS-side issue tracker.
## Problem Statement The actor doc has an `avatar` field (SPEC §5.2, render-only metadata), but posta-server today gives the operator no way to actually host an avatar image. The identity row can carry an `avatar` URL string — set via `PATCH /api/v1/identity` — but the operator has to host the image themselves on an unrelated CDN or web server, then paste the URL in. This is a blocker for the iOS app: in-app avatar editing is a baseline user-facing feature, and the app has no business uploading anywhere except the user's own posta-server. Without server-hosted avatars the iOS UX collapses to "upload your avatar to S3 and paste the URL into Settings" — not viable. ## Solution Add a per-identity avatar upload to the REST surface, store the image on disk under the per-identity data directory, and serve it as a static file from the same host the identity's actor doc lives on. The avatar's URL is **content-addressed** (`/avatar-<sha256-hex>.<ext>`) and is written into the `identity.avatar` column so the actor doc's `avatar` field renders it automatically on next GET. Content-addressed naming lets us ship the avatar as immutable (`max-age=1y`); when Alice uploads a new avatar the URL itself changes, peers fetch the new URL after their actor-doc cache refreshes (≤5 min), and the old URL becomes unreferenced. End-to-end: - iOS / TUI sends `PUT /api/v1/identity/avatar` with raw image bytes and a `Content-Type` header. Server sniffs magic bytes, validates against the declared type, rejects mismatches and unsupported types with `400`. On success: hashes the bytes, writes the file atomically as `avatar-<hash>.<ext>` under the per-identity directory, deletes any prior `avatar-*` file, sets the identity row's `avatar` to `<canonical-URL>/avatar-<hash>.<ext>`. - Peers fetch the actor doc (≤5 min cached per §5.3), see the new `avatar` URL, and GET it directly from the daemon. The daemon serves it public (no auth), with `Cache-Control: max-age=31536000, immutable` and `ETag`. Clients cache it effectively forever; propagation of a new avatar happens automatically because the URL changes. - `DELETE /api/v1/identity/avatar` removes the file and clears the column. ## User Stories 1. As an iOS user, I want to tap my profile picture and pick a new image from the photo library, so my avatar shows up across all my contacts within minutes. 2. As an iOS user, I want my uploaded avatar served from my own posta-server (not S3, not an external CDN), so I don't have to manage a second service to use posta. 3. As an iOS user uploading a HEIC photo from camera roll, I want the iOS app to convert to JPEG before sending — the server will reject HEIC — and the upload to succeed. 4. As an iOS user, I want the upload to fail visibly with a specific reason (file too large, unsupported type) so I can correct the input instead of staring at a spinner. 5. As an iOS user, I want a `Remove photo` action that deletes my server-side avatar without replacing it. 6. As a TUI user, I want the same upload affordance once the TUI grows beyond MVP — no separate ops workflow. 7. As an operator, I want avatar files to live under the same per-identity directory as keys and the SQLite DB (`/var/lib/posta/<slug>/`), so backup and restore of one identity is still one rsync. 8. As an operator viewing the data directory, I want avatars on disk with the original image format intact (no re-encoding), so I can `file avatar-*.png` and have it show as a real PNG. 9. As an operator with a misbehaving avatar route, I want a clear failure mode (`404 no-avatar-set` vs `500 read-failed`) so I can tell whether the file is missing or the disk is unhappy. 10. As a contact of Alice, I want my client to render Alice's new avatar within roughly the actor-doc cache TTL (≤5 min) of her uploading it — the content-addressed URL changes, so peers refetch automatically, no avatar-cache staleness window on top. 11. As a contact of Alice, I want her avatar URL (once embedded in the actor doc) to be cached effectively forever by my browser / app, so I'm not paying network cost on every render. 12. As a peer scraping the public web, I want avatar URLs to be unauthenticated (same as actor docs), so the rendering layer works without bearer tokens. 13. As the daemon, I want avatar uploads larger than the size cap (1 MiB) to be rejected at the body-read stage with `413 payload-too-large`, not buffered in memory. 14. As the daemon, I want `Content-Type` validation to be magic-byte-based, not trust the header, so a `Content-Type: image/png` body that's actually a shell script can't get written to disk under a `.png` name. 15. As an operator changing my avatar, I want the previous `avatar-*` file cleaned up on upload so the per-identity directory doesn't accumulate stale files indefinitely. 16. As an operator, I want the upload to be atomic — a crash mid-write must leave either the old avatar or the new one fully on disk, never a half-written file at the served path. 17. As an operator running the daemon, I want the avatar feature to require zero new configuration: it should Just Work given the existing per-identity data directory and the existing bearer-token auth. 18. As an iOS user pairing a new device with an invite link, I want my server-hosted avatar to remain valid across pairings — it's identity-scoped, not device-scoped. 19. As an iOS user revoking a stolen token, I don't want the avatar deleted — the avatar belongs to the identity, not to any particular token. 20. As a developer reading the actor doc, I want the `avatar` field to be either a fully-qualified URL or absent — never a relative path — so spec-compliant verifiers can consume it without rewriting. ## Implementation Decisions **Module A — image sniff (`internal/imagesniff`, new, deep)** A pure module that takes raw bytes (or an `io.Reader`-with-limit) and the declared `Content-Type`, and returns either the validated content type + canonical file extension or a typed rejection. v0 accepts only PNG and JPEG; WEBP and HEIC are out of scope. Magic-byte detection is authoritative — the request `Content-Type` is cross-checked but the sniffed type wins, so a mismatch is rejected as `content-type-mismatch` rather than silently saved under the wrong extension. Rejection categories: - `unsupported-media-type` — declared type isn't `image/png` or `image/jpeg` - `content-type-mismatch` — bytes don't match the declared type - `not-an-image` — magic bytes don't match any supported type - `truncated` — body shorter than the magic-byte header **Module B — avatar storage (`internal/avatarstore` or in `internal/store`, new)** Filesystem-backed per-identity storage with content-addressed filenames. Public surface (TBD names): - Write the new avatar atomically: hash the bytes (SHA-256), write to `avatar-<hash>.<ext>.tmp`, `os.WriteFile`, `os.Rename` to `avatar-<hash>.<ext>`. Then delete any other `avatar-*` files in the directory so each identity has at most one avatar on disk. - Locate the current avatar file (returns path + extension + hash). The hash is recoverable from the filename; no sibling metadata file. - Remove the avatar (delete the file; tolerate "already absent"). The module owns the directory contract: callers pass the per-identity data dir, the module handles the rest. Idempotency: uploading the same bytes twice produces the same filename and is a no-op (the rename succeeds atomically over the identical file). `ETag` is the hex digest in the filename, so it's free to compute. **Module C — API avatar handlers (`internal/api`)** Two new routes on the authed mux: - `PUT /api/v1/identity/avatar` — body is raw image bytes. Body cap is enforced via `http.MaxBytesReader` at 1 MiB so oversized uploads fail at read time with `413 payload-too-large`. Sniff via Module A, store via Module B, then `UPDATE identity SET avatar = ?` with the canonical content-addressed avatar URL. Publish an `identity-changed` SSE event (same as `PATCH /api/v1/identity` does today). Returns `200` with a small JSON body carrying the new avatar URL and the hash (also derivable from the URL). - `DELETE /api/v1/identity/avatar` — Module B removes the file, `UPDATE identity SET avatar = ''` clears the column. Publish `identity-changed`. Returns `204`. The Server struct gains an `AvatarStore` field, wired from `internal/daemon/runner.go` (per-identity data dir → per-identity store instance). **Module D — per-identity GET route** The per-identity mux (built in `daemon/runner.go`) currently mounts `/api/v1/`, `/setup`, and `/` (the inbox/actor handler). Add a new public route that handles `GET /avatar-<hash>.{png,jpg}` (pattern, not fixed path). The handler asks the AvatarStore for the current file; if the requested filename doesn't match the on-disk file (e.g. a stale URL after a re-upload) or no avatar is set, returns `404 no-avatar` with a small text body. Otherwise serves the file bytes with `Content-Type: image/png|jpeg`, `Cache-Control: max-age=31536000, immutable`, `ETag: "<hash>"`. Honours `If-None-Match` for `304 Not Modified` (cheap for clients with HTTP caching disabled). Auth bypass: the avatar route joins `/api/v1/invite/*` and `/setup` in the public-prefix carve-out (`PublicAuthBypassPrefixes` in `internal/api`). **Module E — PATCH /api/v1/identity behavior** The existing PATCH endpoint accepts `name`, `about`, `avatar`. v0 decision: drop `avatar` from the PATCH allowed fields. The only way to set the avatar URL is via the new PUT endpoint, which guarantees the URL points at a real file the daemon serves AND uses a content-addressed shape that immutable caching depends on. Operators with external avatar URLs (CDN, etc.) lose that path in v0; if reinstated later it should be a separate `external-avatar-url` field that PATCH accepts, not a re-overload of the same field. **Actor doc wiring** No code change needed. `dynamicActor()` in `internal/inbox/inbox.go` already reads `identity.avatar` on every GET; once the PUT handler writes the URL into that column, the actor doc reflects it automatically. Peers see the change within their actor-doc cache TTL (≤5 min, §5.3). **Avatar URL shape** `<canonical-actor-URL>/avatar-<sha256-hex>.<ext>`. For `https://arne.posta.no` and SHA-256 prefix `abc123...` of a PNG, that's `https://arne.posta.no/avatar-abc123....png`. For multi-tenant path-based hosts (`https://posta.no/u/arne`) it's `https://posta.no/u/arne/avatar-abc123....png`. The mux must route both shapes; existing per-identity dispatch already keys on Host, and `pathOf(opts.URL)` in the inbox covers the path prefix. The hash is the full 64-hex-char SHA-256. Truncation (e.g. first 16 chars) would shorten URLs but trade off integrity; v0 ships the full digest. **Cache and propagation** Avatar URL: `Cache-Control: max-age=31536000, immutable` + `ETag`. Because the URL is content-addressed, the file at that URL never changes — clients can cache forever, no revalidation needed. Propagation of a new avatar happens via the actor doc: the URL inside the actor doc changes, peers see the new URL after their ≤5 min actor-doc cache (§5.3) refreshes, fetch the new avatar with a fresh URL that bypasses their HTTP cache entirely. The actor-doc cache itself remains capped at 5 min per §5.3 — no change required. **Size cap** 1 MiB. Covers the photographic-PNG case (a portrait shot at 512×512 can run 300–800 KB as PNG), JPEG of any reasonable resolution, and leaves headroom for slightly larger uploads from clients that don't aggressively downscale. iOS guidance (Further Notes) recommends JPEG for photographs to stay well under the cap; the 1 MiB ceiling is the server's safety net, not the expected upload size. ## Testing Decisions **What makes a good test here.** Tests exercise external behavior at module seams. Module A's contract is "bytes → (extension, error)"; no HTTP, no disk. Module B's contract is "directory + payload → atomic file replace with content-addressed name"; tested against a `t.TempDir()`. Modules C and D test through the HTTP surface (existing `newRig` pattern in `internal/api/api_test.go`). **Modules under test:** - **Module A — image sniff.** Magic-byte fixtures for valid PNG (8-byte signature `\x89PNG\r\n\x1a\n`) and valid JPEG (`\xff\xd8\xff\xe0`, `\xff\xd8\xff\xe1`, `\xff\xd8\xff\xdb`). Rejection cases: HEIC (`ftyp` brand), GIF (`GIF87a`/`GIF89a`), WEBP (`RIFF`/`WEBP`), truncated PNG (under 8 bytes), Content-Type mismatch (PNG bytes declared as `image/jpeg`). Table-driven, no I/O. - **Module B — avatar storage.** `t.TempDir()` rig. Write PNG → read returns the same bytes + extension + hash. Write JPEG over the existing PNG → only the new `avatar-<newhash>.jpg` survives. Write identical PNG twice → same filename, no-op. Remove → file gone, second remove is a no-op. Crash-simulation by failing the `Rename` → assert the prior file is untouched. - **Module C — API handlers.** Against `newRig` (existing test rig in `internal/api/api_test.go`). PUT with a valid PNG returns `200`, identity.avatar column carries the canonical content-addressed URL, response body's `hash` matches the URL. PUT with a JPEG declared as PNG returns `400` with `error: content-type-mismatch`. PUT exceeding 1 MiB returns `413`. DELETE returns `204` and clears the column. SSE subscriber receives one `identity-changed` event per mutation. - **Module D — GET route.** Behaviour test wires a runner-like mux with a pre-seeded AvatarStore. GET on the current filename returns `200` with correct `Content-Type` and `Cache-Control: max-age=31536000, immutable`. GET with matching `If-None-Match` returns `304`. GET on a stale (no-longer-current) filename returns `404`. GET with no avatar set returns `404`. Auth bypass works (no bearer token). Prior art for the rig: `internal/api/api_test.go newRig` already wires in-memory SQLite + httptest.NewServer; same pattern for C and D. Module A is pure and tested in isolation. Module B uses `t.TempDir()` with no other dependencies. ## Out of Scope - **WEBP and HEIC support.** Defer; iOS clients can convert HEIC to JPEG before upload, and WEBP/AVIF adoption can wait until the spec library has a chance to weigh in on the canonical set of accepted types. - **Server-side image resizing or re-encoding.** The server stores whatever bytes the client sent. Clients are responsible for sending appropriately-sized images. Defer until usage shows the bandwidth cost matters. - **Multiple resolutions** (avatar@2x.png for retina). Defer; clients resize at render time. - **Animated avatars (GIF, animated WEBP).** Not in v0. - **Avatar metadata in the actor doc** (dimensions, file size). Defer; consumers fetch and inspect locally. - **External avatar URLs via PATCH.** v0 dropping the field is a scope-cut. If operators need to host elsewhere later, add an explicit `external-avatar-url` field to PATCH at that point. - **Migration of existing identities with PATCH-set external avatar URLs.** None exist in production at the time of writing (the field has been wired but never used by clients); any test data with an externally-pointed `identity.avatar` value remains as-is and the daemon will keep serving the actor doc with that URL until the operator overwrites it via PUT or DELETE. ## Further Notes - iOS upload size from camera roll can be tens of MB. The iOS app SHOULD downscale to ~512×512 and re-encode as **JPEG** before upload. PNG is the wrong format for photographic content — a portrait shot saved as PNG at 512×512 typically lands 300–800 KB, vs ~30–120 KB as JPEG quality-80. PNG is appropriate for logo/graphic-style avatars (flat colors, transparency). The server's 1 MiB cap is the safety net, not the expected upload size. - The avatar URL embedded in the actor doc must be the §4.1-canonical URL (PRD #10). The identity's URL is already canonical at runtime (per #12); appending `/avatar-<hash>.<ext>` preserves canonicality because the lowercase hex hash + lowercase `.png` / `.jpg` path-segment satisfies §4.1 (no userinfo, no query, no fragment, no dot-segments). - The actor-doc-cache cap (`Cache-Control: max-age=300`) on the actor doc itself stays unchanged — the avatar's separate immutable caching works in concert with it: actor doc TTL bounds how fast a new avatar URL propagates; immutable caching means once propagated the file is fetched at most once per client. - Coordination with iOS: this PRD's `PUT` endpoint is the minimum surface iOS needs. The iOS app's avatar-edit screen depends on this shipping; track that dependency on the iOS-side issue tracker.
Author
Owner

This was generated by AI during triage.

Umbrella PRD. No code goes directly into this issue; the implementation lives in four child slices:

  • #16 — S1: PNG avatar upload + public GET (tracer bullet, no deps)
  • #17 — S4: Drop avatar field from PATCH /api/v1/identity (independent)
  • #18 — S2: DELETE /api/v1/identity/avatar (blocked on #16)
  • #19 — S3: JPEG support (blocked on #16)

No spec-library precondition this time — every slice is self-contained against the current pkg/posta surface.

Closing this issue is a maintainer decision after the four children land — hence ready-for-human rather than ready-for-agent.

Category: enhancement
State: ready-for-human

> *This was generated by AI during triage.* Umbrella PRD. No code goes directly into this issue; the implementation lives in four child slices: - #16 — S1: PNG avatar upload + public GET (tracer bullet, no deps) - #17 — S4: Drop avatar field from PATCH /api/v1/identity (independent) - #18 — S2: DELETE /api/v1/identity/avatar (blocked on #16) - #19 — S3: JPEG support (blocked on #16) No spec-library precondition this time — every slice is self-contained against the current `pkg/posta` surface. Closing this issue is a maintainer decision after the four children land — hence ready-for-human rather than ready-for-agent. **Category:** enhancement **State:** ready-for-human
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
posta/server#15
No description provided.