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.
Frame tests around the catastrophic failure modes (unreadable data, mid-convergence
loss, silent corruption, false 'safe to delete' signal, auth breakage). Add the
grandfather golden-corpus test (incl. lossy-secret trap), crash/no-loss and
completion-signal gates, over-cap handling, and a convergence dry-run. Move the
plaintext read path into PR1 so NULL/plaintext rows are readable immediately.
Record the four settled decisions (NULL=plaintext, single-JSON keyring with
roles, File+Env explicit-selection providers with no auto-generation, blocking
pre-launch convergence). Drop the online background sweep in favor of
offline-only maintenance, add a read-only admin encryption-status view, and note
the design doc will be replaced by an admin tutorial when the feature ships.
Capture the design discussion for moving the server secret off the
co-located filesystem file toward a pluggable, versioned keyring with
per-row encryption modes (including no-encryption), online key rotation,
and a provable key-retirement signal.
On app open, sync made ~4 HTTP round-trips per project even when nothing
had changed. Add a batched pre-sync probe: the client sends a project-wide
content hash for each eligible project in one request, and the server
returns which ones still match so the client can skip syncing them.
- base: ProjectContentHasher (entity hashes + project-data hash, computed
identically on client and server) and probe request/response DTOs
- server: POST /projects/{userId}/sync_probe, recomputing each project's
hash from stored state; skips projects with an in-flight sync session;
400 on a malformed body
- client: cache the hash in the project journal (written at FinalizeSync,
invalidated on any content mutation via one chokepoint); probe is
best-effort and degrades to a full sync on any failure
- backstop: never skip a project with pending entity or project-data work,
so a stale cache can't drop a change
Writing activity is excluded by design (per-device, conflict-free).
Tests: ProjectContentHasher, client/server probe units, and an e2e suite
covering client/server hash agreement, the session gate, auth, and 400s.
Server reclaims a project sync session only for the originating install
(derived from the auth token), so a leaked or cancelled session no longer
blocks the owner; a different active install is still rejected. Client
ends sessions under NonCancellable so end_sync isn't dropped when a sync
coroutine is cancelled.
* Rename IdRepository to IdAllocator
It is a special foundational primative
* Rename SyncDataRepository to SyncJournal
It is a special foundational primative
* Rename GlobalSettingsRepository to GlobalSettingsStore
It is a special foundational primitive
* docs: add Foundation primitives tier to the architecture doc
Document IdAllocator, SyncJournal, and GlobalSettingsStore as a fixed set of
stateful, cross-cutting primitives that the whole data layer may depend on —
acyclic leaves (GlobalSettingsStore <- SyncJournal <- IdAllocator) that named
the dependency reality instead of treating it as a no-sibling violation.
GlobalSearchRepository was a stateful class masquerading as a Repository while
fanning out across six repositories — really cross-repo coordination with UI
state bolted on. Split it to match the layering:
- SearchProjectUseCase: stateless cross-repo search (data layer)
- GlobalSearchState: component-layer holder (state + debounce), retained on
ProjectRootComponent via InstanceKeeper so search survives the modal being
dismissed/reopened and config changes; stateKeeper carries query+filter
across process death
- GlobalSearchComponent: thin presenter delegating to the holder
Search state now lives in the component layer instead of a project-scoped
singleton, and MutableValue updates run on the main thread (the use case
offloads its fan-out to the default dispatcher).
Split the monolithic SceneEditorRepository into single-responsibility pieces:
- SceneRepository — scene tree, structure, ordering, paths, on-disk layout
- SceneContentRepository — in-memory buffers, autosave, dirty tracking
- SceneMetadataRepository — per-scene and project metadata
- SceneEditorService — the component-facing facade that orchestrates the three
and applies the cross-cutting side-effects (statistics, writing activity,
reference index) the repositories deliberately don't reach up to perform
Components and synchronizers now talk to SceneEditorService; the underlying
repositories hold no sibling-repo dependencies. Tests are reorganized to match
the new boundaries, and the scene-editing domain API is documented in
docs/DESIGN_PATTERNS.md.
- Data foundation: SQLDelight tables for API metrics, error logs, and
login attempts with a v1→v2 schema migration
- Metrics pipeline: per-endpoint request counts, latency, and error
rates collected via a Ktor plugin; daily rollup job with configurable
retention windows
- Error tracking: fingerprinted deduplication, occurrence counts,
first/last seen timestamps, and email alerting on persistent errors
- Security page: login-attempt tracking, brute-force/spray detection
with cooldown alerts, optional IP storage
- Admin dashboard UI: performance charts (Frappe), error panel with
route filter and JSON export, live log viewer (10k ring buffer),
security event feed
- Alert deep-links from the dashboard into filtered error views
- Settings UI: all monitoring options converted to toggle switches;
master switch disables and grays out sub-options via JS; email field
conditionally required with htmx:before-request validation
Introduce an extensible [analytics] server config section. The first
supported provider is Umami; the design (provider enum + per-provider
config block + AnalyticsProvider abstraction/factory) allows adding more
providers later without touching the rendering or CSP wiring.
We've run into many problems where everything publishes fine, except 1 store. This provides a way to unblock that one store without pushing to the rest.
Migrate the sync server's storage from SQLite to PostgreSQL with two
drivers: an in-process Zonky embedded server (the personal-install
experience) and a remote-Postgres mode (for multi-tenant deployments).
Single dialect, single set of .sq files, single set of DAOs.
The v1 schema bakes in Tier 1+2 cleanup: CITEXT for case-insensitive
columns, UUID for project ids, TIMESTAMPTZ everywhere, real BOOLEANs,
BIGSERIAL keys, partial indexes for sparse columns, and explicit ON
DELETE behavior plus CHECK constraints on every table.
A one-shot SQLite-to-Postgres migrator runs at startup against an
existing ~/hammer_data/server.db, copies it row-by-row in a single
transaction, verifies row counts in-transaction, then renames the
source to server.db.migrated-<ts>.bak. --migrate-dry-run lets operators
rehearse against a copy of production.
Verified:
- :server:test 409/0/0/0 (~2 min, embedded Postgres shared across the
JVM with truncate-between-tests)
- :integrationTests:jvmTest 9/0/0/0 (~54s)
- :desktopTest green via pre-commit hook
- Embedded-mode boot smoke against a fresh ~/hammer_data
- Remote-mode boot smoke against a Postgres 16 container, schema landed
cleanly with CITEXT/UUID/TIMESTAMPTZ promotions intact.
Notable carry-overs documented for ops:
- auth_token PK changed from `token` to (user_id, install_id); the
migrator collapses any historical duplicates via ON CONFLICT.
- One-shot migration is irreversible; an off-box backup of server.db
is the operator's job, not a replacement for the in-process .bak.
- 120MB of embedded Postgres binaries are now bundled in the server
jar (one of five platform artifacts is extracted at boot).
- Pre-commit hook now skips :server:test; CI should still run it.
Replace ad-hoc icon scripts with a single source-of-truth manifest
(scripts/assets.yaml) and a Python generator that renders every app icon,
store-listing graphic, MSIX tile, favicon, and the Play feature graphic from
two SVG sources. Compositions (icon + "Hammer" wordmark in Kingthings
Trypewriter) are defined once as percentages of canvas and reused across the
Play feature graphic, MSIX wide/splash tiles, and the Snap featured banner.
Adds the missing snap store icon, fixes the snap desktop icon to the correct
256x256 hicolor size, and routes uploads-only assets to build/store-assets/
(gitignored).
See docs/ASSET-GENERATION.md for the manifest schema.