# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added - Instance operators now have an instance-level audit log (`GET /api/v1/instance/moderation/audit`, operators only, cursor-paginated) recording every instance-wide ban and unban — including a ban of an address with no account, and every unban, which previously left no audit record anywhere (issue #276). Instance-operator actions have no single community to attribute them to, so an audit entry may now carry a null `community_id`; the per-community audit log is unchanged, and each account-ban still writes its member-facing per-community entry too. - A "Reset link" control on your personal calendar subscription (issue #291). The iCal feed token rides in the subscription URL's path, so calendar apps and any fronting proxy log it on every poll; resetting mints a fresh token — dead-ending a link that has leaked — and shows the new URL to paste back into your calendar app. (A group's calendar link, a moderator action, is unchanged for now.) ### Changed - Editing a legal page (privacy policy or imprint) is now guarded against concurrent operators (issue #276). Each page carries a version; if another operator has saved since you opened the editor, your save is refused with a 409 rather than silently overwriting their text, and the editor offers to reload the current version so you can reapply your change. Previously two operators editing at once was last-write-wins with no warning. - The realtime websocket now authenticates with a short-lived, single-purpose token instead of the long-lived device token (issue #175). The device token stayed in the socket URL's query string, where any fronting proxy or CDN logging request paths could capture a 365-day credential; the client now mints a ~60-second token over REST (device token in the `Authorization` header only) and connects with that. The short-lived token is bound to the device, so revoking a device also cuts its realtime access on the next connect. No user-visible change; existing connections are unaffected. ### Fixed - Signing out of an instance now also clears any pending community invite left mid-flight, so on a shared device the next person to sign in can't inherit the previous user's invite token (issue #369) — the same shared-device privacy class as the offline-snapshot clearing (#186), for a different stored key. An audit of every `kammer:*` browser-storage key confirmed this was the only remaining instance-scoped residue; the theme and language preferences are device settings and intentionally persist. - Three concurrent-moderation races that raised a 500 now answer a neutral 404 instead (issue #276): lifting a community or instance ban that another moderator already lifted, and dismissing a report whose content another moderator concurrently removed. The two `unban` controllers gained an `else` clause for the already-folded not-found result (they previously raised a `WithClauseError`), and report `dismiss` now folds its own stale update the way the ban lifts already did. - ICS calendar files now fold long content lines to at most 75 octets _including_ a continuation line's leading space (issue #383, RFC 5545 §3.1); the folder re-measured the tail without the injected space and so emitted 76-octet continuation lines. Cosmetic — every calendar client tolerates it — but now spec-correct. - Editing or cancelling an event now reliably updates calendars that already subscribed to it (issue #363). The ICS export never emitted `SEQUENCE` (RFC 5545 §3.8.7.4), so a calendar app that gates re-processing on the revision counter could silently ignore a later edit or cancellation. Events now carry a `SEQUENCE` that advances on each significant revision — an edit to an exported field, a cancellation, or a reinstatement (a capacity-only change, which the calendar never shows, doesn't bump it) — so both revision-gated and timestamp-gated clients pick the change up. - A calendar app or RSS reader that sends a strict `Accept` header no longer gets a 406 from the webcal ICS feeds, the single-event ICS download, or a public group's RSS/Atom feed (issue #366) — content negotiation against `Accept: text/html` was silently failing the subscription for clients that don't send `*/*`. These media routes moved off the `:browser` pipeline onto a new `:browser_media` pipeline that keeps the session read and setup gating but drops `:accepts`, since each controller sets its own `Content-Type`. Mirrors the `:api_binary` split (#315) on the higher-traffic subscription surface; the one real HTML route (the newsletter-unsubscribe page) stays on `:browser`. - A control character in a post or event title no longer makes a public group's RSS/Atom feed ill-formed (issue #364). XML 1.0 forbids the C0 control range (except TAB/LF/CR) and the U+FFFE/U+FFFF noncharacters; a single one — which the post/event changesets don't strip — made a strict XML parser reject the _entire_ feed rather than the one offending item, so a single crafted title could take down `feed.rss`/`feed.atom` for every subscriber. `Feed.Syndication` now strips the illegal range at both output boundaries (the entity escaper and the CDATA description body), matching how the ICS side (#313) hardens at its boundary. - An instance ban is now a full account lockout, closing the evasion where a banned account could re-authenticate and shed the ban by changing its email (issue #377). Before, `instance_banned?` was consulted only at the community-join choke-point: a banned account could still request a fresh sign-in link/code to its own address, exchange it for a new session, and then change to an un-banned address (the ban is keyed on the old email), rejoining every community. Now a banned address gets no new session — registration, sign-in-link requests, and code/magic-link exchange all refuse it neutrally (no ban oracle: a banned address is indistinguishable from a taken or unknown one), and every authenticated request re-checks the ban list as a backstop. Email change is refused when either the current or the target address is banned, so a ban can't be moved off. A ban also revokes the account's passkeys (a retained passkey would let it sign back in through the usernameless ceremony) alongside the device tokens revoked in #276. The notification channel now re-authorizes each push against the recipient's live group access — matching the feed channel — so a membership or visibility change that never broadcast a socket disconnect still cuts the stream. - The legal-page editor's changeset no longer casts `updated_by_user_id` from the request body (issue #276); the "who last edited" attribution is set programmatically in the context via `put_change`, per the convention that programmatic fields are never cast. It was already safe in practice (the context overwrote any body-supplied value), so this closes the defense-in-depth gap where a future caller skipping that overwrite would let a crafted body spoof the editor. Also drops the now-unused `Legal.change_page/2` form-changeset helper, orphaned when the LiveView edit form was removed in #187. - An instance-wide ban now severs the banned account's current live sessions, not just its community memberships (issue #276). Banning an email removed the account from every community but left its device tokens valid and its websockets open, so an active session kept working (continued `/me` access, historical notifications) until it happened to reconnect — the one adversarial account event that _didn't_ drop its live credentials, unlike account deletion, device revocation, and email change, which all do. `ban_instance` now revokes the account's device tokens inside the ban transaction and the API severs its open sockets. (This kills the live session; a banned account re-authenticating into its now-empty state, and the separate email-change ban-evasion hole, are tracked in #377.) - Deleting a group now removes its uploaded file blobs from disk, not just the database rows (issue #276). A group delete hard-deletes and cascades every child row, but the on-disk bytes for the group's stored files and their thumbnails were never removed — and the daily purge worker sweeps only _soft_-deleted content and expired transient uploads, never a hard-deleted group's blobs, so those stayed orphaned forever, quietly breaking the privacy template's promise that deleted content is purged. `Groups.delete_group/2` now captures the group's storage keys inside the delete transaction and purges the blobs once it commits (a filesystem unlink can't roll back, so it runs post-commit). - The test suite no longer leaks a recurring `update check failed: {:unexpected_status, 500}` warning (issue #280). The update-check failure-path test emitted it from its own stubbed 500 (the check is never invoked unstubbed — Oban runs `:manual` in test); the test now captures and asserts the warning rather than letting it print, which also pins the previously-unasserted behavior that a failed check warns the operator. - Control characters in an email no longer reach Postgres and 500 (issue #334). A NUL byte — or any other C0/DEL control — passed the old format regex `[^@,;\s]+@…` and then raised `Postgrex.Error` (text columns reject a NUL), so a crafted JSON body carrying a NUL (a JSON string may hold a `\u0000` escape) turned a malformed email into an unhandled 500. Two mechanisms are closed. The shared changeset validator now excludes the C0/DEL range and anchors with `\A…\z` (also closing a trailing-newline hole `$` allowed), covering every write that shares it (registration, guest identities, newsletter subscriptions, invites, event/post guest requests) — plus the profile `contact_email`, which had bypassed validation entirely (editable on `PUT /me`; a blank submission still clears the optional field), and the two moderation ban changesets. And the raw-email _lookup_ paths that never touched a changeset — anonymous sign-in (`request_link`), the login-code exchange, and the instance-ban address — now reject a control-char address before it reaches a `where email = ?` query, so the sign-in endpoints keep their neutral anti-enumeration response instead of 500-ing before rate limiting even runs. Unicode/IDN addresses stay accepted throughout. The sweep of genuinely free-text fields (`contact_phone`, `contact_note`, …) is left to the security-audit swarm's input-handling lens. - Client resilience follow-ups (issue #316). Time-derived _state_ no longer fossilizes on a long-lived installed PWA the way relative timestamps once did: an open poll now locks the minute its deadline passes, a scheduled post flips to published when its time arrives, and the events agenda's "Today"/"Tomorrow" headings stay honest across midnight — each now reads the shared reactive minute clock, which also snaps fresh the instant the app foregrounds. The tokenless public shells (`/welcome`, `/sign-in`, `/setup`, public `/c/[community]/**`, and their siblings) gain a render-error boundary so a client-side _render_ crash degrades to a recoverable card instead of a white screen with SSR off. Two smaller hardening fixes: the shared `Button`'s link variant no longer drops `aria-*`/`data-*`/`target` props, and removing an instance clears the cross-instance offline snapshots (issue #186 — a shared device's next signer-in must not inherit them) before — and regardless of — the realtime-socket teardown, so a throw there can't skip the privacy step. - Binary API downloads no longer reject their own documented media type (issue #315). Every byte-serving API route (the single-event ICS, the stored-file endpoints, the GDPR export zip) sat in the JSON-only `:api` pipeline, so a client sending the natural Accept header for the payload — `text/calendar` for the ICS, `image/*` for a file, `application/zip` for the export — got a 406 before the controller ran. They now serve through a binary pipeline that keeps the same authentication but drops JSON-only content negotiation (the PWA was unaffected — its `fetch` sends `*/*`). The authed byte responses (including the secret-token calendar feeds, deliberately) now carry a `private, no-store` cache directive — belt-and-braces over RFC 9111; the tokenless public post attachments keep the framework's default caching instead. And single-event `.ics` downloads take a filename derived from the event title instead of a static `kammer.ics`, so several saved files are distinguishable. - Hardened ICS calendar output (issue #313, affecting every ICS surface — the single-event download, the group/user feeds, and the reminder and guest-confirmation email attachments). A lone carriage return in an event's title, description, or location used to survive escaping; RFC-strict parsers only break a line on CRLF, but several real calendar clients break on a bare CR too, which let an event's text inject whole property lines (ORGANIZER, ATTENDEE, VALARM) into subscribers' calendars — every line-break variant now collapses to one escaped `\n` (CR/LF plus the Unicode separators NEL/U+2028/U+2029 that Unicode-aware clients also break on), and control characters are dropped (C0 and DEL, which RFC 5545 forbids outright, plus the C1 range as defense-in-depth against lenient clients). Cancelled occurrences now export `STATUS:CANCELLED` instead of looking live, so a downloaded file marks the event cancelled rather than planting a normal-looking one. - The newsletter unsubscribe link no longer deletes on GET (issue #239). GET is a safe method, and non-RFC-8058 mail scanners and corporate link-checkers prefetch every URL in an email with no human involved — so following the `List-Unsubscribe` URL in a browser now renders a small confirmation page (instance default locale, both themes) whose button fires the RFC 8058 POST, and only the POST unsubscribes. Mail clients' one-click POST keeps its semantics — same delete, same neutral 200 (the response body is now the same small HTML page instead of plain text); both endpoints still answer identically for valid and garbage tokens alike. The issue's second item — the email body's manage link carrying the full token in the URL path — had already been fixed in the #187 cut (the link rides the URL fragment), so this closes the issue. Alongside: the instance-locale wrapper the guest and newsletter notifiers each carried a private copy of moved to `KammerWeb.Gettext.with_instance_locale/1`, now shared by the notifiers, the decision-poll texts, and the new pages. ### Added - Owner-facing notice email on account deletion and data export (issue #338). The two most consequential, irreversible account actions — permanent deletion and the full-data export — now email the account's own address after the fact, the way an email change already notifies the old address. It's the one signal a hijacked account's real owner still receives if a stolen device token is used to destroy the account or pull all its PII in one request: each notice names what happened and says to contact the instance's administrator if it wasn't them. Best-effort — a mail failure can't fail the action it reports (the deletion notice is sent from the in-memory record once the row is gone), but a failure is logged, since the notice is the security signal. EN + DA. Closes the residual gap ADR 0029's step-up work left (surfaced by #337's review). - Test coverage for the community-file-space rehoming data migration (issue #308). The #187 migration that moves the now-unreachable, LiveView-only community file space onto a group carries real branching — it prefers the oldest group that can actually surface the files (not archived, not private, `:files` on), falls back to the community's oldest group otherwise, excludes transient uploads, and skips a group-less community — and it moves personal-data-bearing rows, yet had no test. It now runs its _real_ `up/0` (not a copy of its SQL) under the test sandbox through a new reusable `Kammer.MigrationHelper`, which drives `Ecto.Migration.Runner` so a data migration's `execute/1` commands run on the test's own sandboxed connection — a harness future migration tests can reuse. - Step-up re-authentication before credential changes (issue #294, ADR 0029). Adding or removing a passkey, revoking a device other than your own, and starting an email change now require the calling device to have recently re-asserted a root of trust — either a passkey confirmation or a single-use emailed link — within a short window (`STEP_UP_VALIDITY_MINUTES`, default 10). This closes the gap where a transiently stolen device token could mint persistence (add a passkey that survives revocation, lock the owner out, or repoint the account email); signing out, including revoking your own device, stays ungated. Gated endpoints answer 401 with the new `step_up_required` code; the PWA opens a confirmation dialog (passkey or "email me a link" with a check-your-inbox step), then retries the original action. The emailed link lands on a new `/step-up/{token}` page that works in any browser and elevates only the device that asked. This supersedes the #258-era decision to ship the API email-change flow without a re-auth gate, and resolves ADR 0018's open sudo-mode note. - Per-community accent re-tint in the PWA (issue #321, SPEC §21). Community-owned surfaces — the authed community tree and the public community pages — now re-tint the interface with that community's configured `accent_color`, delivering §21's "branding is structural" promise: switching communities switches the accent. The accent and its foreground are derived per theme from the stored color with a WCAG AA floor (≥ 4.5:1 against paper/surface in the light _and_ dark palettes, and for text on the accent), darkening or lightening only as far as safety requires, so any admin-chosen color ships readable in both themes; an invalid stored color falls back to the default accent. Merged, multi-community surfaces (Home, cross-instance lists) and the app chrome keep the neutral default. - Per-event capacity limit with an automatic ordered waitlist (issue #318). An event can now cap its attending RSVPs (`capacity`, empty = unlimited, set from the event form): once full, further "yes" answers — member and confirmed-guest alike, one cap over the whole RSVP set — land on a waitlist in arrival order, shown to the caller ("You're #3 on the waitlist") and to members on the event page as an ordered waitlist section (the public event read carries counts only, never queued identities). A freed seat (an attendee cancelling, the organizer raising or removing the cap) promotes from the front of the queue atomically — concurrent writes serialize on a row lock, so a seat is never double-filled — and the promoted member is notified through the notification machinery at their level (new `event_promoted` kind); a promoted guest gets an email, and a guest whose RSVP queued is told so in their confirmation email. Lowering the capacity never demotes anyone already attending. Deliberately decoupled from ticketing (#133): capacity is a small delta on the existing RSVP machinery. - Newsletter subscription form on the PWA's public group page (issue #185, part of #187, SPEC §8). When a group opts guests into its newsletter (`guest_subscribe_allowed`), its public page now offers a tokenless subscribe form — name, email, and a per-post / daily digest / weekly digest cadence picker — matching what the LiveView group page offered. It posts to the existing `POST /api/v1/communities/{slug}/groups/{slug}/newsletter` endpoint, always answers a neutral "check your email to confirm" (no oracle on whether the address is already subscribed), and lets the guest change cadence or unsubscribe later from the management link the confirmation email carries. This closes the last unbuilt guest surface before the LiveView removal cut. - Passkey enrollment in the PWA (issue #260 port 5b, part of #187, ADR 0018). Four new authenticated endpoints under `/api/v1/me/passkeys` — `POST /challenge` (WebAuthn registration options), `POST` (verify the attestation and store the credential), `GET` (list), and `DELETE /{passkey_id}` (remove) — let a signed-in account register a passkey, the authenticated twin of the usernameless sign-in ceremony (#287). The ceremony runs statelessly: the challenge travels to the browser inside a signed, short-lived token (a salt distinct from the sign-in challenge's, so the two are never interchangeable) and comes back to verify against. Enrollment failures — a stale or tampered token, a bad attestation, a duplicate credential — all collapse into one neutral 422 that never reveals which step failed. The device page gains a "Passkeys" section listing the account's passkeys (name, when added, when last used) with remove, plus an "Add a passkey" control shown only when the browser supports registration and the instance is same-origin (WebAuthn binds the ceremony to this origin's host); otherwise a short note explains why. The serializer never emits the credential id or public key. - Recurring event-series organizer view in the PWA (issue #260 port 4, part of #187, SPEC §6). A new authenticated endpoint — `GET /api/v1/communities/{slug}/events/series/{id}` — returns a series' rule, every occurrence (with RSVP counts and cancel state), and the attendance matrix (group members × upcoming occurrences, each cell the member's RSVP). It is organizer-only: the series' creator or a group moderator, gated exactly as the LiveView organizer page was. The PWA gains a series page reached from a "View series" action on any occurrence's detail, listing every occurrence with per-occurrence cancel/reinstate and rendering the attendance matrix. Attendance is the members' RSVPs (SPEC §6 — members only; guests count toward an occurrence's totals but are not matrix rows), computed, never a separate "mark present" step. - Calendar subscription (iCal) links in the PWA (issue #260 port 3, part of #187, SPEC §6). New authenticated endpoints — `GET /api/v1/me/calendar-token` and a group sibling — hand the signed-in caller their subscription URL for the secret-token iCal feeds the browser routes already serve: a personal merged-events feed, and any group's whose events they can see. The PWA gains a "Subscribe" control on each group's page (when its events feature is on) and on the personal events page (one per added instance, since each feed lives on one server), revealing the feed URL with a copy action and a one-tap `webcal://` open link. The token is minted on first fetch and is the whole credential; the group endpoint gates exactly as the group's own feed does (viewable, events feature on), and an unviewable group is the same 403 every group-scoped endpoint gives. - Group creation in the PWA, with a warm new-community cold-start (issue #278, part of #187). The PWA could edit a group's settings but had no way to _create_ one — a fresh community dead-ended with no first group (the LiveView was the only surface that could make a group). Adds a new-group form (name / URL / description / visibility / join, posting, and comment policy / approval queue / the create-only sealed flag) at parity with the LiveView's, and a `create_group`-gated "New group" affordance. A community with no groups now shows a warm empty state ("Create your first group") instead of a blank card — a new community starts empty by design (no auto-created default group, SPEC §3). The new-group form offers optional named suggestions (Everyone / Announcements / Public page) that pre-fill a sensible starting shape the operator renames and adjusts — suggestions, never a furnished room. SPEC §3 clarified: groups are the sole container (no community-wide file space), and cold-start is UX, not a data-model default. - Passkey sign-in in the PWA (issue #260 port 5a, part of #187, ADR 0018), driving the usernameless WebAuthn assertion the API already served (`/auth/passkey/challenge` → `/auth/passkey/verify`). The sign-in screen's email step gains a "Sign in with a passkey" option that runs the ceremony and lands on the same added-instance state as the magic link — no email typed, no password. The affordance is shown only where it can actually succeed — the browser can run WebAuthn and the instance is same-origin (a passkey is bound to its serving origin, so a cross-origin community couldn't sign in with one) — never a button that can only fail. Every failure (an unusable challenge, a dismissed prompt, a rejected assertion) collapses to one neutral message, giving no oracle for which passkeys or accounts exist. - Custom profile-field management over the API and PWA (issue #259, part of #187, ADR 0020) — the last buildable #259 item, porting the LiveView community-settings surface and going past it. Managers get list, add, edit, and delete under `/api/v1/communities/{slug}/custom-fields`, all gated on `:manage_community` in the context. A manager can add a field (text or single-select with options), edit an existing field's label, visibility, or required flag, and delete one. A field's type and its options are frozen once it exists (changing them after members answer would orphan values); everything else stays editable — the LiveView only toggled required. Making a field required after the fact nudges already-joined members rather than locking them out (ADR 0020). The PWA community settings page gains a "Profile fields" section for all of it; a field id from another community answers a neutral 404 through this community's path. - Anonymous instance landing and community directory over the API and PWA (issue #260, part of #187), porting the signed-out `InstanceLive.Home`: a tokenless `GET /api/v1/public/communities` listing the communities that opted into the landing page (`listed_on_instance`, SPEC §3 — unlisted communities never appear, pinned by test), and a public `/welcome` page with the product-ethos blurb, a sign-in affordance, and the directory linking into the existing public community pages. Visitors without a signed-in instance now land there rather than straight on the sign-in form, taking over from the signed-out LiveView root. - The four remaining admin-parity surfaces over the API and PWA (issue #259, part of #187), each mirroring its LiveView semantics. Group deletion: `DELETE /api/v1/communities/{slug}/groups/{slug}` — group owners and community admins (their sole power over sealed groups, ADR 0005), audit-logged by the context — with a confirm-guarded delete button in the PWA group settings danger area, gated on a new `delete_group` entry in the group's `viewer_can`. Instance-wide email bans (the `InstanceLive.Moderation` twin): operator-only list/create/lift under `/api/v1/instance/moderation/bans`, keyed on the email itself so an address without an account can be blocked, and a PWA instance-moderation page under the account area with the ban form, active-ban list, and 422 field names mapped to our own copy (#253). Legal-page editing (`PUT /api/v1/legal/{key}`, operator-only, the `LegalLive.Edit` twin) with a PWA legal-pages overview — including the LiveView nag's equivalent, a "still showing the built-in template" hint per unpublished page — and a markdown editor linking the public page as preview reference. And a per-viewer `instance_operator` flag on `GET /api/v1/instance` (the `can_create_community` precedent), which the You page now reads from the capability-doc fetch it already made for the version line — replacing the per-instance settings-read 403 probe (#271's review note) and gating the new operator links. - The PWA profile page now exposes the account's language, timezone, and email-digest frequency (issue #260, part of #187) — three settings `PUT /api/v1/me` already accepted and digest/notification emails already read, but which had no edit surface outside the LiveView settings screen. Language and digest mirror the LiveView option lists (English/Danish; off/daily/weekly Mondays); the timezone upgrades LiveView's free-text input to a select over the browser's IANA zone list, and the hints spell out that this language governs emails from the server while the app's own display language stays a device-local choice on the You tab. A 422 on save now maps the changeset's field names onto our own copy instead of rendering the server's message string (#253). - Ban creation and an audit-log page in the PWA's moderation surface (issue #259, part of #187), both over API endpoints that already existed with no PWA caller. The moderation page gained a "ban a member" form (admins only): pick a plain member from the roster — the server refuses admins, owners, and self-bans, mirroring the LiveView semantics — with an optional reason the LiveView flow never exposed, and a 422's field names map to our own copy (already-banned email, over-long reason). A new audit-log page under `moderation/audit`, linked from the moderation page like the LiveView's inline audit section, lists the community's append-only admin actions (summary, action code, relative timestamp) for community admins. - Community and instance-operator settings pages in the PWA (issue #259, part of #187), both over API surfaces that already existed — no new server endpoints. The community settings page gained the controls the update endpoint already accepted: web address (slug), default language, "list on the instance landing page", and the real-names request — previously only name, description, and accent were editable, and a 422 now maps the changeset's field names to our own copy (`name`/`slug`) rather than showing a generic error. A new operator-only instance settings page under the account area edits instance name, default language, who may create communities, the storage policy, and content-minimized email mode — reachable only where this account operates the instance (gated on the settings read itself, since there is no operator capability on the client). The LiveView instance settings screen only ever exposed the email toggle; the other four fields had no edit surface before now. - Community creation over the API and PWA (issue #259, part of #187): a new `POST /api/v1/communities` endpoint, gated by the same instance community-creation policy (SPEC §3: operators only / any user) the LiveView flow honors — the creator becomes the community's owner — plus a `communities/new` PWA screen reachable from the groups tab. The instance capability doc (`GET /api/v1/instance`) now reports a per-viewer `can_create_community` boolean so the client gates its "new community" entry point instead of guessing the policy. - The PWA group settings page now covers the full settings surface the API already accepted (issue #259, part of #187): visibility, join/posting/comment policy, web address (slug), approval queue, and file-version retention — previously only name, description, features, and archive were editable. The page also lists pending join requests with approve/deny, and links to a new group invites screen (mirroring the community invites page). The group serializer and its OpenAPI `Group` schema now expose `posting_policy`, `comment_policy`, `approval_queue`, and `version_retention` so the form can pre-fill them — emitted only to a viewer who can manage the group (`viewer_can` includes `manage_group`), so a public group's moderation posture and retention config never ride the tokenless public shape; no new server capability was added — the update endpoint already accepted every one of these fields. ### Changed - Notifications no longer reimplements the comment-subject resolution (issue #124, architecture audit). `Feed.comment_context/1` — already the shared resolver used by Moderation — now returns the subject _struct_ (post/event/assignment), not just its id, so `Kammer.Notifications` derives a comment's host group, subject author, and notification references from it instead of independently branching and reaching `Repo.get!` into the Events and Assignments schemas across a context boundary. No behavior change; one copy of the branching, in one context. Also fills a coverage gap the audit surfaced (issue #95): the `@everyone` mention gate — a real authorization control, since it escalates a comment to a full-group broadcast — is now tested on event and assignment comments, not only post comments, pinning that those entry points route through the shared gate. - Expelling a sealed **private** group from outside is now recorded as an operator-level action (console/DB), not an API power (owner-decided on issue #347, ADR 0005 amendment): the no-oracle gate (#224/#339) hides such a group from a community admin before the delete authorization could run, and restoring an admin-reachable delete would itself be an existence oracle. The group's own owner keeps the ordinary delete; sealed non-private groups keep the API path for admins too. - The default community accent now matches the design-system accent (issue #328). A community that never customized its accent used to carry a green (`#3E6B48`) distinct from the app chrome's `#8a4b24`, so untouched communities rendered two-tone out of the box; the server default (schema and column) flips to `#8a4b24` and a migration moves existing rows still on the old default (matched case-insensitively — the untouched wizard default could be stored in either case), so a tint is always a deliberate admin choice. The data migration is one-way: rolling back restores the old column default but cannot tell a migrated row from one whose admin chose `#8a4b24` on purpose. The setup wizard, new-community form, and community-settings placeholder pre-fill the same value. - Single-account display collapse in the PWA (issue #322, SPEC §21). With exactly one instance added — the overwhelmingly common case, and the entire pilot — the chrome now hides the multi-instance abstraction instead of presenting a one-item federation: Home, Groups, and Search drop the instance-name provenance labels, the You tab presents one plain account (no instance-named card header, no several-servers explainer, "Your account" instead of "Your communities"), the You sub-pages (profile, devices, push notifications, data & account) drop "on " from their descriptions, and the failed-instance banners say "your community" rather than naming the only server there is. Everything derives from a shared `instances.solo` flag, so adding a second account restores the current multi-account presentation unchanged — and URLs keep their `[instance]` segment either way (deep-link stability; this is presentation-only, the data model and routes are untouched). - PWA API error handling collapsed into one shared `ApiError` (issue #270, part of #187). The ~10 near-identical per-module error classes — each carrying its own copy of the status→kind mapping, envelope parsing, and `fetch`-rejection guard — are gone; every client API surface (authenticated and tokenless alike) now throws the single `ApiError` from `$lib/api/errors`, and the two duck-typing bridges that existed only to reconcile the separate classes (`manage`'s `loadErrorKind`, `tools`' `toolsErrorKind`) collapse into one shared `errorKind` collapser. The duplicated status-to-kind mapping tests are retired in favor of one canonical spec. Net −600 lines. No user-visible behavior change beyond one first-run-setup network-error string now reading the shared "Could not reach this community." wording (converged with the other raw-`.message` render sites by the #253 field-error follow-up). A few internal mappings also unify — a swallowed `push` network string, `400` no longer aliased to the `validation` kind on the four tokenless surfaces that did so, and `too_large` now preserved through the collapsed bridges — but no current consumer distinguishes them. This is the shared mechanism the inline 422 field-error work (#253) builds on. - Inline 422 field errors on the PWA's group and community create and update forms (issue #253, part of #270). A validation failure now highlights the specific input that caused it — a taken or reserved web address lands on the slug field, a blank-or-too-long name on the name field (and, on group settings, a bad version-retention value on that field) — with localized copy, instead of one generic banner. Two shared mappers, `groupParamsErrorKeys` and `communityParamsErrorKeys`, turn an `ApiError`'s 422 `details` (Ecto changeset field names, via `traverse_errors`) into per-field i18n keys, reusing the `registerErrorKeys` pattern and the shared `Input` `error` prop; any unmapped field or non-validation failure falls through to the shared `ErrorBanner`. Server-English message strings are still never rendered. - Inline 422 field errors on the PWA's remaining forms — event create/edit and profile update (issue #253, part of #270). The event form now lands a non-http(s) location link on the location-link field (issue #247), an end-before-start on the end-time field, an over-length title/location on their fields, and a too-narrow repeat-until window on that date; the profile form lands a blank-or-too-long display name and an over-length pronouns value on their inputs. Two new mappers, `eventParamsErrorKeys` and `profileParamsErrorKeys`, turn the 422 `details` (Ecto changeset field names, verified against `Kammer.Events.Event`/`EventSeries` and `Kammer.Accounts.User.settings_changeset`) into per-field i18n keys; the event form's inputs moved onto the shared `Input` component to carry the `error` prop. `Select`-constrained fields and any unmapped field fall through to the shared `ErrorBanner`, and the event pages no longer render the raw server `ApiError.message`. First-run setup keeps its banner for now: its `with`-chained sub-changesets yield flat but cross-entity-ambiguous detail keys (`name`/`slug` for both community and group) split across two wizard steps, so field mapping is deferred — but its raw-`.message` render was replaced with localized copy too. The community/group invite and instance-settings forms need no change: their endpoints emit no field-level 422 (the invite changeset doesn't validate `invited_email`; `instance_name` is unvalidated), so they correctly stay on the banner. ### Removed - The gettext catalogs dropped the 661 msgids whose only consumers were the LiveView templates deleted in the #187 cut (issue #325, the cleanup #294's build deliberately deferred): `mix gettext.extract --merge` on a clean tree, verified lossless — every surviving msgid keeps its EN and DA translation, `errors.po` untouched. - **LiveView is gone — the Svelte PWA is now the only web UI** (issue #187, closes #165; ADR 0024). The entire `lib/kammer_web/live/` tree, the LiveView-only web layer (core/feed/Kammer components, layouts, the CSP-nonce plug, the server `assets/` esbuild/Tailwind/daisyUI pipeline), and every LiveView-supporting controller whose capability had already moved to the JSON API (`GdprController` → `GET /me/export` + `DELETE /me`; `UserSessionController` → `/api/v1/auth/*`; the browser invite-accept and guest/newsletter confirm landings → their PWA + API twins; the browser file-download controller → the Bearer and public `/api/v1/files` routes) were deleted in one atomic cut, along with the `phoenix_live_view`, `phoenix_live_dashboard`, `phoenix_live_reload`, `heroicons`, `esbuild`, `tailwind`, and `lazy_html` dependencies, the dev LiveDashboard route (the Swoosh mailbox preview is retained for the e2e sign-in flows), and the LiveView smoke-test CI job. The `KammerWeb.UserAuth` plug shrank to a session _reader_ (browser sign-in was already an API device-token flow), and the browser pipeline dropped its LiveView flash / root-layout / CSP-nonce plugs. - **The PWA moved from `/app` to the site root.** `:pwa_base_path`, the client's `paths.base`, the web manifest, and every email/redirect producer now target `/` — a magic link is `/sign-in/{token}`, a public group is `/c/{slug}/g/{slug}`, and the guest-manage link carries its token in the URL fragment. The router's PWA catch-all moved to the very end so it shadows neither the JSON API, `/healthz`, the ICS/RSS feeds, nor the newsletter-unsubscribe routes. - The community-level file space (a LiveView-only surface with no API twin) was removed; a data migration rehomes any community-space folders, file entries, and stored files onto a group so nothing becomes unreachable. The target is the oldest group that can actually surface them — not archived, not `:private`, `:files` enabled — falling back to the oldest group overall (communities with no group are left untouched). ### Fixed - Every tokenless public surface now draws one "public" line (issue #345, expanded by its review round): `publicly_readable?/1` gates the RSS/Atom feeds (an archived or sealed `public_listed` group used to keep serving a live feed whose every link landed on the SPA error state), the guest RSVP/comment gates (a sealed public group accepted whole guest flows whose confirmation links 404), the newsletter subscribe gate, and — the review round's sharpest catch — newsletter _delivery_: per-post and digest sends now re-check the gate, so a group flipped off the public presets stops emailing post excerpts to guest subscribers instead of leaking content across the visibility boundary indefinitely. The anonymous subscribe, guest comment, and guest RSVP requests all resolve through the shared public fetches now, folding sealed/archived public groups into the same 404 their pages give (the old answers confirmed existence to slug probers: 403 for archived, and for sealed a 202 acceptance of a flow whose confirm link then died). The single-post newsletter email links to the post itself (the digest's roundup link was correct and stays; the misnamed `post_url/1` helper is renamed, and all email links now build through `PublicLinks`, sharing the confirm links' base handling), and a confirmed guest comment redirects to the commented post — with the confirm pages growing the "go to the page" link that makes the redirect visible (the field previously had no client consumer). - Leaving or being removed from a group no longer leaves an ex-member's RSVP sitting on that group's future events (issue #329, owner-decided option 1). Before #318 added capacity and a waitlist, a stale RSVP after removal was cosmetic; afterwards it was consequential — an ex-member could keep an attending seat or a promotable waitlist spot on an event they could no longer even view. `Kammer.Events` gains `drop_member_future_rsvps/2` (and the bulk `drop_member_future_rsvps_in_groups/2` for callers ending a membership across several groups at once): it deletes the user's RSVP — any status, including waitlisted — on the group's future events, then runs the same locked promotion pass a member's own cancellation triggers, so a freed seat or a removed waitlist row promotes the next waitlisted member in order. Past-event RSVPs are untouched, staying as attendance history. Wired into every path that ends a group membership: `Groups.leave_group/2`, `Groups.remove_member/3`, `Groups.remove_memberships_in_community/2` (the community-wide removal `Communities.remove_member/3` already delegates to), `Moderation`'s community and instance bans, and — added by the pre-merge review, which caught it as the sixth door — account deletion (`Gdpr.delete_account/1`), where the FK cascade already deleted the rows but nothing promoted the freed seats. - Community audit log cursor pagination (issue #340, 2026-07-17 dismissal audit). `Audit.list_events` hard-capped at 50 rows with no way to see anything older, the API took no pagination params, and the PWA audit page fetched once — an append-only accountability log that silently dropped history for any active community. Originally dismissed as "parity with the LiveView ceiling," which is exactly the limitation-carry-forward this file's process section bars. `GET /api/v1/communities/{slug}/audit-log` now takes the same `after`/`limit` cursor params as the notification center and group feed (`Kammer.Audit.list_events_page/4`, same `{events, next_cursor}` contract; default page size stays 50, `Pagination.limit/2` gained an optional default override for it); the PWA audit page grows a "Show older" button matching the notification center's, appending pages and hiding once `next_cursor` is `nil` — a failed page keeps the loaded log on screen and stays retryable, mirroring the group feed's load-more error handling. - RSS/Atom group feed items now link to the post itself instead of the group page (issue #341). Feeds shipped in #54 before any per-post page existed, so every item's `` (RSS) / `` (Atom) pointed at the group page — the best available target at the time. #246 later added the public post page at `/c/{community_slug}/g/{group_slug}/p/{post_id}`, but nothing circled back to point feed items at it, so a reader following an item from their feed reader landed on the group's whole feed rather than the post they clicked. Each item's link now resolves through a `post_link_fun` the controller passes to `Kammer.Feed.Syndication` (an `unverified_url/2` build of the post's public page, the same convention as the feed's other links); the feed-level `` correctly stays the group page. The RSS `` is unaffected — it was never a URL. - An explicit JSON `accent_color: null` on community create, community settings, or the setup wizard no longer escapes as a 500. The request bodies declare the field nullable, but the column is `NOT NULL`, so the cast nil died at Postgres. A null now means "no choice": the changeset drops it, keeping the stored value on update and the schema default on create — which is what `nullable: true` honestly means for a defaulted setting. Pinned by an API test driving null through both create and update. - Three previously-dismissed review findings, remediated after the owner overruled the dismissals (2026-07-17). The invite landing page now re-tints with the invited community's accent once the token resolves — the community is known from that moment, so the page carries its branding like every other community-owned surface (the invite preview payload gains the community's `accent_color`; the guest confirm and manage pages stay untinted — their payloads carry no single community, and a guest's inventory can span several). The single-account failure banners no longer presume one community: "your community" copy is now count-agnostic in English and Danish (one instance can host several communities, so the old wording was wrong for multi-community accounts). Internally, waitlist-promotion notification jobs are enqueued with one `Oban.insert_all` instead of a per-promotion insert loop — same in-transaction atomicity, one statement per promotion burst. - Invite creation now validates `invited_email` (issue #305). The changeset cast the address with no format check, so an admin could invite `jhon@` or `alice@example ` — the API answered 201 and the invitation email silently bounced. A malformed address now fails with the standard 422 envelope (`details.invited_email`) via the shared email rule every other email field uses (`Kammer.Validation.validate_email_format/3`); valid addresses are stored downcased like the guest and newsletter email writes (the column is citext, so comparisons were already case-insensitive). Validation also runs before the per-admin email-invite throttle, so a refused address no longer consumes email budget. The PWA invite forms (community and group) surface the 422 inline on the email field through the #253 field-error mapper, in English and Danish. This supersedes the #253-era note earlier in this release that the invite forms "correctly stay on the banner" — that held only while the server emitted no field-level 422 for them. - "Add to calendar" on an event page no longer fails with a silent 404 for members-only groups (issue #307). The signed-in event page linked the tokenless browser ICS route (`/c/{slug}/events/{id}/ics`), which a plain `` navigation reaches without the device token — so any event the anonymous public couldn't see (every `private`/`community` group, i.e. most of them) answered 404 to exactly the members it belongs to. A new Bearer-authenticated endpoint — `GET /api/v1/communities/{slug}/events/{id}/ics`, serving the same `text/calendar` attachment behind the events surface's no-oracle 404 — replaces the link with an authenticated download (the same object-URL anchor pattern as the account export), with pending state and the shared `ErrorBanner` on failure. The browser route stays for public events and calendar apps. - Client resilience floor (part of #270). Relative timestamps ("2 min. ago") now keep aging while a screen stays open — a shared minute ticker (`createSubscriber`) re-renders every `RelativeTime` and the offline stale-data banner on the minute and runs only while one is mounted, so a long-lived installed PWA no longer shows fossilized times. The SPA also gains its error floor: a branded, localized root `+error.svelte` (distinct copy for 404 vs other failures, always a link home) replaces SvelteKit's unstyled English fallback on stale or mistyped URLs; a `hooks.client.ts` `handleError` logs unexpected client errors to the console (the only sink there is — expected 404s excluded) and keeps raw error detail out of the UI; and a `` around the app shell's content column degrades one crashed screen render (a single bad post, say) to an inline retry card instead of white-screening the shell — the navigation stays outside the boundary, and the card resets the boundary on navigation, so leaving the broken screen always works. And removing an instance now tears down its realtime socket on every removal path — sign-out and re-sign-in-replacement alike: previously the manager lingered until a reconnect happened to 401, and re-signing in (a fresh instance id) orphaned the old manager entirely. `Button` gained the audit's `href` story along the way (renders a real link with the button look), replacing the error page's hand-rolled copy of that recipe. - PWA action surfaces no longer render the raw server-English `ApiError.message` (issue #253, part of #270). A shared `ui/ErrorBanner` renders localized `errors.` copy instead, so a Danish user sees Danish rather than an English server string, and no server-internal message leaks to the UI. Converted the post composer, the join/leave and invite actions, device/passkey management, and the feed/event/series/files/roster stores' action-error surfaces (each store's action-error state now carries the `ApiErrorKind`, not a message string). Field-bearing forms — inline per-field 422 errors — follow in the next slice. - `Moderation.list_open_reports/2` no longer authorizes the report queue one report at a time (issue #342, audit finding). For a non-community-admin caller, every open report ran its own `Authorization.can?(actor, :moderate_group, group)` check, each resolving the actor's community and group roles fresh — two queries per report, on top of the queue load itself. It now collects the distinct groups the loaded reports belong to and resolves all of them in one batched `Authorization.group_relationships/3` call (#206) — the same shape `CommunityController.groups/2` already uses — then filters in memory, dropping the per-report cost to a fixed few queries regardless of queue size. Community admins still see the whole queue; group moderators still see only reports whose subject lives in a group they moderate, and only that group's. ### Security - The web client's transitive `cookie` dependency is force-resolved to 0.7.2 via a pnpm override (GHSA-pxg6-pf52-xh8x, low): SvelteKit still declares `^0.6.0`, whose parser accepts out-of-bounds characters in cookie names/paths/domains. The vulnerable path only runs in dev/SSR — the shipped client is a static build — but the override clears the advisory at zero API cost (0.7 is compatible) rather than leaving the alert to explain forever. - Account deletion and the data export now require step-up re-authentication (issue #323, updating ADR 0029). `DELETE /me` and `GET /me/export` answer 401 `step_up_required` until the calling device has recently re-asserted a root of trust, closing the two gaps the original step-up scope (issue #294) deliberately left open: a transiently stolen device token could irreversibly destroy the account in one request (destruction needs no persistence), or pull every stored byte of the account's data as one zip (one-shot bulk exfiltration). The deletion flow's typed-back-email confirmation stays, layered after the gate — accidental-click protection on top of, not instead of, the security control. The PWA's delete and export flows open the same step-up dialog and retry, like every other gated flow. The consent copy — the step-up email and the confirmation landing page — now names the widened stakes explicitly (deleting the account, downloading all its data) instead of the original "security settings" phrasing, so a user approving a step-up knows the worst the action-generic grant can do (adversarial review of the #323 widening). - `Event.location_url` now rejects anything but `http`/`https` on write (issue #247, found by the adversarial review of #246): previously it was only length-checked, so an event host could set it to `javascript:...` and have the LiveView event page render a raw `` — `rel="noopener noreferrer"` does not neutralize `javascript:` URLs, and this executed same-origin, i.e. member-facing stored XSS. Defense in depth, outermost first: the changeset validation (`Kammer.Validation.validate_http_url/3`, a new shared helper — `InstanceBookmark`'s near-identical private URL check now delegates to it too) blocks new writes; a data migration nulls any pre-validation row (which also keeps the ICS export's `LOCATION` text clean); the API serializer never emits a non-http(s) `location_url`, covering every current and future client at one choke point; and the render sites — the LiveView anchor (`EventLive.Show`) and both PWA event pages (the anonymous public one already guarded in #246, plus the authenticated member page which had the same gap, both via a shared `safeHttpUrl` helper) — still guard independently. Both sides enforce the same anchored scheme-allowlist rule rather than full URL parsing: RFC-3986 parsing over-rejected IDN hosts (`https://øl.dk`) and pasted maps URLs browsers accept, while diverging from the WHATWG parsing the client used. A repo-wide sweep for other user-entered URL fields rendered as a raw href found none unguarded: `InstanceBookmark.url` already validated the scheme (from its original commit), and the two other raw `href={...}` sites in the LiveView app (`file_href/1` in the file browser and search results, `latest_known_release_url`) are either server-generated paths or sourced from GitHub's release API, not user input. - Slug-addressed group sub-endpoints no longer leak private/sealed group existence through a 403-vs-404 split (issue #339, found by the 2026-07-17 dismissal audit). #224 folded a view-denied group into the same neutral 404 a missing one gets on the group management endpoint itself; eight private `with_group`/`with_feature_group`/ `with_files_group` copies in the post, event, calendar, assignment, availability, decision, file-library, and group-member controllers didn't make the same fold, so an outsider probing a slug could tell a real hidden group from a typo by whether the group's feed, calendar token, assignment/availability/decision list-or-create, file library, or membership surface answered 403 or 404 — a live existence oracle the #224 fix was supposed to close everywhere. Extracted the fold into one shared `KammerWeb.Api.GroupGate.fetch/4` (optionally folding a disabled feature toggle too, ADR 0016) that all nine controllers — the original included — now call; only the `:view_group` resolution folds to 404, so a group a viewer can see but isn't allowed to write to, join, or manage still answers an honest 403. A review pass over the fix then caught and closed four more of the same oracle — uploads, group invites, and the anonymous newsletter-subscribe and guest-comment surfaces (the anonymous two the worst, being tokenless probes) — plus a latent 500 in the assignment/availability/decision create actions, where a denied write or invalid changeset in a _visible_ group escaped `with_feature_group` unhandled instead of answering the honest 403/422. Flipped the tests that had pinned the wrong 403, tightened the `ResourcesTest` and `FeedWritesTest` parity properties (each previously accepted 403 _or_ 404 for an invisible group, which is exactly how this got past them), and pinned every gated surface with a deterministic invisible-group 404 test — added for create on the assignment/availability/decision suites, index and create on the feed and invite suites, and the single write each of the upload, newsletter, and guest-comment surfaces exposes — alongside new 403/422 pins for the formerly-crashing denied and invalid creates. The independent pre-merge review then caught the final member of the class on the event-addressed twin: the anonymous guest-RSVP and guest-claim requests answered 403 for an event in a hidden group but 404 for a missing one (disagreeing with the public read of the same event, which already folded). Folded those too, with a pin asserting the hidden-event and missing-event responses are byte-identical — status alone being what let every earlier oracle hide. ### Added - The `GET /api/v1/instance` `features` object now carries `vapid_public_key` (issue #251, part of #186): the raw VAPID public key the PWA needs for `PushManager.subscribe`, `null` when push isn't configured server-side (and `web_push` now reflects real server config rather than a hardcoded `true`). The notifications settings page reads it and enables the push toggle — the last piece the #186 client work was blocked on. - Account lifecycle over the API and PWA (issue #258, part of #187): the three flows that were LiveView/browser-only — email change, account deletion, and the GDPR export — now exist on the API, so SPEC §12's data rights survive the LiveView cut. `POST /me/email-change` emails the new address a single-use confirmation link landing in the PWA (same notify-the-new-address-only semantics and per-user rate limit as the web flow); `POST /me/email-change/confirm` consumes the token and — because device tokens are bound to the address they were issued under — answers with a rotated `device_token` for the confirming device while every other device signs out. `DELETE /me` deletes the account via `Kammer.Gdpr.delete_account` after the caller types their own email back (`confirm_email`, 422 on mismatch — possession of the device token is the re-auth, the typed address confirms intent); `GET /me/export` streams the same export zip as the browser route. The PWA grows the matching surfaces: an email-change section on the profile page, the `/confirm-email/{token}` landing (which updates the stored instance's email and swaps in the rotated token), and a "Data & account" page per instance with export-to-download and the typed-email delete flow that removes the instance locally on success. EN+DA strings, OpenAPI operations + conformance taps, and API tests covering the full round-trip, deletion semantics, and the export payload. - Report intake for event and assignment comments (issue #262, part of #187): `POST .../events/{event_id}/comments/{comment_id}/report` and `POST .../assignments/{assignment_id}/comments/{comment_id}/report` close the gap #263's contract trace found — the shared comment engine (ADR 0007) already let members report these comments in LiveView, but the API only exposed the post/post-comment intake, so the moderation queue would have lost event/assignment-comment reports at the LiveView cut. Same `Moderation.report_comment/3` behind both: neutral `201 {status: "reported"}`, a repeat report answers identically, an invisible subject 404s byte-identically (no-oracle, #156/#161), and filing draws from the same 20/h per-reporter budget as every other report (the shared acknowledgement now lives in `KammerWeb.Api.ReportIntake`, one definition for all four intake endpoints). PWA: the Report action now shows on event comments and on assignment-discussion comments, reusing the feed's inline reason form + confirmation idiom and existing EN/DA strings; `schema.d.ts` regenerated. - PWA join flow (issue #255, part of #187): the invite link admins share (`/invite/{token}`) now lands on a real page in the Svelte client — until now the PWA had no invite landing and no registration form, so post-cut no new member could have joined. The landing previews what the invite opens (community/group, real-names note) via the until-now-unused public preview endpoint, with dead tokens collapsing into one calm "no longer valid" state; a visitor already signed in to this instance gets a one-tap accept, everyone else branches into sign-in or a new registration form (display name + email → magic link, `POST /auth/register`'s first client). The invite token rides sessionStorage through the email round-trip, so arriving back via the magic link accepts automatically and lands in the joined community/group; the sign-in screen also gains a create-account branch when the probed instance reports open registration. EN+DA strings, and a new e2e spec (`04-join.spec.ts`) drives the whole newcomer path — invite link → register → magic link → joined. - Report creation over the API and the PWA report flow (issue #256, part of #187): `POST .../posts/{post_id}/report` and `POST .../posts/{post_id}/comments/{comment_id}/report` file a moderation report through the same `Kammer.Moderation` functions the LiveView flow uses — the group-feed slice of the gap where the moderation queue would have had no intake once LiveView goes (event- and assignment-comment reporting still needs its API sibling; tracked separately). The answer is a neutral `{status: "reported"}` (201) — a repeat report of the same subject answers identically, and an invisible post/comment 404s like every other feed write (no oracle, #156/#161). Filing is rate-limited to 20 reports per reporter per hour — a fixed anti-abuse backstop (ADR 0027 tier 3), enforced in the context so every surface shares it. The PWA's member group feed gains the matching UI: a Report action in the post menu and on every comment, an inline reason form, and a confirmation once it's sent (EN + DA). - PWA notification center (issue #257, part of #187): the Notifications tab, previously a pure empty-state stub, is now the real in-app list — `NotificationLive.Index` parity over the #173 API. Notifications merge newest-first across every added account (same multi-instance shape as Home/Events, per-instance #159 failure kinds surfaced without blanking the rest), with unread emphasis, optimistic mark-read on tap, mark-all-read fanned out per instance, and tap-through into the right account's group feed or event page. EN + DA strings; store merge/mark-read logic covered by Vitest specs. - Operator-configurable tier-2 deployment settings (issue #234, ADR 0027): the throughput/policy rate limits, token lifetimes, and retention windows an operator legitimately tunes now read from `Kammer.Config`, each with a bounds-validated env override that fails the boot (naming the offending var) on an invalid value instead of silently clamping or being dropped — new vars `RATE_LIMIT_POSTS_PER_5MIN`, `RATE_LIMIT_COMMENTS_PER_5MIN`, `RATE_LIMIT_UPLOADS_PER_10MIN`, `RATE_LIMIT_EVERYONE_MENTIONS_PER_HOUR`, `SESSION_VALIDITY_DAYS`, `API_DEVICE_VALIDITY_DAYS`, `CHANGE_EMAIL_VALIDITY_DAYS`, `CONTENT_RETENTION_DAYS`, `TRANSIENT_UPLOAD_DAYS`, `GUEST_CONFIRM_LINK_HOURS`, and `GUEST_MANAGE_LINK_DAYS` (documented in `.env.example`). Every default is unchanged — the anti-abuse/security rate limits (magic-link, login-code, signup, setup, email-change, invite issuance) deliberately stay fixed constants, per ADR 0027. - Account-less guest surfaces, newsletter, legal pages, and first-run setup over the JSON API (issue #185, the server half of the last #165 parity rung before the LiveView cut). These flows stay tokenless by design (ADR 0024): a guest holds no device token, so the signed link in the URL is the whole credential (ADR 0013) and the endpoints live in the public API, never behind Bearer auth. Guests may RSVP to a public event, claim a signup slot, and comment (each a request → emailed confirm link → record two-step flow), then reach one management page — behind their signed management link — that lists and changes every RSVP, claim, comment, and newsletter subscription they created, or erases all of it (SPEC §6/§8/§12). Newsletter subscribe/confirm mirror the same shape; the RFC 8058 one-click unsubscribe stays a plain-HTTP endpoint (mail clients POST the exact header URL) and is deliberately not duplicated. A public read endpoint serves the privacy and imprint legal pages (operator text or the built-in template). First-run setup is exposed over the API using the _existing_ operator-bootstrap credential — the setup token printed to the server logs — with no new secret: a status probe, a token check, and a one-shot complete that creates the operator, instance settings, first community and group, and invite link, then locks setup permanently. Every flow runs the same context function, authorization, and rate limit the LiveView flows use; an invalid, expired, or used token gets one neutral answer, never an oracle. OpenAPI operations, schemas, and conformance taps included; PWA screens follow as the client half. - Tokenless public content-read endpoints over the JSON API (issue #185 slice B, the server half of the last #187 rung before the LiveView cut): `GET /api/v1/public/communities/:community_slug` (public face plus its `public_listed` groups), `.../groups/:slug` and `.../groups/:slug/posts` (cursor-paginated feed) and `.../posts/:post_id`, and `.../events/:event_id` — enough for the PWA to host the guest RSVP/signup-claim/comment request forms (above) on public content pages without an account. Every read is scoped through the new `Authorization.publicly_readable?/1` — the same `public_link`/`public_listed`-and-not-archived boundary the RSS/Atom feeds and the guest request endpoints already expose, additionally hardened against sealed groups since this is a newly-browsable JSON surface; a community/group/event/post that exists but isn't publicly readable answers the identical neutral 404 a nonexistent one does — no oracle (#156/#161). The group feed and single-post read exclude soft-deleted posts entirely (not shown as tombstones, unlike the authenticated feed) since a guest has nothing to comment on there and the guest-comment request endpoint already refuses one. The `Group` serializer gained `guest_rsvp_allowed`/`guest_comment_allowed`/ `guest_subscribe_allowed` so a client can decide whether to render those forms without guessing from `visibility`/`archived`/`sealed` or trying a request and handling the refusal. OpenAPI operations, schemas, and conformance taps included. - Tokenless public reads of post attachments (issue #185 slice B): `GET /api/v1/public/files/:file_id` and its `/thumbnail`/`/download` twins serve the same bytes and hardening headers as the Bearer-authenticated `/api/v1/files/:file_id` (`KammerWeb.FileServing` shared between them), so an anonymous PWA visitor browsing a public post can load its image/file attachments — previously every attachment URL pointed at the Bearer-protected route, so a guest hit a 401 on every image in a public post's feed. Authorization is a new `Kammer.Files.fetch_public_file/1`, strictly narrower than the actor-based scope check `fetch_accessible_file/2` uses: a file is public only when it's an attachment on a post an anonymous visitor can already read through `PublicController.post/2` (published, not pending approval, not deleted) whose group additionally passes `Authorization.publicly_readable?/1` — never merely because its owning scope (a group/community file space) is publicly viewable, so library files not attached to any visible post stay 404. The `Serializer.post/4` shape gained a `public: true` option so posts read through the `/public` surface emit attachment URLs pointing at the new route instead, without changing the Bearer-authenticated shape any existing client already depends on. OpenAPI operations and conformance included. - Guest, newsletter, legal, and first-run setup screens in the PWA (issue #185, the client half of the surfaces above): every route the guest/newsletter emails and the setup flow link to, all top-level and unauthenticated since these guests and operators hold no device token (ADR 0024). The RSVP, signup-claim, comment, and newsletter confirm links each POST their token once and show a neutral confirmed/error state (a full slot on claim confirm gets its own message rather than the generic "link invalid," since the API already distinguishes it). The guest management page lists RSVPs, claims, comments, and subscriptions behind a signed management link, lets a guest change an RSVP or subscription cadence, release a claim, unsubscribe, or erase everything — erasure requires an explicit in-page confirm step with focus moved to it and returned to the trigger on cancel, given how irreversible it is. Unlike the single-use confirm links, the management token is long-lived (issue #230, ADR 0026), so the emailed link carries it in the URL _fragment_ rather than a path segment — the page reads it from `window.location.hash` and sends it as an `Authorization: Bearer` header, never a URL, so it never lands in access logs, proxy logs, or `Referer`. Legal pages (privacy/imprint) render through the same sanitized Markdown component the feed uses. The first-run setup wizard mirrors `SetupLive.Wizard`'s field set (operator/instance → community/group → done) so the two stay interchangeable while LiveView setup still exists; there is no separate token-verification step (issue #230) — the setup token rides the operator step's form and is only actually checked on the final `POST /setup`, with a bad or expired one surfaced as a neutral error on that step rather than a pre-flight yes/no. EN + DA throughout; public event/group/post browsing and the initial guest RSVP/claim/comment _request_ forms are out of scope here — they need tokenless content-read endpoints for `public_listed` events/groups/posts that don't exist yet, left as remaining #185 scope rather than a new issue. - Public community/group/post/event browse screens in the PWA, hosting the guest RSVP/signup-claim/comment request forms (issue #185 slice B, the client half of the last #187 rung before the LiveView cut — this is the guest-parity surface #187 was gated on). Four new top-level, tokenless routes consume the public content-read endpoints above: `/c/:community` (public face plus its `public_listed` groups), `/c/:community/g/:group` (description plus a cursor-paginated feed with load-more), a post page nested under the group (body, attachments, comments, and the guest comment form), and `/c/:community/events/:event` (details, RSVP counts, signup slots, and the guest RSVP/claim forms). Every guest form honors the `guest_rsvp_allowed`/`guest_comment_allowed` flags the group serializer now exposes — hidden entirely rather than shown and refused — and answers with the same neutral "check your email to confirm" state the request endpoints already guarantee (rate-limited, no oracle on whether an email is already known). The RSVP and signup-claim forms share `guest_rsvp_allowed` (one flag governs both, matching `Authorization.can_guest_rsvp?/1` server-side); each open signup slot reveals its own claim form rather than one shared one, since a claim is scoped to a specific slot. Post/event bodies render through the same sanitized Markdown component the authenticated feed uses; comments render read-only (no reactions, edit, or reply — none are guest capabilities). Attachments link straight to the serializer's plain URLs rather than the authenticated feed's Bearer-fetched object URLs (a guest holds no device token) — today that means an anonymous visitor's image request can 404 until the public-file-serving path lands in a parallel PR, so each image degrades to a neutral placeholder on load failure instead of a broken-image icon. `schema.d.ts` regenerated from the merged spec to pick up the five new `/api/v1/public/...` operations. EN + DA throughout. - Collaborative tools and global search in the PWA (issue #184, the client half of the #165 parity rung): the Svelte screens over the API above. Each group surfaces the tools it has turned on (ADR 0016) — Availability polls (list, create, answer a candidate date, close or convert the winning date into an event), Assignments (the task list: create, claim/release, complete/reopen, delete, and per-task discussion), and the Decisions register (browse, raise a motion, record its outcome). A global search entry queries every community across all signed-in accounts and presents hits community-first (SPEC §10) over posts, comments, events, and files. Every control is gated on the resource's `viewer_can` so a button that would 403 is never shown; per-action failures surface inline without discarding the screen. EN + DA throughout. - Collaborative tools and global search over the JSON API (issue #184, the #165 parity rung): the per-group tools the client previously had no way to reach, each behind its group feature toggle (ADR 0016) so a disabled tool is unreachable. Availability polls — list the open polls, create one, answer a candidate date, and close it plainly or by converting the winning date into an event. Assignments — the group task list, create/edit/delete, claim/release, complete/reopen, and the shared comment engine (ADR 0007). The decisions register — browse it, raise a motion (a feed post carrying the default For/Against/ Abstain vote), and record the outcome. Global search — one community-scoped endpoint over posts, comments, events, and files, applying the exact listing-visibility and folder-permission narrowing the context already enforces, so a result never surfaces what the viewer couldn't already see. Every endpoint enforces authorization in the context, hides a resource the caller may not see behind a 404 (no oracle), carries a `viewer_can` capability list, and ships OpenAPI operations with inline conformance taps. - Management and moderation over the JSON API (issue #183, the #165 parity rung): the community-admin and instance-operator surfaces the client previously had no way to reach. Moderation — the open report queue, resolve (removes the content) or dismiss a report, community email bans (list/add/lift), and the append-only audit log. Community settings updates (name, slug, branding, locale, listing and real-name policy). Group management — create, edit settings, toggle features (ADR 0016), and archive/unarchive. Instance operator settings — read and update the singleton (community-creation policy, storage policy, default locale, instance name, content-minimized emails). Every endpoint enforces authorization in the context, hides rows the caller may not act on behind a 404 (no oracle), and ships OpenAPI operations with inline conformance taps. - Management and moderation screens in the PWA (issue #183): the client side of the management rung — a moderation queue (resolve or dismiss reports, lift bans), community-settings and group-settings screens, and capability-gated entry links on the group page. Every control is shown only when `viewer_can` says it would succeed, so a stale capability degrades to a graceful "not allowed" rather than a broken button. EN + DA. - Invite issuance is now rate-limited (issue #97): a community/group admin may send at most 20 email invites per hour, enforced in `Kammer.Invitations` before any row is written or mail delivered, so the invite endpoint can't be turned into an arbitrary-recipient email relay. A refused invite returns the `rate_limited` code (HTTP 429). Link invites, which send nothing, are never limited. - The last owner of a community can no longer demote themselves into an unrecoverable ownerless state (found by the #182 people-API review): `PUT .../members/{id}/role` refuses it with `422 last_owner`. - Membership, profiles, and the member directory over the API (#182, part of the #165 parity ladder): invite issue/list/revoke and a public preview + accept flow that reports required custom profile fields still missing (ADR 0020); community and group membership lifecycle (leave, policy-aware group join, join-request queue, role changes, removals); the member directory with per-viewer field redaction and custom-field filters; own profile and per-community custom-field answers read/update; per-group notification levels; and device management — listing and revoking browser sessions and API device tokens by id, with revocation severing the device's live sockets (closes #174: API device tokens were invisible to their owner, and the devices page now lists and revokes them too). Group and community payloads gained `my_role`, `join_policy`, and join/request-to-join capabilities in `viewer_can`. In the PWA: the Groups tab became a per-community directory hub (visible groups with roles, plus Members/Invites links gated by `viewer_can`); a member roster screen with custom-field filters and admin role/removal controls; an admin invite screen (shareable links, email invites, revocation); the group page gained join/request-to-join, leave, and a per-group notification-level selector; and the You tab gained per-account Profile (base profile, contact visibilities, and each community's custom fields with the required-fields nag) and Devices (list + revoke) screens. EN+DA throughout. - End-to-end Playwright coverage of the Svelte PWA (issue #235): the client-side counterpart to the LiveView smoke test (`scripts/screenshots.sh`), and the piece of test coverage the LiveView removal (#187) needs before it can proceed without losing browser-level assurance. `clients/web/e2e/global-setup.ts` builds and stages the client at `priv/static/app` (exactly where the Dockerfile's client stage puts it) and boots a real `mix phx.server` against a throwaway `kammer_dev` database — same-origin, so the suite exercises the deep-link sign-in flow's actual `window.location.origin` assumption rather than working around it with a second dev-server port. Three serial spec files cover what a first-time operator and a later anonymous visitor both do: the first-run setup wizard through to a magic-link sign-in (`01-onboarding.spec.ts`); posting with a file attachment, seeing it in the feed, creating an event, and RSVPing (`02-content.spec.ts`); and the tokenless public surface — anonymous browse of a public community/group/post/event, plus the guest comment and RSVP request forms landing on a neutral "sent" state (`03-guest.spec.ts`). A new `e2e` CI job runs the suite headless on every PR, structured like `smoke` (same Postgres service, Nix/Mix caching, and runner-provided Chrome) but skipping the LiveView asset-build step the Svelte client doesn't need. `scripts/e2e.sh` runs the same suite locally. - Service worker, offline reading, and Web Push registration for the Svelte PWA (issue #186, the final parity-ladder rung before the LiveView removal cut, #187). `clients/web/src/service-worker.ts` uses SvelteKit's native `$service-worker` build support (no workbox dependency): it precaches the hashed build bundle, the static files, and the SPA shell (fetched explicitly — it isn't part of either, since `KammerWeb.PwaController` renders it per-request) into a version-named cache, serves precached assets cache-first and everything else network-first, and falls back to the cached shell only on a failed navigation (offline). New versions install silently and take over on reconnect/next-visible rather than mid-session (`$lib/pwa/register-service-worker.ts`), matching the LiveView side's "clients pick up new versions on reconnect" (SPEC §13). `endpoint.ex`'s `Plug.Static` `only:` allowlist gained `service-worker.js` (the one server-side touch this client work needed — the file wasn't in the allowlist, so it fell through to the SPA fallback and served HTML instead of the worker script) with a `no-cache` header so an HTTP cache can't mask a new build from `registration.update()`. Last-fetched-data offline reading (SPEC §14, full write queue stays #137) is a small `localStorage` snapshot per view (`$lib/offline/snapshot-cache.ts`) — the leanest option that satisfies "last data readable offline with a stale indicator" without an offline database or a service-worker-level HTTP response cache (which would also have to reason about auth headers and multi-instance CORS invalidation). Home, the Events tab, and a group's feed each fall back to their last snapshot when every added instance is unreachable, and show a calm `StaleBanner` while doing so. Web Push registration lives on a new per-instance You page (`you/[instance]/notifications`), wired to the existing `POST`/`DELETE /api/v1/push-subscriptions` endpoints (#173) via `$lib/push/api.ts`, and unregisters best-effort on sign-out (`revokeAndRemoveInstance`). Because a browser holds one push subscription per _origin_, and the PWA is instance-served (one origin per instance), push is only manageable from the instance actually serving the page — the settings page detects a mismatch and points elsewhere rather than offering a dead control. `service-worker.ts`'s `push`/`notificationclick` handlers and `$lib/push/notification-routing.ts` translate a notification's server-sent link (`Kammer.Notifications.push_payload/4`'s `{title, body, url}`) into the right in-PWA account/community route, landing on an already-open window via `postMessage` or a cold-opened one via a `notify` query param the root layout resolves (a service worker has no `localStorage` access, so it can't resolve instances itself). Actually subscribing is currently blocked on issue #251: `GET /api/v1/instance` exposes whether a server has push configured (`features.web_push`, unchanged) but not the VAPID public key `PushManager.subscribe()` needs — the settings page shows an honest "almost there" state instead of a non-functional button; the client plumbing (`subscribeToPush`/`unsubscribeFromPush`) already takes the key as a parameter and needs no further change once #251 lands. Manifest/icons (shipped earlier) were manually re-verified against an installability checklist: `start_url`/`scope` within the service worker's scope, 192/512/maskable icons, `standalone` display — no Lighthouse available in this container. ### Changed - Test-suite prune (T2 of the #208 overhaul, per the #207 standard): ~30 ceremony/duplicate tests removed or merged across the LiveView, core-context, worker, and API suites — presentation trivia on the feature-frozen LiveView UI (empty states, link-visibility checks, "renders page" smokes, nav-click tests, `layouts_test.exs` and `error_html_test.exs` wholesale), phx.gen.auth Ecto boilerplate, the third/fourth duplicates of the #167 provenance rule, and the unfalsifiable `update_check_worker_test`. Defused the #187 coverage time bomb: tests for surviving HTTP endpoints (ICS calendar feeds, file download incl. anonymous denial, RFC 8058 one-click unsubscribe) moved out of death-row LiveView flow files into new `calendar_controller_test.exs`, `file_controller_test.exs`, and `newsletter_controller_test.exs`. The API conformance suite was restructured to the inline-tap idiom: `assert_operation_response` now taps the real interaction tests (posts/comments, events, notifications, push subscriptions, auth incl. the passkey ceremony — adding previously missing taps for `auth_register`, `auth_request_link`, and `auth_revoke`), and `schema_conformance_test.exs` shrank to the read-only operations with no other home. Several half-asserted tests were sharpened rather than deleted: the event-reminder test now proves the no-RSVP member got nothing, the reschedule test proves the new job carries the new start time and no stale email went out, the guest-RSVP token test now exercises a genuinely expired token, the recurrence cancel-authz test probes a fellow member instead of a stranger, and the User inspect test now actually sets the redacted `ics_token`. ### Fixed - Configuration-layer hygiene, the no-decision "clear fixes" slice of the ADR 0027 audit (issue #234). `MIN_CLIENT_VERSION` now actually wires the advertised minimum-client-version knob to an env var (`config/runtime.exs`, documented in `.env.example`) — previously `Kammer.min_client_version/0` read a config key nothing ever set, so the advisory floor could never be raised without hand-editing source. The `"Kammer"` product-name default, previously re-typed at ten `Application.get_env(:kammer, :product_name, "Kammer")` call sites across the notifiers, digests, newsletters, and templates, now has one accessor (`Kammer.product_name/0`, mirroring `min_client_version/0`) that every call site uses instead. The endpoint's request-body length ceiling (previously a hardcoded `128_000_000`) now derives from `UPLOAD_MAX_MB` plus fixed multipart headroom via a small wrapper plug (`KammerWeb.Plugs.BodyParsers`), so raising `UPLOAD_MAX_MB` no longer requires the source edit `.env.example` used to ask operators for — `Plug.Parsers` bakes its options in at compile time, before `config/runtime.exs` runs, so the wrapper recomputes them per request instead. Several bare magic `limit:` literals (`Digests`, `Newsletters`, `Feed.list_home_feed/3`, `Events` past-events) are now named module attributes with a one-line rationale, and the duplicated 24-hour event-reminder lead time (`Kammer.Events` and `Kammer.Workers.EventReminderWorker` each hardcoded `-24, :hour`) is now a single `Kammer.Events.reminder_lead_hours/0` accessor both read, so they can't drift apart. - Documentation drift between the spec/README/moduledocs and the shipped code (issue #93, round-2 quality audit). `SPEC.md` cited a nonexistent `RESTORE.md` (the restore walkthrough lives in `docs/backups.md`) and still called the cross-community **Home** feed "roadmap" though it shipped (`Kammer.Home`, ADR 0015); the README still listed passkeys as roadmap though they shipped (ADR 0018). Four moduledocs cited the wrong SPEC section (Search → §10, digests → §9, not §16 "Architecture strategy"), `Notifications` implied digests were unbuilt, and `Kammer.Repo`/`Kammer.Mailer` had no `@moduledoc` despite CONVENTIONS requiring one on every module. - Guest/setup public API hardening (issue #230, refining #185/#229 — the owner asked for a "do it properly" pass rather than matching the LiveView precedent). `POST /api/v1/setup/verify-token` is removed: it was a boolean oracle over the setup credential, and `complete` already validates the token itself before doing any work. `POST /api/v1/setup` is now rate-limited per IP (10/hour, fixed — no config knob, same stance as every other security limit in `Kammer.RateLimit`), defense-in-depth for the one window in an instance's life with no operator around to notice abuse. The guest **management** link's token — long-lived, unlike the single-use confirm tokens — moved from a URL path segment (`/guest/manage/{token}`) to an `Authorization: Bearer` header (`/guest/manage`, read via `GuestController.fetch_manage_token/1`): a path segment for a credential that stays valid indefinitely was leaking into server/proxy access logs, browser history, and `Referer`. The emailed management link now carries the token in the URL fragment instead, which browsers never send to any server; a missing, malformed, or forged Authorization header answers the same neutral 404 an invalid token already did — checked before any request-shape validation, so the request body can never be probed without an authentic token. This covers the API surface; the recurring newsletter emails still carry a manage token in their URL and `List-Unsubscribe` header and are hardened separately. See ADR 0026 for the full reasoning, including why the setup token and the single-use guest confirm links deliberately keep their current transport. - Email-change confirmation is now rate-limited (issue #97, security-hardening pass): any signed-in user in sudo mode could loop the account-settings email form to send the branded confirmation to arbitrary addresses (an email-flooding relay) and pile up never-cleaned token rows. Issuance is now capped per acting user (five per hour) in the `Accounts` context, checked before any token is written or mail sent — a refused request does neither. - The test suite no longer deadlocks intermittently on the instance-settings singleton (issue #215): the row is seeded once in the test bootstrap, so concurrent sandboxed transactions never race the lazy unique-index insert (production was already safe — the insert is an idempotent upsert). - Deleting a folder that contained files crashed (a foreign-key cascade interplay deleted the folder's file entries out from under their stored files) instead of honoring the documented semantics — files fall back to the space root and outlive their folders. A migration releases folder contents on delete, and the same-name entry lookup now tolerates the sibling entries a release can create. Found by the new API folder-deletion success test (part of #208). ### Security - Bumped `plug` (1.20.2 → 1.20.3) and `postgrex` (0.22.2 → 0.22.3, plus `db_connection` 2.10.1 → 2.10.2) to clear two newly-published advisories against the pinned versions: CVE-2026-56814 (plug's multipart `:length` limit not charged for part headers → unbounded temp-file creation, a DoS) and CVE-2026-58225 (Postgrex dollar-quote injection in the reconnect notification replay → notification DoS). Both had started failing `mix deps.audit` in CI on every branch. ### Added - Trusted reverse-proxy support (issue #162, security-hardening pass): a new `TRUSTED_PROXIES` env var (comma-separated IPs/CIDRs) names the proxies allowed to speak for clients. When the TCP peer is on the list, the real client IP is recovered from `X-Forwarded-For` (rightmost address that isn't itself a trusted proxy) and rate limits key on it — signup, magic-link, sign-in-code, and guest budgets become per-client again instead of instance-wide behind a proxy. From anyone else the header is ignored entirely, so a client that reaches the port directly can't spoof its way past the limiter; unset (the default) ignores the header everywhere, and an invalid entry fails the boot. Applied on both the plug pipeline (`conn.remote_ip`) and the LiveView socket path (`peer_data` + `x_headers` connect info). Hand-rolled rather than the `remote_ip` hex package (SPEC §22's minimal-internal-version rule): that library honors forwarding headers without ever checking the peer and treats all private/loopback ranges as implicit proxies — the two defaults #162 exists to close. - Product-version surface (issue #204, implementing the ratified versioning strategy #203): `mix.exs` is now the explicit single source of truth (`0.1.0-dev` until the first tagged release), exposed at runtime through `Kammer.version/0` so no code hardcodes it, and the public `GET /api/v1/instance` capability endpoint — already the RFC 0001 discovery surface carrying `version` and `api_versions` — additionally reports `min_client_version` (null until a release needs to fence out old clients), the foundation the future native-app handshake (#131) negotiates against. The PWA's You screen shows each signed-in instance's server version as a small about line (EN + DA). - Client test additions from the test-suite audit (#208): XSS assertions for `renderInlineMarkdown` — the comment-body route into the PWA's single `{@html}` sink previously had zero sanitization tests (raw-HTML escape, `javascript:`/`data:` link refusal, image downgrade, no block elements); the untested rollback mirrors — `feed-store.reactComment`'s conditional rollback (clean and echo-intervened branches) and the event store's optimistic RSVP snapshot rollback (new `event-store.spec.ts`); the feed store's load-401 → `noteInstanceAuthFailure` socket bridge (mock was wired but never asserted); and the one no-observable-assertion test (`requestLink` "resolves on success") strengthened to assert the POSTed endpoint, method, and body. - Per-viewer capabilities on the API (issue #199, groundwork for the #165 parity ladder): serialized posts, groups, and communities now carry a `viewer_can` array naming the action-oriented rights the calling viewer actually has — `edit`/`delete`/`pin`/`moderate` on a post; `post`/`moderate`/`manage_group`/`manage_members`/`create_event`/ `upload_file` on a group; `manage_community`/`create_group`/ `view_member_directory` on a community — so clients can hide controls a viewer would only be `403`ed on click, instead of offering them to everyone. Each capability is computed from the same pure `Kammer.Authorization` decision the controllers enforce (present IFF the action would succeed) and adds no queries: controllers thread the viewer's already-loaded relationship into the serializer. Consumed by the #179/#182/#183 client screens (client wiring is a follow-up; the API field is the enabler). - Test-suite audit follow-up, security & correctness batch (part of #208, per the #207 standard): domain-level authorization negatives that were the only gate on their function (`Feed.approve_post/2`, `Files.delete_file/2`); an archived-groups property extension over the membership/admin actions (`join`, `request_to_join`, `create_group_invite`, `approve_group_members`) whose archived guards no test protected; a home-feed test pinning that pending and scheduled posts never leak into other members' home feeds; four promise-breaking tests made real (the decisions register-entry rollback is now actually induced, the `User` inspect test now sets the one redacted field, the ADR 0016 gate test now queries the unauthorized viewer and the nonexistent-event surface, the passkey stale-sign-count test is named for the bookkeeping rule it pins); GDPR coverage for the export controller route and for comments/uploaded files in the export zip plus comment anonymization on erasure; API coverage for folder-deletion success and `DELETE /events/{e}/slots/{slot_id}` with their conformance taps; pagination edge-contract assertions (garbage cursor, absurd limit); an events-write leg on the transport-parity property; and `file_versions_test.exs` no longer runs async while swapping the global uploads path. - Files over the API and in the Svelte PWA (issue #181, part of the #165 parity ladder): a group's file library — browse folders and files with breadcrumb navigation, upload new files, re-upload the same name as a new version (ADR 0017), view version history and download any version, and — for managers — create/delete folders, set the read/write preset overrides (ADR 0009), and delete files and versions. New `/api/v1/communities/{c}/groups/{g}/files` and `/folders` endpoints (list/detail/upload/version-upload/delete plus folder create/override/delete), all through the existing `Kammer.Files` context so the folder-permission invariant is enforced in one place; genuinely no-oracle (a folder or file the caller can't see 404s to every verb, never 403), the per-file size/quota 413 is honored on this path too, and the group file space stays behind the files feature toggle (ADR 0016). A files screen inside each group in the PWA — folder browsing, upload, version history, download — with EN + DA, dark mode, reduced-motion, and AA contrast. - Events over the API and in the Svelte PWA (issue #180, part of the #165 parity ladder). API: full write parity under `/api/v1` — create an event or a bounded recurring series (`POST .../groups/{group}/events`, ADR 0019; the response is the first occurrence carrying its `series_id`), edit this occurrence and delete (creator/moderator), per-occurrence cancel/reinstate (`.../cancellation`), signup slots (add/delete for managers, claim and release for members — a full slot refuses with `422 slot_full`, never overbooking), and the shared comment engine on events (create/edit/delete/react). The `Event` serializer now carries `series_id`, `cancelled`, `comments_locked`, and its `comments`. PWA: an Events tab (merged upcoming events across every account, bucketed by community with filter chips and a calm agenda view) and an addressable event detail screen (RSVP with counts, signup slots, comments, an "add to calendar" ICS link) with a create/edit form for authorized roles. EN + DA, dark mode, reduced-motion, AA. Every event write is genuinely no-oracle (issues #156/#161): an event the caller can't see answers `404` to every verb, never `403`, so a hidden event is indistinguishable from a nonexistent one. - Reacting to a comment that hangs off an event or an assignment no longer crashes (`Kammer.Feed.toggle_reaction/3` resolved the host group only for post comments); it now resolves the group for every comment kind via `comment_context/1`, and only post reactions broadcast a feed update. - Home and group feed screens in the Svelte PWA (issue #179, part of the #165 parity ladder): the merged, community-first Home (recent activity and upcoming events across every added account, bucketed by community with filter chips and per-instance failure banners) and the group feed — chronological/activity sort, pinned-first, cursor pagination, and live updates over the #173 realtime channels. Full write parity on the #178 endpoints: a composer with Markdown (safely rendered — `html: false`, link-validated, the one audited sink), poll builder, attachment upload, and acknowledgment-required toggle; reactions and poll votes are optimistic, post creation is post-then-insert (id-keyed against the channel echo); one-level comment threads with compose/reply/react/edit/delete. Per-instance realtime sockets with manager-owned exponential-backoff reconnect that surfaces auth failure for re-sign-in. EN + DA, dark mode, reduced-motion, AA contrast throughout. - Feed write parity over the API (issue #178, part of the #165 parity ladder): reaction toggles on posts and comments; poll voting (`PUT .../poll/votes` sets the whole selection — single-choice keeps the first id, empty list unvotes) with the poll's `my_votes` in the response; post create takes a real, documented `poll` object (options in list order) and `stored_file_ids`; acknowledgments (`PUT .../acknowledgment`, idempotent) plus the author/admin acked-vs-pending view (`GET .../acknowledgments`); post edit (author), delete (author soft-deletes to a tombstone, moderators hard-delete via `?hard=true`), pin/unpin (moderators); comment edit (author, new `Feed.edit_comment/3` — comments now carry an `edited_at` marker) and delete; multipart feed-attachment uploads (`POST .../uploads`, same context path as the LiveView composer) and Bearer-authorized file serving (`GET /api/v1/files/{id}`, `/thumbnail`, `/download` — the browser file routes' logic extracted into a shared `FileServing` module). Serializer/schema growth: post `attachments`/`my_reactions`/`my_acknowledged`/`acknowledged_count`, comment `edited_at`/`reactions`/`my_reactions`, poll `my_votes` (issue #81). Every write goes through the same context functions and authorization as LiveView; writes address posts via `fetch_visible_post`, so an invisible post answers 404 to every verb (issue #156's no-oracle rule extended to writes per issue #161, covered by a write-parity property test). Context hardening the raw transport demanded: feed uploads now require posting rights in the context, `stored_file_ids` must be the author's own uploads into the group (422 otherwise), and locked-comment/closed-poll refusals carry stable `comments_locked`/`poll_closed` error codes (422, previously a generic 400). - PWA-native sign-in, server side (issue #177, ADR 0024): API-initiated sign-in emails now deep-link into the instance-served PWA (`/app/sign-in/{token}`) and carry a short single-use sign-in code (8 characters, Crockford base32 without lookalikes, 15-minute lifetime, hashed at rest) for cross-device sign-in — `POST /api/v1/auth/exchange` accepts `email` + `code` as an alternative to `magic_token`, with per-email and per-IP attempt rate limits so the short code cannot be brute-forced. Passkey sign-in lands in the API too: `POST /api/v1/auth/passkey/challenge` (usernameless WebAuthn assertion options with a signed, short-lived challenge token — no account-enumeration surface) and `POST /api/v1/auth/passkey/verify` (assertion → device token, same response shape as exchange). The web-initiated LiveView flow is unchanged (bugfix-frozen per ADR 0024); passkey _registration_ stays web-only until the You-tab arc (#182). All new operations are in the OpenAPI document with conformance tests; sign-in emails are localized EN + DA. - Instance-served PWA (issue #176, ADR 0024): the release image now builds the Svelte client (new Dockerfile client stage — node/pnpm, `pnpm build`) and serves it at `/app` on the instance's own domain, from `priv/static/app`. Real files come off `Plug.Static`; everything else under `/app` falls back to the client's `index.html`, so client-side routes — most importantly the magic-link landing `/app/sign-in/{token}` — deep-link straight into the SPA. LiveView keeps `/` until the removal cut (#187); the mount point is a single config key (`:pwa_base_path`) matched by the client's `paths.base`. A dev server without a built bundle answers `/app` with a plain-text pointer to the client dev workflow instead of a 500, and the Docker workflow's boot check now asserts the shipped image serves the PWA (base path and deep link both). - Svelte client foundation (issue #32, ADR 0024): the design system in SPEC §21's language (warm neutral palette, hairline borders, typed UI primitives) with first-class light/dark themes (system-following plus a persisted manual override, no flash on load) and global `prefers-reduced-motion` support; the app shell — bottom tab bar on mobile, sidebar on desktop, five tabs with calm empty states and a route guard back to sign-in; EN + DA i18n from day one (typed message catalogs — a missing Danish key is a compile error); the full sign-in flow (instance URL probe → request link → paste the link/token or follow a `/sign-in/{token}` deep link → exchange), per-account sign-out from the You tab, plus theme and language controls. Hardening from review findings: the instance store gets a versioned envelope with per-element validation and v0 migration (issue #158), merged-home failures carry an `auth`/`network`/ `server` kind so screens can tell "sign in again" from "retry" (issue #159), and the vacuous client tests were replaced with real ones — including Authorization-header and store-migration coverage (issue #160). Installability lands too: a geometric "K" mark (any + maskable SVG) and a completed manifest close issue #145. - Realtime and notifications for the JSON API (issue #30, ADR 0014): Phoenix Channels at `/api/socket` authenticated with the same device token as REST — `feed:group:` re-exposes the live feed events LiveViews already react to (join gated by the same `:view_group` decision, every payload re-fetched per viewer so pending/scheduled posts never leak) and `notifications:user:` streams the owner's notifications, both shaped by the one REST serializer. New endpoints: `GET /api/v1/notifications` (cursor-paginated, newest first), `PUT .../notifications/{id}/read` and `.../notifications/read-all` (foreign ids answer 404 — no existence oracle), and `POST`/`DELETE /api/v1/push-subscriptions` mirroring the browser's Web Push registration flow, upsert semantics included. All operations documented in the OpenAPI contract with response-conformance tests. - OpenAPI schema-conformance tests (issue #151, audit-driven): every API operation's real response is now validated against its documented schema (`schema_conformance_test.exs`), closing the gap where the route-level drift test couldn't see field-level lies — which is exactly how #154 went unnoticed. Also extracts the shared `api_conn/1` test helper (`KammerWeb.ApiHelpers`). - CORS on the JSON API (issue #150): `/api/v1` now answers cross-origin requests and preflights with `Access-Control-Allow-Origin: *` by default — required for the multi-instance Svelte client (any web client at any domain) and safe because API auth is a Bearer device token, never a cookie. Instances that want a tighter policy can set `API_ALLOWED_ORIGINS` to a comma-separated origin allow-list. The browser-facing LiveView app keeps the same-origin default. - Svelte PWA client scaffold at `clients/web/` (issue #32, ADR 0001): SvelteKit + TypeScript, static adapter in SPA mode (`ssr = false` — the client is a pure session-holder talking to remote instances, nothing server-rendered), Tailwind CSS, Prettier/ESLint, Vitest, and a minimal PWA manifest. Own CI job (`web-client`, node/pnpm), not inside the Elixir gate; root Prettier excludes `clients/web/` since it owns its own config/plugins (Svelte, Tailwind class sorting). Screens (sign-in, merged home, community list, group feed, events) land in follow-up PRs — this is tooling-only, verified to lint/typecheck/test/build clean. - Generated TypeScript API client for the Svelte PWA (issue #32): `openapi-typescript` types generated from `/api/v1/openapi.json` (`clients/web/src/lib/api/schema.d.ts`, regenerated via `pnpm run generate:api`, never hand-edited) plus a thin `openapi-fetch`-based wrapper (`client.ts`) creating one typed client per added instance, per ADR 0001's multi-instance model. - Multi-instance session-holder core for the Svelte PWA (issue #32, ADR 0001): `clients/web/src/lib/instances/` — `instanceStore` (localStorage- backed, dedupes by instance+account so re-authenticating replaces rather than duplicates), `probeInstance`/`requestLink`/ `exchangeAndAddInstance`/`revokeAndRemoveInstance` (the sign-in and device-token lifecycle), and `fetchMergedHome` (client-side merging of `GET /home` across every added instance, timing out per-instance so one unresponsive server can't hang the whole merged view). No screens yet — this is the session/data layer they'll be built on. - OpenAPI response schemas for `/api/v1/instance`, `/api/v1/auth/*`, and `/api/v1/home` (previously untyped `object()` placeholders generating `Record` in the TypeScript client — silently defeating the "generated, not hand-written" client for exactly the endpoints a multi-instance client needs first). New `Schemas.Instance`, `Schemas.AuthUser`, `Schemas.AuthRegisterResponse`, `Schemas.AuthExchangeResponse`, `Schemas.StatusResponse`, `Schemas.HomeGroupSummary`, `Schemas.HomeResponse`. - `POST /api/v1/auth/register` (issue #30): the API can now create accounts, not just authenticate existing ones — mirrors `UserLive.Registration` exactly (same changeset, same per-IP rate limit, same confirmation-email step). Registration decision resolved: open, not web-only — the Svelte PWA and native apps need full sign-up parity with the web UI. `/api/v1/instance`'s `features.registration` flag flipped from `"web_only"` to `"open"` accordingly. - File search + text extraction (SPEC §10/§16): the last piece of global search — files now surface by filename and by extracted text (PDF via `pdftotext`, plaintext read directly), alongside posts, comments, and events. Text extraction runs off the request path in an Oban job after upload, with a graceful skip for unsupported content types. File search rides the folder-permission invariant (ADR 0009) exactly: a loose SQL/full-text candidate query is narrowed in Elixir with the same `Authorization.can_read_folder?/4` decision `Kammer.Files.list_files/3` uses, so a file in an `admins_only` folder never surfaces regardless of its group's visibility. - Newsletter subscriptions (SPEC §8, ADR 0013 extended): anonymous visitors on a public group's page can subscribe by email — no account, double opt-in through a signed confirm link, choice of every-post or a daily/weekly digest. Delivery reuses `Kammer.Digests`' cadence math (own Oban worker, offset cron tick) and every email carries a one-click `List-Unsubscribe` header (RFC 8058) alongside a signed management link that reuses the existing guest-manage page — change cadence or unsubscribe, same as changing a guest RSVP. - Report surfaces on event and assignment pages (SPEC §11): the "Report to the moderators" flow the group feed already had for posts and comments now covers comments on events and assignments too — `Kammer.Moderation.report_comment/3` already handled any comment regardless of subject, only the UI was missing. The modal and its handlers moved into a shared `KammerWeb.KammerComponents.report_modal/1` / `KammerWeb.ReportHandlers` pair, so all three pages use the identical code path instead of three copies. Closes out HANDOFF's Moderation gaps backlog. - Instance-wide bans (SPEC §11): operators get a second, broader ban list at `/instance/moderation` — ban an email instance-wide and it can't rejoin _any_ community on the instance, not just one. Same choke-point as the existing community ban (`Communities.add_member/3`), same email-keyed design (blocks a future signup too, not just an existing account), same two-step "demote before you ban" rule for other operators. Banning an existing account strips its memberships everywhere at once; each affected community's own admins see the removal in their audit log. - Activity-sort feed view (SPEC §5, ADR 0006): an opt-in "Activity" ordering next to the default "Newest" on every group feed and the Home feed — posts bump to the top on their newest comment, forum-style, while pins still sort first. Chronological stays the default everywhere; no ranking, no algorithm, exactly the one alternate ordering ADR 0006 always reserved. Persists per user, toggled from a small dropdown right on the feed. - Custom profile fields and the member roster (SPEC §4, ADR 0020): community admins define fields (text or single-choice) from `/c/:slug/settings` — "Instrument", "Section", "Dietary needs" — each with members/admins-only visibility and an optional `required` flag. Required fields hard-block at invite acceptance (a small "before you continue" step collects them); making an existing field required only nags already-joined members with a banner and a link, never a lockout. The member directory shows each person's visible answers and gains filter dropdowns for single-choice fields — the roster. Also added personal profile fields (bio, pronouns, and phone/email/other contact info, each independently hidden/members/admins-only, hidden by default) under Account settings. - Content-minimized email mode (SPEC §9, ADR 0011): an operator-only instance toggle at `/instance/settings` — new, linked from the instance home — that strips digest emails down to a per-group post count and a link, dropping author names and post excerpts. Per-event notification emails never carried post content in the first place, so they're unaffected; auth and RSVP emails stay exempt by construction. - RSS and Atom feeds for public groups (SPEC §8): every `public_link` and `public_listed` group exposes `/feed.rss` and `/feed.atom` — no account, no secret token, gated by the same visibility check the group page itself already uses. A feed reader can follow a group's posts without ever creating a Kammer account. Linked from the group page for anyone who can see it. - Admin update notice (HANDOFF §5.6): a daily, privacy-respecting check against this project's GitHub releases surfaces "a newer Kammer exists" to instance operators on the home page — no payload beyond the version fetch, and `DISABLE_UPDATE_CHECK` turns it off entirely. - Recurring events (SPEC §6, ADR 0019): weekly, biweekly, or monthly series bounded by an end date, up to 52 occurrences. Each occurrence is a real, independent event — RSVP, comment, and add it to your calendar exactly like a one-off — so cancelling or moving a single date needs no special handling. A new series page lists every occurrence with cancel/restore and shows the organizer attendance matrix (members × upcoming instances). - Passkeys (SPEC §16, ADR 0018): register a WebAuthn credential from the Devices page after your first magic-link login, then sign in usernameless — your device's fingerprint, face, or screen lock, no email hop. Built on Wax; registration and sign-in verify entirely server-side, with a hand-crafted-ceremony test suite exercising the real cryptographic path (no browser required). Losing every passkey still leaves magic-link sign-in as the fallback. - Audit log (SPEC §11): every role change, ban/unban, content removal via moderation, settings update, and group deletion — plus community-admin overrides into groups, flagged as such — writes a plain-language entry to an append-only log, visible to community admins from the moderation page. Entries stay readable even after the row they describe is gone; the log is deliberately not itself audited and never blocks the action it records. - GDPR data rights (SPEC §12): download everything the instance stores about you — profile, posts, comments, reactions, poll votes, RSVPs, signup and assignment claims, availability answers, and every file you uploaded — as one zip, and delete your account self-serve. Deletion removes your identity and personal records immediately; your posts and comments stay so group history isn't broken, shown as "Deleted user". Both live under Account settings. - Moderation: a Report action on every post and comment (one open report per person per subject — signal, not spam), a queue for community admins and group moderators with dismiss / remove-content actions, and community bans — banning removes the member everywhere and blocks their email from rejoining through any invite until the ban is lifted. - Email digests, strictly opt-in: choose daily or weekly (Mondays) under Account settings and get one calm summary — new posts across your groups and the coming week's events, in your language and timezone. Quiet periods send nothing: no email is better than a hollow one. - Backups (SPEC §14): `mix kammer.backup` / `Kammer.Release.backup/1` write a restorable snapshot — pg_dump custom-format database dump plus an uploads tarball — with optional age encryption and per-kind retention pruning. Setting `BACKUP_DIR` turns on a nightly scheduled backup. Restore guide (both directions, verified steps) in docs/backups.md. - Global search within a community: one box over posts, comments, and events (Postgres full-text, mixed-language friendly), filtered by the same listing visibility as everything else — property-tested so search can never surface content from a group you couldn't see listed. Anonymous visitors search exactly the public face. File search follows once it can ride the folder-permission invariant. - Decisions register (per-group toggle, off by default): raise a motion — it lands in the feed as a post with a For / Against / Abstain vote — then record the outcome (adopted, rejected, noted, with a note for the record). The register lists every motion and outcome chronologically: minutes-grade institutional memory, built for board groups. - Assignments (per-group toggle, off by default): a flat task list — open, claimed, done, nothing else. "Who takes this?" is one tap, several people can hold the same assignment, anyone can mark it done (the record shows who), and each assignment carries a discussion thread through the same comment engine as posts and events. No boards, no sprints — associations run on lists. - Availability polls ("Date finding", per-group toggle, off by default): propose candidate dates, members answer yes / if needed / no on a shared grid, and closing the poll turns the winning date into a real event with one click. Open polls appear on the events page under "Finding a date". - Signup slots on events: "bring cake ×2, drive ×4" — event managers add capacity-bounded slots, members claim with one tap (never overbooked, enforced in the database), and on public events guests sign up through the same email-confirm flow as guest RSVPs. The guest management page lists signups next to RSVPs and comments; the API event detail now includes slots. - Guest comments on posts in public groups that opt in (`members_and_guests` comment policy): name + email + comment, a signed confirm link (nothing stored until followed — the comment travels inside the link), then a moderator approval queue inline in the feed. Pending comments are invisible to everyone but moderators. The guest management page now lists everything a guest created — RSVPs (changeable) and comments — with one-click full erasure, and signing in with the guest's email claims comments too. - OpenAPI document at `GET /api/v1/openapi.json` — the machine-readable API contract clients generate from, drift-guarded against the router by a test. - Versioned files: uploading a file with the same name into the same folder now creates a new version instead of a duplicate. Every file has a browsable version history (uploader, time, size, download); single versions can be deleted (never the last); deleting the file removes all versions. Admins can cap history per space ("versions to keep"); default is unlimited. - JSON API v1 (`/api/v1`): passwordless device-token auth (request link → exchange → revoke, tokens hashed at rest and revocable from the devices page), instance capability discovery, communities and groups, cursor-paginated group feeds with post and comment creation, events with RSVP, and the merged Home — every route through the same authorization module as the UI, with a property test guaranteeing API/UI visibility parity. - Home: the instance start page now merges upcoming events and recent activity across all communities and groups you belong to — strictly chronological, read-only, with a per-group "Show in Home" switch on every group page (on by default, sealed groups included). - Per-group feature toggles: group admins choose which tools the group shows (events, files; the feed is always on). Turning a feature off hides it everywhere — navigation, listings, ICS feeds, guest RSVP — without deleting anything; turning it back on restores everything. - Guest RSVP on public events (no account needed): name + email, a signed confirm link that records nothing until followed, a confirmation email with calendar file, and a management link to change the answer or erase all guest data. Signing in with the same email claims guest history automatically. Event pages in public groups are now viewable without an account. - Passwordless authentication: magic-link sign-in, revocable sessions with device overview, rate limiting on link requests. - Communities and groups: multi-tenant instance, community switcher, four group visibility presets, join/posting/comment policies, roles, invite links, post-as-group, irreversible sealed groups, archiving. - Central authorization module (`Kammer.Authorization`) with property-based test suites for the permission matrix, sealed-group semantics, and the file-visibility invariant. - Feed: Markdown posts, image uploads (re-encoded, metadata stripped, thumbnailed), polls, attachments, emoji reactions, single-level comments, mentions (@everyone/@admins/display names), pinned + scheduled + acknowledgment-required posts, edit history, soft-delete with 30-day purge, live updates. - Events: timezone-aware single/all-day/multi-day events, RSVP, comments, email reminders with ICS attachments, secret-token ICS feeds per group and per user. - Files: community and group spaces, shallow folder trees, permission presets under a centrally enforced visibility invariant, auto-collections, local-disk and S3-compatible storage, optional quotas. - Notifications: in-app center, email, and Web Push behind a pure §9 channel matrix ("highlights" defaults; broadcast groups default to everything); per-group levels. - Hybrid first-run setup: env-always-wins boot initialization, token-gated /setup wizard (operator, instance settings, first community/group, invite link, SMTP-testing magic link), permanent lock, optional one-click-removable demo community. - Legal pages (privacy policy, imprint) with built-in templates, public routes, operator editing, and an operator nag until published. - `/healthz` endpoint; Docker/compose deployment (app + Postgres + optional MinIO) with migrations on boot. - English and Danish translations throughout, including emails. - Repository automation: Dependabot with gated auto-merge, CodeQL, dependency review, Gitleaks, Docker image CI publishing to GHCR, an importable branch-protection ruleset, CODEOWNERS. - Reproducible dev environment: Nix flake (canonical), direnv `.envrc`, `devbox.json` — one toolset, three entry paths, reused by CI. - Phoenix 1.8 application scaffold (LiveView, Tailwind, UUID primary keys). - Engineering-standards toolchain: mix format, Credo strict (incl. custom single-letter-variable ban), Dialyzer, Sobelow, ExCoveralls with coverage floor, mix_audit/hex.audit, lefthook hooks, commitlint, GitHub Actions CI, warnings-as-errors. ### Changed - Strategic pivot recorded (ADR 0024): the Svelte PWA — instance-served by the Phoenix release at each instance's own domain — is the product UI, and LiveView is a first-build stepping stone to be removed from the repo entirely, not a permanent companion (LiveView can't do offline mode or multi-instance session-holding/merging, the two capabilities the product depends on). LiveView is feature-frozen as of now (bugfixes only) while the PWA climbs a surface-by-surface parity ladder — each surface ships with full write parity, including its missing API endpoints — with removal in one cut at full member+admin+guest coverage. SPEC.md §1/§16/§21 corrected to stop describing LiveView as the product UI (and §21 gains the community-first IA principle); AGENTS.md now carries the freeze policy so future sessions don't add LiveView features. Transition umbrella: #165. - SPEC.md §16's "explicit non-goals" list corrected: native apps, offline support (beyond v1 app-shell caching), chat/DMs, E2EE, event ticketing/capacity/waitlists, collaborative document editing, video upload, storage billing, and group type templates were listed as permanently excluded when they're actually owner-confirmed future roadmap — several (native apps, offline support, group type templates) were oversold exclusions even by the ADRs that originally scoped them narrower. Each now has a tracking issue (#131–#138, #22) and the list itself is corrected so it stops reading as settled product philosophy. ActivityPub was considered directly and stays excluded, now with documented reasoning (ADR 0023) instead of a bare mention. Native apps' and offline support's re-scoping is recorded in ADR 0022, amending ADR 0012. See AGENTS.md's new "Product scope changes" section for the process fix this prompted. ### Removed - 10 unused "valid values" accessor functions with zero call sites anywhere in the codebase (`Folder.overrides/0`, `Notification.kinds/0`, `NotificationPreference.levels/0`, `EventSeries.frequencies/0`, `CustomField.field_types/0`, `CustomField.visibilities/0`, `InstanceSettings.community_creation_policies/0`, `Decision.outcomes/0`, `AvailabilityResponse.answers/0`, `User.visibilities/0`), and trimmed two `phx.new`/`phx.gen.auth` generator boilerplate example comments (`user_auth.ex`, `error_json.ex`) down to their essential guidance. Found by the full-codebase audit (#78). ### Fixed - Config and env-var errors now fail the boot loudly instead of being silently absorbed (issue #98, security-hardening pass): `PHX_HOST` is enforced in prod like `SECRET_KEY_BASE` (a forgotten value used to silently ship `example.com` sign-in links in real emails); env-provided instance settings go through the same `InstanceSettings.changeset/2` validation as the setup wizard, so a `DEFAULT_LOCALE` typo can no longer be persisted unvalidated and fed to Gettext; unrecognized `COMMUNITY_CREATION_POLICY` / `STORAGE_POLICY` values raise exactly like `MAILER_ADAPTER` / `STORAGE_ADAPTER` typos always have (they used to be dropped with no log at all); an `OPERATOR_EMAIL` that can't become an account raises instead of vanishing; and the boot-time settings application now runs synchronously, so those raises actually stop the boot rather than crashing a background task the supervisor shrugs off. Also from #98: `OPERATOR_EMAIL` (with its auto-promote side effect called out) and `STORAGE_POLICY` are documented in `.env.example` at last, and `Kammer.Backups` asks `Kammer.Storage.adapter/0` instead of reading the raw config key — an unset adapter means local storage there too, so `mix kammer.backup` outside a prod release archives the uploads tarball instead of skipping it with a misleading "S3" message. - Group-authored posts no longer leak the human author's identity on the remaining web surfaces (issue #167, found by independent review of #166) — completing #153's closure across all transports: search results and the signed-in home's recent activity now dispatch on `author_type` like the feed does, and notifications for as-group posts render the group as actor everywhere the fanout's `actor_user_id` used to surface — the in-app notification center (name and avatar), the JSON API's notification serializer, and the email/push summary line ("Board posted", not "Alice posted"). Rendering-side dispatch keeps `post.author_type` the single source of truth (matching digests and newsletters) and retroactively fixes already-stored notification rows. Regression tests per surface. - Three residual holes in the ban enforcement family #129 hardened (issues #170, #171, #172, found by independent review of #169): `Communities.add_member/3` re-checks the community and instance ban lists inside its own transaction against the row-locked (`FOR UPDATE`) user — the same lock the ban paths take first — so a concurrent ban and invite redemption serialize instead of leaving a banned email holding a live membership; `Moderation.ban_member/4` re-reads the target's email from that locked user row, so the ban records the current address rather than a stale struct snapshot the target could sidestep by having changed their email; and `Communities.create_community/2` now consults the instance-ban list (under the same lock) and refuses with `:unauthorized`, closing the path where a banned account's surviving session could create — and own, unpurgeably — a fresh community. All membership-writing transactions now share one lock order (user row → community membership → group memberships), so none can deadlock another. The races themselves aren't reproducible under the test sandbox; new context tests pin the observable contract (current-email bans, banned-email refusals against stale structs, banned-creator refusal). - The moderation ban guards checked the target's protected status (admin/owner role for `Moderation.ban_member/4`, operator flag and community ownership for `Moderation.ban_instance/3`) before their transactions rather than inside them (issue #129, found by independent review of #128) — a concurrent promotion or ownership transfer landing in that window could let a ban proceed against a freshly protected target, in the instance-ban case reintroducing the owner-purge bug #122 fixed, via a race instead of a stale guard. Both guards now run inside the ban transaction against row-locked (`FOR UPDATE`) user/membership rows, making check and act atomic, and `Communities.remove_member/3` now deletes the community-membership row before the group rows so every membership-removing transaction acquires locks in the same order (the reverse order could deadlock a concurrent ban + remove of the same member). The race window itself isn't reproducible under the test sandbox; the new context tests cover the observable contract around it (current-role reads, no-partial-purge refusal, rollback of the membership removal when the ban insert fails). - Group-authored posts no longer leak the human author's identity through the API (issue #153): the feed queries never preloaded `:group`, so the serializer fell through to the user clause and exposed the real name/id that posting "as the group" exists to hide. `/home` and the group feed now serialize the same author. - The OpenAPI document no longer misdescribes single-object responses as arrays (issue #154): `posts_create`, `comments_create`, and `events_show` now use a single-object envelope, the RSVP response documents its actual `{event_id, status}` shape, and `acknowledgment_required` is a boolean, not a string. Error documentation is also truthful now: every operation documents 401/403/404, and writes additionally 422/429 — previously only 401/404 appeared despite 403s being designed behavior. The generated TypeScript client (`clients/web/src/lib/api/schema.d.ts`) is regenerated — its types were actively wrong for these operations. - A malformed post id in API comment creation is a 404, not a 500 (issue #155): the id is UUID-cast before querying instead of raising `Ecto.Query.CastError`. - Comments can no longer target unpublished posts (issue #156): `Feed.create_comment` now enforces the same visibility rule as the feed queries — a pending-approval post takes comments only from its author or a moderator, a scheduled post only from its author, and the refusal is a 404 (an invisible post answers exactly like a nonexistent one, no existence oracle). Previously any member who learned a post UUID could comment on a moderation-queued post through the API before it was visible. The created comment also returns its author populated instead of `"author": null`. - Posts now serialize `pending_approval` (issue #157), so a member's own moderation-queued post is distinguishable from a published one in API clients — the field existed on comments but not posts. - `.prettierignore` excluded `SPEC.md`, `BUILDLOG.md`, and `CHANGELOG.md` from formatting with no real reason — they're ordinary markdown, not legal text like `LICENSE` (the one entry that's actually justified). Reformatted all three; only `LICENSE` stays ignored now. - `priv/gettext/da/LC_MESSAGES/default.po` had an empty `msgstr` for the setup wizard's SMTP-failure message (issue #101) — the only untranslated string in an otherwise fully-translated file. A Danish-locale operator hitting an SMTP failure during first-run setup saw a blank error instead of the guidance to check SMTP settings and request a new sign-in link. Filled in the translation. - The OpenAPI document's `operation/4` helper hardcoded every operation's success response to `200`, wrongly documenting `posts_create`, `comments_create`, and the new `auth_register` (all real `201 Created` responses) — caught by independent review on #141. Added a `status` option, defaulting to `200`, set to `201` on the three creation endpoints. - Closed the last 3 of 7 Repo-bypass gaps found by the architecture audit (#121, closes #123): `Files.list_feed_collection/2` queried `Kammer.Feed.PostAttachment` directly instead of asking `Feed` for it (new composable `Feed.attached_stored_file_ids_query/0`); `DecisionLive.Index`'s "record outcome" handler bypassed `Decisions.fetch_viewable_decision/2` for a raw `Repo.get/2` (also picks up its UUID-cast guard for free); `AssignmentEventHandlers`' unclaim handler queried `AssignmentClaim` directly instead of through `Assignments` (new `Assignments.get_claim/2`, matching the existing bare-accessor convention). - `Feed.comment_context/1`'s assignment branch raised `MatchError` (generic 500) instead of `Ecto.NoResultsError` (proper 404) for a comment on a since-deleted assignment — reachable via `Moderation.report_comment/3`. Introduced by the #123 fix above, caught by independent review before merge. Renamed the existing private `Assignments.get_assignment!/1` (heavy preloads, one internal caller) to `get_assignment_with_details!/1` and added a proper bare `get_assignment!/1` (matching `Events.get_event!/1`'s pattern) so `comment_context/1`'s assignment and event branches now raise identically. Added regression tests for both branches. - `Moderation.ban_instance/3` (instance-wide bans) could silently strip a community owner's ownership membership, with none of the protections the normal removal path enforces — no ownership transfer, not even a block. Found by the architecture audit (#121, closes #122). `ban_member/4` (single-community bans) already refuses to ban an admin/owner, and `Communities.remove_member/3` already refuses to remove an owner's membership at all; `ban_instance/3` now gets the same guard, refusing the ban outright when the target owns any community (a bulk instance-wide purge has no single community to ask "who's the new owner?" of, unlike the single-community path). - `test/kammer_web/live/decision_flows_test.exs`, `legal_live_test.exs`, and `search_flows_test.exs` asserted exclusively via raw HTML substring matching (`html =~ "..."`), contrary to CONVENTIONS.md/CLAUDE.md's guidance to use `element/2`/`has_element?/2`. Found by the full-codebase audit (#79), closing it. Added DOM ids to the elements these tests needed to target that didn't have one yet (`LegalLive.Show`'s content article and edit link, `InstanceLive.Home`'s imprint-nag link and demo-purge button, `SearchLive.Index`'s per-result links), then rewrote every assertion to `has_element?/2,3` scoped to those ids. No behavior changed. - `Moderation.ban_instance/3` ran two separate `CommunityMembership` queries against the same target user (an ownership check, then a second query for the communities to audit-log) — self-review on #122 flagged this and it was waved off as not worth fixing; it was. Folded into one `community_memberships_for/1` query that serves both the guard and the audit list. - `Kammer.Media.process_image/1` — the libvips pipeline SPEC §11 credits with stripping EXIF/GPS metadata via re-encoding — had no test. Found by the full-codebase audit (#79). Added `test/kammer/media_test.exs`, verifying the metadata-stripping claim directly (embeds real EXIF data via `Vix.Vips.MutableImage.set/4`, confirms it survives an unstripped save and is gone after `process_image/1`), plus dimension math (unscaled below the display max, downscaled and aspect-preserved above it), the WebP thumbnail's format and width, and the error path for an unreadable source file. - `Kammer.Storage.S3` (the S3-compatible storage adapter — MinIO, Hetzner Object Storage, or any S3 API, SPEC §1's alternative to local disk) had zero test coverage of any kind. Found by the full-codebase audit (#79). Added tests for `put/2`, `put_binary/2`, `path_for/1`, and `delete/1` against a fake S3 endpoint (`Req.Test`), covering the round trip, the local-cache short circuit, `:not_found`, and unexpected-status errors. Each function gained an optional third `opts` argument (default `[]`, merged into the underlying `Req.new/1` call) purely as a test seam for injecting `plug: {Req.Test, name}` — behavior is unchanged for every existing caller, which never passes it. - Six Oban workers with real branching logic — no-op vs. configured, scheduled vs. approval-held, empty vs. due recipients, vanished vs. live records — had zero `perform_job/2` coverage, unlike their tested siblings. Found by the full-codebase audit (#79). Added worker-level tests for `DigestWorker`, `BackupWorker`, `PublishScheduledPostWorker`, `PurgeDeletedContentWorker`, `NewsletterDigestWorker`, and `UpdateCheckWorker` that exercise each worker's own branches via `perform_job/2`; the underlying context functions each delegates to remain covered in depth by their own context test suites. No production code changed. - The guest-confirm controllers resolved a post/event/group's community by walking `Kammer.Repo.get(Community, ...)` chains themselves instead of asking the owning context for it. Found by the round-2 audit (#91), closing it. `Feed.confirm_guest_comment/2`, `Events.confirm_guest_rsvp/2`, `Events.confirm_guest_claim/2`, and `Newsletters.confirm_subscription/2` now return the post/event/group with `:group`/`:community` preloaded, so `GuestCommentController`, `GuestPaths.event_path/1`, and `NewsletterController` build their redirect paths from the preloaded association instead of a second `Repo` lookup. Added `Communities.get_community/1` for `InstanceLive.Home`'s demo-community lookup, the one remaining direct `Repo`/schema reference in the web layer this issue covered. - The web layer fetched Assignment/AvailabilityOption/EventSlot/ SlotClaim/Report/CommunityBan/InstanceBan entities directly via `Kammer.Repo` and their schema modules instead of through the owning context. Found by the round-2 audit (#91). Added bare-id accessors (`Assignments.get_assignment/1`, `Availability.get_option/1`, `Events.get_slot/1`, `Events.get_slot_claim/2`, `Moderation.get_report/1`, `Moderation.get_community_ban/1`, `Moderation.get_instance_ban/1`); the affected LiveViews call them instead of naming `Kammer.Repo` or a schema module directly. - The web layer fetched `Feed` entities (posts, comments, polls) directly via `Kammer.Repo` and the schema modules instead of through `Kammer.Feed`, and hand-rolled the poll-vote-toggle selection logic (`FeedEventHandlers.toggle_option/3`) — Feed domain logic living in `KammerWeb`. Found by the round-2 audit (#91). `Kammer.Feed` now exposes `get_post/1`, `get_comment/1`, `get_poll/1`, and `toggle_poll_option/3`; `FeedEventHandlers`, `ReportHandlers`, `GroupLive.Show`, `AssignmentLive.Show`, and `EventLive.Show` call them instead of naming `Kammer.Repo`/`Kammer.Feed.Post`/ `Kammer.Feed.Comment` directly (one exception, documented inline: `FeedEventHandlers`'s `create_comment` handler keeps a direct `Repo.get/2` — narrowing that one call site's type triggers an unrelated Dialyzer false positive on `Feed.create_comment/3`'s `:comments_locked` branch, confirmed real via `feed_test.exs`). Remaining bypasses in Assignments/Availability/Events/Moderation and the guest-controller redirect-path chains are tracked as follow-up work under the same issue. - The passkey sign-in and passkey-registration colocated JS hooks (`user_live/login.ex`, `user_live/devices.ex`) each defined identical `b64urlToBytes`/`bytesToB64url` WebAuthn base64url helpers. Found by the full-codebase audit (#77), which is now fully addressed. Extracted `assets/js/webauthn.js`; both hooks import from it instead of maintaining their own copy. - `GuestClaimController` and `GuestRsvpController` each had a byte-for-byte identical private path helper (`event_path/1` and `confirmed_path/1`) resolving a guest-confirmed event's community-scoped URL. Found by the full-codebase audit (#77). Now both call the shared `KammerWeb.GuestPaths.event_path/1`. - Community and instance moderation each hand-rolled their own ban-row list markup and `unban` `handle_event` body. Found by the full-codebase audit (#77). Now both call the shared `KammerComponents.ban_row/1` and `KammerWeb.BanEventHandlers`, parameterized on the DOM id prefix, confirmation text, and the scope-specific unban function, so the two can no longer drift. - The group assignment list and the single-assignment page each hand-rolled their own `claim`/`unclaim`/`complete`/`reopen` `handle_event` bodies, including a byte-for-byte identical manual `AssignmentClaim` lookup before calling `Assignments.unclaim/2` in both places. Found by the full-codebase audit (#77). Now both call the shared `KammerWeb.AssignmentEventHandlers`, so the two can no longer drift. Added a regression test for the single-assignment page's full claim/unclaim/complete/reopen cycle, which had no prior test coverage. - The community home feed hardcoded a post's `edit`, `pin`, `lock_comments`, and `approve` permissions to `false`, unlike the group feed's `post_permissions` which computed all of them via `Kammer.Authorization` from the exact same inputs (post, group, relationship, current user). A group admin or post author browsing their Home screen instead of the group page couldn't edit, pin, lock, or approve a post they were fully entitled to act on — the moderation menu simply didn't show those options. Found by the full-codebase audit (#77). Both feeds now share `KammerWeb.PostPermissions.for_post/4`, so the two can no longer drift. - The group feed and the aggregated community home feed each hand-rolled their own feed-sort (Newest/Activity) control markup and `set_feed_sort` `handle_event` body, near-verbatim. Found by the full-codebase audit (#77). Now both share `FeedComponents.feed_sort_form/1` and route `set_feed_sort` through the existing `FeedEventHandlers` (already used for the rest of their feed interactions), so the two can no longer drift. - Group and community settings each hand-rolled their own invite-link list markup and create/revoke `handle_event` bodies, near-verbatim. The community copy also displayed a bare `invite.use_count` instead of the group copy's `used/max_uses` label — so a single-use email invite (`max_uses: 1`) showed as a plain "0" or "1" in the list instead of "0/1", losing the cap information. Found by the full-codebase audit (#77). Now both call a shared `KammerComponents.invite_list/1` component and `KammerWeb.InviteEventHandlers`, so the two scopes can no longer drift. - `Kammer.Moderation.report_comment/3` and `report_group/1` (via a private `comment_group/1` helper) re-derived a comment's owning group by branching on which of `post_id`/`event_id`/`assignment_id` was set — the exact same resolution `Kammer.Feed.comment_context/1` already did, reaching directly into `Events`/`Assignments` schemas instead of asking `Feed`. Found by the full-codebase audit (#76). Now `Moderation` calls the newly-public `Feed.comment_context/1` instead of re-deriving the branch itself. - `Layouts.community_shell/1`, `CommunityLive.Home`, and `GroupLive.Show` each re-derived community membership/admin status with a private `member?/1`/`admin?/1`/`member_of_community?/1` helper that pattern-matched `community_relationship.community_role` directly, duplicating logic that already lives in `Kammer.Authorization` — the codebase's sole authorization choke-point. Harmless today since the helpers matched `Authorization.can?/4`'s `:view_community`/`:manage_community` clauses exactly, but a future change to either without noticing the other would have silently diverged. Found by the full-codebase audit (#75). Now all three call `Authorization.can?/4` directly. - `Kammer.Files.fetch_accessible_file/2` only checked coarse group/community visibility — it never loaded the file's folder chain or checked an `admins_only` read override, unlike `list_files/3` which correctly does. A file placed in an `admins_only` folder was correctly hidden from folder listings for non-admins, but still directly downloadable via `/files/:id`, `/files/:id/thumbnail`, and `/files/:id/download` by anyone who could see the owning group or community, if the file's ID was known or guessed — the folder-level restriction was silently bypassed on the direct-fetch path. Now applies the same folder-permission invariant (ADR 0009) as `list_files/3`. - `Kammer.Files.fetch_accessible_file/2` skipped the `Ecto.UUID.cast` guard its sibling `fetch_viewable_*` functions use, so a malformed file ID raised `Ecto.Query.CastError` instead of returning `{:error, :not_found}`. This broke `FileController.serve/4`'s deliberate 404 branch (the exception aborted the `with` before it could run) and crashed the `FileLive.Index` delete handler's LiveView process on a tampered `id` param. Found by the round-2 audit. Now guards the same way its siblings do. - `Kammer.Feed.create_engine_comment/5`'s reply-flattening (`normalize_parent/1`, now `/2`) accepted any `parent_comment_id` the client sent and looked it up globally, with no check that the candidate parent belonged to the same post/event/assignment as the comment being created. Since replies render via a `has_many :replies` preload keyed purely on `parent_comment_id`, this let any member with comment access anywhere on the instance inject a comment that would render nested inside an unrelated thread they had no visibility into at all — including a comment-locked or archived thread, or one in a sealed group. Found by a second, more thorough full-codebase audit (round 2). Now verifies the candidate parent shares the same subject before adopting it; a cross-subject `parent_comment_id` is silently treated the same as a missing one (becomes a root comment), matching the existing not-found fallback. - Extracted `Kammer.Validation.validate_email_format/3` and `validate_display_name_length/3`, replacing six duplicated copies of the same email-format/length and display-name-length rules across `Kammer.Newsletters`, `Kammer.Events` (two guest-request changesets), `Kammer.Feed`, `Kammer.Guests.GuestIdentity`, and `Kammer.Accounts.User` — the last of the DRY violations from the full-codebase audit (#74). Deliberately narrow: normalization (e.g. downcasing email) stays with each caller since not everyone wants it — `User`'s email notably never has. Behavior-preserving at every site, including `User`'s 100-character display-name bound (the other five use 120) via the shared helper's `max` parameter. - Added direct test coverage for two previously-untested security-relevant boundaries flagged by the full-codebase audit: `Kammer.Storage.Local`'s path-traversal guard (every key reaching disk goes through it — uploads, thumbnails, extracted text) and `Kammer.Markdown.to_html/1` (the sanitization boundary for every post, comment, and event body in the product). No production code changed; both were already correct, just unverified by the suite. - Consolidated comment creation for posts, events, and assignments into one `Kammer.Feed.create_engine_comment/5` (ADR 0007's "one comment engine," which had drifted into three near-identical copies). Fixes two real bugs the drift had caused: event and assignment comments skipped the `@everyone`-mention rate limit that post comments already enforced, and — activated for the first time by this same fix, since event/assignment comments were never previously enqueued for fan-out — `Kammer.Notifications.fanout_comment/1` assumed every comment belonged to a post and would raise `Ecto.NoResultsError` for any event or assignment comment. Fan-out now resolves the comment's host (post, event, or assignment) polymorphically, with regression tests covering all three subjects. - Consolidated the instance-operator check (`user.instance_operator`) into `Kammer.Authorization.instance_operator?/1`, replacing raw struct-field checks scattered across three context modules (`Kammer.Legal`, `Kammer.Communities`, `Kammer.Moderation`, `Kammer.Setup.DemoData`) and four LiveViews (legal page edit/show, instance settings, instance moderation, instance home) — the third and final authorization-consolidation fix from the same audit. Behavior-preserving; existing tests for all eight sites pass unmodified. - `KammerWeb.CommunityScope`'s `:require_member` `on_mount` hook — the route-level gate for most of the app's community-scoped LiveViews (groups, files, members, moderation, settings, events, assignments, decisions, availability) — checked community membership with an inline `if socket.assigns.community_relationship.community_role` instead of going through `Kammer.Authorization`, another violation found by the same audit. Now calls `Authorization.can?/4` with `:view_community` (the same predicate, `community_role != nil`), with a dedicated test for the gate itself rather than only the indirect coverage every other community-scoped test already gave it. - Consolidated the "creator or group moderator" access-control rule (who may edit/cancel/close/record-an-outcome-for a resource) into `Kammer.Authorization.can_manage_own_resource?/3,4`, replacing five independent copies of the identical logic across `Kammer.Events`, `Kammer.Availability`, `Kammer.Assignments`, and `Kammer.Decisions` — a direct violation of CONVENTIONS.md's "one authorization module" rule found by a full-codebase audit. Now property-tested alongside the rest of the authorization decision core instead of five separate unit tests that never covered the rule as a shared invariant. ### Security - Full rate-limit coverage (SPEC §11): every item on the spec's rate-limit list now has a real `Kammer.RateLimit` bucket — account creation (per IP), posting, commenting, and file uploads (per author/uploader), on top of the magic-link and guest-endpoint limits that already existed. While auditing coverage, `@everyone` inside a **comment** turned out to bypass the broadcast-rights gate entirely — `Notifications.fanout_comment/1` already escalated it to a full-group broadcast for any commenter, since only post creation ran the gate. Comments now get the same gate and rate limit posts already had. - Nonce-based CSP (SPEC §11, ADR 0021): `script-src` drops `'unsafe-inline'` for a fresh per-request nonce, closing the one remaining gap that would have let a successful XSS run arbitrary inline script. The app's one inline script (the light/dark theme bootstrap) carries the nonce; colocated LiveView hooks were never affected — they compile into the external `app.js` bundle. - Newsletter one-click unsubscribe no longer embeds the full-power, 60-day guest manage token in the RFC 8058 `List-Unsubscribe` header (issue #233) — a URL mail gateways auto-fetch with no human in the loop, so that token was a live, wide credential sitting in every delivered email. `Kammer.Guests.Token` gained a third, single-purpose salt (`sign_unsubscribe/1`/`verify_unsubscribe/1`, 180-day max-age): the payload carries only the one `subscription_id` it authorizes, never an identity, so a leaked or auto-fetched copy can unsubscribe exactly that one subscription and nothing else — it fails `Guests.manage_token_valid?/1` outright (different salt), so it's powerless against every manage endpoint. The `/newsletter/unsubscribe/:token` route (GET and the RFC 8058 POST) dropped its separate `:subscription_id` segment — the token names its own subscription, so there's no longer a caller-supplied id to tamper with. The body "manage or unsubscribe anytime" link is deliberately left carrying the full manage token for now (it's a link a human must click, not an auto-fetched header) pending the #185/#187 PWA transition.