The app called the GitHub releases API from ProjectSelectionComponent.init
to decide whether to show an "update available" dialog. That is an
unprompted network request before the user has done anything, which has
been raised as a privacy concern.
The release notes are already in the repo at release time, so bake them
in instead. prepareForRelease writes the new CHANGELOG.md entry to
common/src/commonMain/composeResources/files/changelog.md and commits it
alongside the version bump, so the resource cannot drift from the version
in libs.versions.toml.
On load, ChangelogRepository compares the baked entry's version against
lastSeenChangelogVersion in global settings and shows a "What's New"
dialog when they differ. Dismissing records the version. Fresh installs
are seeded as already-seen, so only upgrading users get the popup;
the old lastDismissedUpdateVersion key is dropped, which is safe because
the TOML serializer ignores unknown keys.
About drops its update check entirely and gains two buttons: Changes,
which reopens the dialog with no network, and GitHub Release, which opens
the browser.
VersionCheckRepository stays for the protocol mismatch dialog, which only
appears after the user has connected to a sync server and exists to tell
them which version to get. It keeps its automatic check and is now that
repository's only consumer.
Links in the notes are clickable via linkifyChangelog, which handles
[label](url) and bare urls, including urls containing parentheses. It is
deliberately not a markdown renderer: entries lead with [New] / [Fix]
tags that a real parser mangles.
* Add per-project language setting (#754)
An optional BCP-47 language on ProjectData, picked from a searchable
list of all platform locales in project settings. New projects default
to the device locale; the Alice example project is en-US.
Spell check is gated per project: when the project language does not
leniently match the dictionary locale, the dictionary is withheld
(ProjectSpellCheckRepository) and project settings explain why.
The public story page emits <html lang> and JSON-LD inLanguage from the
declared language, and EPUB export prefers it over the device locale.
The hasher contributes zero bytes when unset so existing sync hashes
stay stable.
* Fix review findings in the project-language feature
createProject now only seeds the default language for genuinely new
projects (seedDefaultLanguage), so account sync materializes server
projects with the never-synced baseline intact, and the seed is
language-only so it cannot gate spell check against a same-language
dictionary. The hasher's language block gets a -1 marker plus length
prefix so it can never collide with a tags block, and the initial
write goes through the shared saveStoredProjectData path.
The Locale type now retains the script subtag, keeping zh-Hans/zh-Hant
style locales distinct in the picker. The picker's clear row is pinned
above the list so it survives an empty search, watchSpellCheckAllowed
delivers on the main dispatcher, and the public story page hashes the
stored project-data hash into its validator instead of parsing the
blob per request, applying the language override after withDefaults so
chrome links keep the viewer's locale.
* Enforce single-owner persisted formats
The tags write in PromoteIdeaUseCase rewrote project_data.toml from
scratch, erasing the language seed createProject had just written: the
exact hazard of a second inline writer. It now read-modify-writes
through the datasource's scope-less helpers, and ProjectsListComponent's
hand-rolled reader delegates to a new blocking readStoredProjectData.
The rule is written down (ARCHITECTURE.md hard constraint 7, CLAUDE.md)
and enforced by PersistedFormatOwnershipTest, which fails the build when
raw TOML I/O appears outside a Datasource file. Migrators are exempt by
role; the two remaining legacy offenders are allowlisted as a burn-down
that can only shrink.
* Burn down the last raw TOML I/O outside datasources
ProjectStatisticsCacheReader now delegates to a scope-less
readProjectStatistics helper in StatisticsDatasource, and the example
project's fabricated activity log goes through writeDeviceLog in
WritingActivityDatasource, which also becomes the single owner of the
.activity path convention.
With no offenders left, PersistedFormatOwnershipTest drops its
burn-down allowlist entirely: only Datasource files and migrators may
touch persisted TOML formats from here on.
* Pass seedDefaultLanguage in the Android instrumented-test harness
* Pass seedDefaultLanguage in the round-trip sync HeadlessClient
htmx discards the body of a 4xx, so the out-of-band toasts the dashboard's rejection paths send never reached the user.
The toast helpers mark error responses with X-Hammer-Swap-Error and toast.js swaps the ones carrying it. Marking each response rather than every 4xx keeps the swap away from responses that are not swap payloads: a bare respond(BadRequest) would blank its target, and a 404 or 401 would land a whole error page inside it. Toast-only errors also reswap to none so the toast lands without emptying the request's target.
Delete-account no longer needs its 200-on-error workaround.
Fixes#812
* Add self-service account deletion
Users can delete their account from the web dashboard danger zone. The
account is soft-deleted: locked out of login and sync, all stories
unpublished, pen name released, data retained for a configurable window
(accountDeletion.retentionDays, default 30 days) during which an admin
can restore it from the users page. A daily job permanently purges
accounts past the window. Admin accounts cannot be deleted; the guard is
enforced in the SQL, the service, and the UI.
* Harden account deletion edge cases from review
softDelete verifies the deleted flag actually landed before running its
destructive steps, and retries re-run the idempotent cleanup so a partial
failure heals; markDeleted leaves an already-deleted row untouched so
retries never extend retention. Tokens of soft-deleted accounts are
hidden inside the token query itself, restoring the whitelist-off
single-query bearer auth path. Re-registration against a soft-deleted
email returns the pending-deletion message instead of a misleading
"account exists", and the delete dialog warns that the email stays
reserved. Shared test account builder replaces per-file duplicates.
Clients are HTTPS-only, but the Docker image serves plain HTTP, so a client
pointed at it fails its TLS handshake and reported only "Network error
connecting to /api/account/create". The handshake is rejected by Jetty's HTTP
parser before any route runs, so nothing about it reaches the server log
either, leaving unrelated UnsupportedProtocolVersionException entries as the
only visible clue.
Split the IOException arm of Api.makeRequest so a TLS failure names HTTPS and
the certificate or reverse-proxy requirement. Detection is expect/actual:
SSLException on JVM, message markers on iOS where NSURLSession carries nothing
else through.
Also fix the server URL field on the way in: cleanUpUrl stripped the scheme
with removeSuffix instead of removePrefix, so a pasted http:// URL failed
validation, and validateUrl required a dotted TLD, rejected capitals, and
admitted ports above 65535 that would crash the unguarded toInt() in url().
Fixes#790
* Add Docker support for self-hosting the sync server
Provide an official Docker image for self-hosting the Hammer sync server,
alongside the existing Java executable distribution.
- docker/Dockerfile: slim, non-root runtime image on a glibc base
(eclipse-temurin:21-jre-jammy). Runs unprivileged because the embedded
PostgreSQL binaries refuse to run as root, and stays on glibc because those
binaries are not musl-compatible. Pins user.home to /data so a single volume
holds the database, caches, keyring, and config. The image packages the
pre-built application distribution rather than compiling from source, since
:server depends on :base and a source build would need the Android SDK.
- docker/docker-compose.yml, config.example.toml, README.md: turnkey
self-hosting with a data volume and optional host-managed config.
- .github/workflows/publish-docker.yml: builds the distribution and publishes a
multi-arch (amd64/arm64) image to GHCR on release. One build serves both
arches because the distribution is pure JVM bytecode with the embedded
PostgreSQL binaries for every OS/arch bundled inside the jars.
- .dockerignore: trims the build context to just the built distribution.
- docs/HOW-TO-RUN-A-SERVER.md: document the Docker path and drop the stale
"Eventually we'll add Docker images" note.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KJos31QSeA2YHkCU44nNZ
* Docker: pre-create data dir so config bind mount works unprivileged
Bind-mounting a config file at /data/hammer_data/config.toml would make Docker
create the parent dir as root when it doesn't already exist, leaving the
non-root server unable to write pgdata. Pre-create and chown /data/hammer_data
in the image so a named volume initializes with it hammer-owned, and clarify the
config provisioning paths in the Docker README.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KJos31QSeA2YHkCU44nNZ
* Docker: configurable host port and a BOM caveat for config.toml
Both found while testing the image end to end on Windows.
Compose merges `ports` lists by appending, so the hardcoded 8080 mapping could
not be overridden from an override file. Drive it from HAMMER_HTTP_PORT instead,
which is the usual escape hatch when the host port is already taken.
A config.toml saved as UTF-8 with a BOM fails with an UnexpectedTokenException
pointing at line 1, which reads like a syntax error in the file. Notepad and
PowerShell's Out-File -Encoding utf8 both write one, so call it out.
* Docker: note the one-time GHCR package visibility step
The package is created automatically on first publish, but may land private,
which would break the anonymous docker pull the docs point self-hosters at.
Left as a manual step because making a package public cannot be undone.
* Docker: note why the packages:write permission block is required
The repo default workflow token is read-only, so the explicit block is what
makes the GHCR push work rather than 403.
* Docker: document external PostgreSQL, add init for orphan reaping
Remote storage already worked (the DI picks RemotePostgresDatabase and the
schema initializer runs on first connect), but nothing in the Docker setup
showed how to use it. Add a commented-out postgres service with the matching
depends_on, and a README section covering it.
The service mounts its volume at /var/lib/postgresql: postgres 18+ images abort
startup when the mount is at the older /var/lib/postgresql/data path.
Call out that storage.remote.useSsl defaults to true, which fails against a
plain postgres container that serves no TLS.
init: true reaps orphans from the embedded PostgreSQL process tree. Shutdown was
already graceful without it - the start script execs, so the JVM is PID 1 and
runs its shutdown hooks on SIGTERM - but the JVM does not reap orphans.
* Docker: split out a dedicated hosting doc and fix review findings
Move the Docker hosting guide to docs/HOW-TO-RUN-A-SERVER-DOCKER.md and reduce
the inline section in the main guide, plus docker/README.md, to pointers so the
three cannot drift apart.
Fixes:
- Pin user.home through SERVER_OPTS rather than JAVA_OPTS. The start script
appends both, so an operator setting JAVA_OPTS for heap was silently moving the
data directory to /home/hammer, stranding the volume and starting an empty
database.
- Publish the plain HTTP port on 127.0.0.1 by default, overridable with
HAMMER_HTTP_BIND. Published ports bypass host firewall rules, so the previous
0.0.0.0 default could put cleartext credentials on the internet.
- Warn that bindHosts must not be set under Docker; it binds the container
loopback, leaving the server unreachable but still reporting healthy.
- Gate the release trigger on the +server tag convention so client-store-only
releases no longer republish and move latest.
- Add a ref input so a dispatch that names a version builds that ref.
- Correct the claim that cert paths resolve relative to the data directory, and
document that renewals need a container restart.
- generate-keyring example now writes to the volume with --out.
- storage.remote host is postgres, matching the compose service name.
Also trims the narrating comments across the Dockerfile, compose file, and
workflow.
* Docker: document running the admin CLI subcommands in a container
The embedded PostgreSQL holds an exclusive lock on pgdata, so subcommands that
read the database (prune-key --role content, --converge-dry-run) cannot run
while the server container is up. It fails safely rather than corrupting
anything, but the operator has to stop the server first, and nothing said so.
Adds a table of which commands need the database, the stop/run/start sequence,
and a key-rotation walkthrough noting that a rotated keyring only takes effect
on restart. The lock is embedded-specific; remote storage has no such
constraint.
* Docs: drop em dashes from the Docker hosting guide
* Docs: drop em dashes from the server hosting guide
* Remove PageSpeed PDFs accidentally added to this branch
These were untracked working-tree files swept in by a `git add -A`; they are
unrelated to the Docker work and stay on disk.
* Pin the docker/* actions to commit SHAs
Matches how every other third-party action in this repo is referenced, and
clears the supply-chain findings Codacy raised on the PR. Kept within the major
versions the workflow was written against rather than moving to the newer
majors, since the workflow has not run yet.
* Clarify why +server releases publish the Docker image
---------
Co-authored-by: Claude <noreply@anthropic.com>
The OG card and rendered story HTML caches were hard-coded to
hammer_data/cache. A [cache] block now sets the root directory and the
per-cache size bound, so an admin can move them to a scratch partition.
A configured directory resolves relative to the config file and is probed
for writability at startup: an unusable path aborts rather than degrading
to a permanent cache miss that just looks like a slow server.
* Close the gaps in cache directory validation
Validating after path resolution made the blank-directory guard dead: a
blank value resolves to the config file's own directory, which then looks
like a perfectly good absolute path, so the caches would land next to the
database instead of aborting. The cache block is now validated on the
parsed config, before resolution touches it.
The write probe only covered the cache root, but entries go in a
subdirectory per cache — a writable root holding a subdirectory owned by
someone else still degraded to a silent permanent cache miss, which is
the failure the probe exists to catch. Probing every subdirectory needs
their names in one place, so they move from string literals at the two
injection sites into a DiskCache enum.
Also: bound maxSizeMb, since a size given in bytes by mistake overflowed
the conversion into a negative cap that escaped the positive-value check;
collapse the 200 MB default to one definition; and read the e2e cache
helper's location from the config the server under test actually runs on.
* Cache static assets with Cache-Control headers
Add CachingHeaders so static assets get a public max-age — CSS/JS for a day,
images/fonts for a week — letting browsers skip revalidating them on every
navigation (ETags via ConditionalHeaders still catch changes once max-age
lapses). HTML and XML responses get no caching header. Also gate gzip on
minimumSize like deflate.
* Add canonical URLs, per-page titles, and author meta description
- Shared <head> emits <link rel="canonical"> and an optional <meta name="description">.
- Every page gets a self-referential canonical from its request path (query stripped)
via withDefaults; the base URL prefers publicUrl and falls back to the request host.
- Story pages override the canonical to include ?page so each page is independently
indexable (self-canonical), but never the ?p password param.
- Author and story pages get unique <title>s; author pages get a bio-derived description.
* Add OpenGraph and Twitter Card meta tags
Shared <head> emits og:site_name/type/title/description/url/image and the
twitter:card equivalents, reusing the title/description/canonical fields already
set. Defaults (type=website, image=site icon) come from withDefaults; author
pages set og:type=profile and story pages og:type=article.
* Add a branded 1200x630 default OpenGraph image
Ship a wide default share image (Hammer icon + wordmark) so social previews
render as summary_large_image cards instead of the small square icon. og:image
/twitter:image now point at it; pages can still override og:image later.
* Use branded per-type default OG images for authors and stories
Author and story pages now use type-specific static share cards (og-author /
og-story) instead of the generic default, so shared links read as an author
profile or a story at a glance. Zero setup — these ship as static assets.
* Add a generic size-bounded LRU disk cache
Stores arbitrary byte blobs keyed by string (SHA-256-hashed to a filename), with
atomic writes and approximate-LRU eviction by last-access time. put() self-bounds
to maxBytes; prune() and prune(maxAge) expose size/age maintenance for a scheduled
job. Reusable beyond the upcoming OG-image cache.
* Add OpenGraph image renderer and richLinkPreviews flag
Headless-AWT renderer for 1200x630 share cards (icon + wordmark + wrapped title
+ subtitle), loading the Kingthings TTF once. Adds the richLinkPreviews config
flag (default false; needs native font libs). Wiring to routes comes next.
* Wire dynamic OpenGraph images behind richLinkPreviews
With the flag on, author/story pages point og:image at per-entity endpoints
(/a/{pen}/og.png, /a/{pen}/{project}/og.png) that render personalized cards via a
disk cache (OgImageService over LruDiskCache) and 404 non-public entities. A
recurring job prunes the cache by age. With the flag off (default), pages fall
back to the branded static cards, so no font libraries are needed. Documents the
fontconfig/libfreetype6 requirement.
* Fix OG card layout so the subtitle never collides with the accent bar
Use fixed title/subtitle baselines instead of spacing relative to the title
height, so a 3-line (wrapped/truncated) title no longer pushes the subtitle off
the bottom. Slightly smaller title font to fit three lines cleanly.
* Redesign OG story card and localize share-card labels
Story card now leads with the title as the hero (inline open-book mark,
full-width wrap below), the author beneath it, and a footer attribution
pinned to the bottom. Secondary text is larger and darker for legibility
when the card is scaled down to a chat unfurl.
All card labels are localized: the renderer takes them as parameters and
the route resolves them via the existing ResourceBundle i18n. Reuses
public_story_by; adds og_attribution and og_author_subtitle (the latter a
{0} template filled with the server's own host). Cache keys are built from
the exact render inputs, so language and host variations regenerate.
* Add JSON-LD structured data to author, story, and home pages
A typed schema.org builder (kotlinx-serialization) emits ProfilePage/Person
for community author pages, Article for publicly-published stories, and
WebSite for the home page. The header template renders it into a
<script type="application/ld+json"> block when the model carries jsonLd.
Author-supplied text is escaped (< -> <) so it can't break out of the
script block. Structured data is emitted only for indexable pages.
* Add per-page titles and meta descriptions to public pages
Home, About, Community Authors, and Story Feed pages now set descriptive
<title>s and meta descriptions (reusing existing localized subtitles).
Public story pages get a meta description built from the title and author.
These feed the description/og/twitter tags the header already renders.
* Add Subresource Integrity to the htmx and Font Awesome CDN links
Pin the exact bytes of the two third-party assets so a compromised CDN
can't inject altered code: the browser blocks either file if its hash
doesn't match. Hashes verified against the served bytes (htmx cross-checked
across unpkg + jsdelivr, Font Awesome against cdnjs's published SRI);
crossorigin=anonymous added since both CDNs send Access-Control-Allow-Origin.
* Resolve dynamic OG images by stable id instead of caller-supplied strings
The share-card routes now take an account id / project UUID and render only
fields read back from the database — the pen name, story title, and the
subtitle host (from publicUrl config, never the request Host). Nothing the
caller supplies is drawn into the image or mixed into the cache key.
The author/story pages emit the dynamic og:image URL only when the subject
actually qualifies (community author; public, password-free story), matching
what the route will serve — so a share preview is never a broken 404 that
should have fallen back to the static card. A malformed project UUID is
rejected before the query so it can't raise a Postgres cast error. Rendering
moves off the event loop.
Adds findPublicProjectByUuid and route-level gating tests.
* Harden OG image caching, render concurrency, and cache stampede
- Scope Cache-Control by path: dynamic OG cards under /og cache for 30 days
(the disk-prune window), /assets keep their type-based rules, everything
else stays uncached. Previously any image response — including the dynamic
OG PNGs — was matched by content type and cached for a week.
- Make LruDiskCache.getOrPut single-flight per key (striped locks) so a
scraper burst on a viral link renders the card once, not once per request.
- Collapse the duplicated wrapText into a delegation to wrapTextIndented.
Remove the HTTP/HTTPS protocol picker and all ssl plumbing from the
client. Persisted server settings now always resolve to HTTPS, so a
legacy ssl=false server.json is upgraded on load. ServerSettings.ssl is
kept only as an internal seam for the plain-HTTP integration-test server.
Android: drop the permissive network_security_config so cleartext
traffic uses the secure platform default (blocked).
Server keeps its plain HTTP connector for reverse-proxy deployments. In
--dev with no sslCert configured, it now generates and persists a
self-signed keystore (hammer_data/dev-selfsigned.jks) and serves TLS on
a non-privileged port (8443 by default). The desktop --dev client trusts
that cert for loopback hosts only; remote hosts still get full cert and
hostname validation.
The repository moved from github.com/Wavesonics/hammer-editor to
github.com/Darkrock-Studios/hammer-editor. Update all URLs across
source, build scripts, web templates, docs, store metadata, and
test fixtures.
When no --config is passed, load ~/hammer_data/config.toml if present, else fall back to defaults. Doc renamed serverConfig.toml -> config.toml throughout.
Quick-capture story ideas as tagged markdown blobs in a new Project
Selection tab, stored one file per idea in .ideas/ and promotable into
a project. Offline-first; syncs as a phase inside the account sync
session (shape-agnostic server storage, hash-baseline conflicts,
tombstone/outbox deletion, ideasStateHash skip for unchanged sets).
Unifies idea + project tag suggestions behind AccountTagService.
Projects can now be tagged in Project Settings, with suggestions drawn
from the user's other projects. Tags show as chips on project rows, and
a new search bar on the project list filters by name and #tag using the
same query syntax as Global Search (parser extracted to a shared
data/search module). Conflict resolution gets a Tags row, picked as a
unit like the other project-data fields.
Sync safety:
- Tags hash with zero bytes when empty, so all existing hashes (synced
baselines and server rows) stay byte-identical; golden-pin tests
enforce this.
- The server now stores project data as an opaque blob with a
client-supplied hash (like entities), validating only that the
payload decodes; undecodable rows heal via re-upload. Adding fields
to ProjectData no longer requires server changes.
- Fast-forward records the hash of what was actually stored, so an
out-of-date client can no longer strip and delete fields a newer
build added. Documented in SYNCING-PROTOCOL.md.
- HAMMER_PROTOCOL_VERSION bumped to 3: older servers decode project
data destructively and would silently drop tags.
UI: the redundant projects-page heading is removed; search reveals via
a masthead toggle. New design-system pieces: HdClearGlyph and
HdCollapseGlyph (drawn glyphs, replacing misaligned text "×" and the
ambiguous double-X in search strips) and an HdSearchRow molecule now
shared by all four searchable screens.
Mutating account endpoints (begin/end sync, create, delete, rename)
were GET. The server now routes both verbs, keeping GET for older
clients. The client prefers POST and retries once as GET on 404/405
so newer clients still work against servers that predate the move.
Protocol version is unchanged; parameters are identical either way.
* Mark legacy GET routes and client fallback for removal at the next protocol bump
A crashed account sync locked that device out until the 2-minute
session expiry; project sync already allowed the owning install to
reclaim its own stale session. Mirror that: begin_sync claims the
slot when the bearer-token-derived installId matches the holder.
Also make the account end_sync route report validation failures
instead of unconditionally answering 200 (this masked a wrong-syncId
bug in the rename e2e test, now fixed).
The download 404 path previously punted (TODO) when the entity still
existed locally, failing the sync forever. Known deletions are skipped
before this point, so the local copy is the surviving truth: re-upload
it with no baseline and converge.
The server now repairs a stale entity hash column itself on download
(content is the truth, the hash is derived metadata) instead of
returning 412 and trusting the client to force-upload a heal. This
also fixes the hole where a fresh device could never download an
entity with a stale server hash: the client heal silently reported
success with no local copy to upload.
The client keeps its 412 handler for legacy servers, but now fails
the entity loudly when it has nothing to heal with.
Replace the project name in sync endpoint paths with the server-issued
projectId. The server resolves the ProjectDefinition from the DB by id;
the create endpoint takes the name as a query param. Updates client APIs,
tests, and the syncing protocol doc.
* Harden sync projectId refactor: protocol bump, 410 for missing project, path encoding
- Bump HAMMER_PROTOCOL_VERSION so version-mismatched clients fail fast via the
protocol gate instead of hitting silent 404s on the renamed routes.
- requireProjectDef responds 410 Gone (not 404) for a missing project, so the
download_entity client can't mistake a vanished-mid-sync project for a deleted
entity and silently abandon undownloaded entities.
- Re-apply encodeUrlPathSegment to the projectId path segment and restore the
path-encoding test so a reserved character can't escape the URL template.
Adds GA4 as a second web-analytics provider alongside Umami, selected via
analytics.type = "google" with an [analytics.google] measurementId block.
GA4 can fall back to tracking-pixel loads, so the AnalyticsProvider
interface gains imgSrcHosts() (empty for Umami) and the CSP builder folds
it into img-src. Download-click events flow through the existing neutral
data-track-* / hammerTrack bridge unchanged.
Introduce a SERVER platform (tag token `server`) and a third release
scope alongside All / Targeted. A server-only release produces a
`vX.Y.Z+server` tag, which matches none of the per-store publish jobs in
publish-release.yml, so no client app store upload runs while the server
distribution still builds and deploys out of band.
isPlatformReleaseTag now recognizes `+server`, so backout/revert clean it
up like any other release tag.
The bind address was hardcoded to 0.0.0.0. Add a bindHosts config list
(default ["0.0.0.0"]) so self-hosters can restrict the server to loopback
only, e.g. bindHosts = ["127.0.0.1", "::1"] when running behind a reverse
proxy on the same host. Each address gets its own HTTP and HTTPS listener.
Distinct from the existing `host` field, which remains the public display
name shown on the setup page.
Closes#590
* Update HOW-TO-RUN-A-SERVER.md
adding user and systemd setup.
* Update HOW-TO-RUN-A-SERVER.md
adding note about the installation directory fill.
* Update HOW-TO-RUN-A-SERVER.md
Update run.sh under linux
* Update HOW-TO-RUN-A-SERVER.md
Add caveat about using port 80.
* Update HOW-TO-RUN-A-SERVER.md
simplified bash run.sh and added where to put the run.sh script.
* Update HOW-TO-RUN-A-SERVER.md
Added steps for using Nginx as a reverse proxy for Hammer.
* Update HOW-TO-RUN-A-SERVER.md
Explicitly call out not to use the SSL steps for Java when using Reverse Proxy.
* Update HOW-TO-RUN-A-SERVER.md
fixed LetsEncrypt paths for copy-pasta.
added full location block to https example instead of the [...].
added missing file link step.
added the missing http to https redirect as well as a note about LE doing it.
removed dhparams line because unnecessary and may not be auto generated.
After a rotation + convergence, old content key generations linger in the
keyring as dead weight, and deleting the wrong one by hand destroys data.
prune-key reads the keyring (provider or --in), checks the database for which
content generations still protect rows, and drops every non-active generation
with zero rows on it; the active generation is never removed.
A generation still referenced is kept and reported (skip + report), so the
sweep never half-strands data. An explicit --key that is active or still
referenced fails instead of silently no-opping. The tokenHmac role needs no
database: only the active token key verifies tokens, so every non-active
generation is already dead.
KeyPruner holds the pure logic; the command resolves in-use key ids from new
distinctCiphers queries via AesGcmContentEncryptor.keyIdForTag. A parity test
pins all convergence queries to fold the legacy tag identically.
The feature has shipped (PR1-PR5), so swap the temporary working docs for an
operator guide: SERVER-SECRET-STORAGE.md is now "Encryption at rest & key
management" (the keyring, enabling/disabling encryption, generate-keyring /
inspect-keyring / rotate-key, --converge-dry-run, deleting an old key, and
upgrading an already-encrypted server). Linked from HOW-TO-RUN-A-SERVER.md.
Deletes the implementation plan doc.
- rotate-key subcommand: adds a new key generation to a role (content or
tokenHmac), makes it active, keeps the old keys, and emits the updated
keyring (stdout or --out). Offline flow: rotate-key -> place keyring ->
restart -> convergence re-encrypts onto the new key. KeyringCodec.rotate.
- --converge-dry-run: reports rows off the configured target and any entities
that would exceed the size cap once encrypted, then exits writing nothing.
- Crash/no-loss test: an injected mid-convergence failure leaves committed
rows re-crypted and the rest with their readable original; a re-run finishes.
This completes PR5: enable/disable/rotate convergence, the blocking boot gate,
the nullable-mode downgrade guard, rotation, and the dry-run.
EncryptionConvergence re-crypts story_entity and review_scene rows onto the
active cipher (per-row atomic updates, resumable: the tag column is the
progress ledger). The SQL predicate normalizes NULL to plaintext and the
legacy AES/GCM/NoPadding tag to v1, so a server upgrading from before key ids
sees no re-crypt churn. An over-cap row aborts with a named report.
EncryptionBootstrap is the boot gate (runs in appMain before routing):
- encryption.mode is now nullable. Unspecified + existing encrypted data is a
hard stop (admin must choose); explicit none converges to plaintext; explicit
aes converges to the active key.
- A last-applied marker in ServerConfigDao skips the scan on normal boots.
remaining(target) is the completion signal: 0 means fully converged, so an old
key is provably unreferenced and safe to delete.
rotate-key CLI and the dry-run land next.
Foundation for key rotation. AES content is tagged aesgcm:<keyId> instead of
"AES/GCM/NoPadding"; the registry holds one AES encryptor per content-key
generation in the keyring and resolves a row's encryptor by its tag. The
legacy "AES/GCM/NoPadding" tag aliases to aesgcm:v1 (the grandfathered key),
so existing rows keep decrypting.
Key derivation now takes the content-key value (cached per content-key +
client-secret), making the key provider keyring-agnostic. The active write
encryptor is the active key id's AES instance, or plaintext under mode=none.
No data migration here: new writes get the new tag, old rows read via the
alias. Converging old rows onto the active key is the next sub-commit.
TokenHasher now keys off the keyring's tokenHmac role instead of the raw
server secret, completing the content/token key split. When no keyring exists
at all (a zero-config plaintext server) it falls back to the auto-managed
server.secret, so auth still works with no setup. Any keyring present
(explicit or grandfathered) takes precedence.
A grandfathered keyring carries tokenHmac.v1 == the legacy server.secret, so
existing tokens keep verifying with no forced re-login. "No auto-generation"
now means content keys specifically; the token key stays auto-managed because
losing it only forces re-login, never data loss.
Replaces the single auto-generated content secret with a versioned keyring
read through a pluggable provider.
- Keyring/RoleKeys data classes + KeyringCodec (parse, serialize, generate,
grandfather). Key values are opaque strings used directly (PBKDF2 chars,
UTF-8 HMAC bytes), never decoded to raw bytes. New keys are base64(32 bytes),
fixing the lossy-entropy generation; a grandfathered key is the legacy
server.secret string verbatim so existing content stays readable.
- ServerSecretProvider with File and Env implementations, selected by a new
[secret] config block (default file).
- KeyringManager resolves the keyring (provider, else grandfather a pre-existing
server.secret) and fails fast when mode=aes has no content key.
- AES key provider reads the active content key; boot requires it under mode=aes.
- generate-keyring / inspect-keyring kotlinx-cli subcommands.
Content keys only: the tag format stays "AES/GCM/NoPadding" (aesgcm:vN with
rotation is PR5) and TokenHasher stays on the legacy server.secret until PR4.
EncryptionModeGuard.verifyOnBoot refuses to start the server when
encryption.mode is none but AES-tagged rows exist in story_entity or
review_scene. This stops a previously-encrypted deployment from silently
downgrading to plaintext on upgrade (the plaintext default would otherwise
leave existing AES data unreadable on write); the admin must explicitly set
mode=aes.
Unconditional for now. PR5 will refine it so an explicit mode=none triggers
convergence to plaintext instead, and add a second trigger on a keyring
content key being present.
Two PR2 follow-ups for a coherent encryption-mode story:
Reviews polymorphic: review_scene gains a cipher TEXT NOT NULL column
(schema v5, migration 4.sqm). ReviewRepository tags snapshots with the
active encryptor on write and resolves the row's encryptor from the registry
on read, mirroring story_entity. review_scene postdates at-rest encryption,
so existing rows have no plaintext history; the migration backfills them with
the AES tag (NULL would wrongly read as plaintext) via a temporary default
that is then dropped.
Plaintext default: a zero-config server now stores plaintext (EncryptionMode
default NONE) so a casual self-hoster needs no key material. Enabling AES is a
deliberate opt-in. EndToEndTest pins mode=aes since it exercises the AES path.
Adds an [encryption] config block (EncryptionMode aes|none, default aes)
that selects the active write encryptor. DI binds ContentEncryptor by
encryption.mode, so a server can be configured to write plaintext. Reads
remain polymorphic per-row, so existing AES rows still decrypt after the
mode changes.
Decryption now dispatches on each row's stored cipher tag instead of the
single DI-injected encryptor. Adds ContentEncryptorRegistry and an identity
PlaintextContentEncryptor (tag "none"); loadEntity resolves the row's
encryptor from its cipher column. NULL cipher resolves to plaintext
(pre-#367 rows); an unknown non-null tag fails loudly rather than reading
ciphertext as garbage. Store path is unchanged.
Tests: mixed-tag read (aesgcm/none/NULL), unknown-tag loud failure, and
cipher-orthogonal-to-hash invariant.