Commit graph

90 commits

Author SHA1 Message Date
Adam Brown
8fcc067446
Add a Google Analytics provider (#643)
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.
2026-06-23 20:48:56 -07:00
Adam Brown
86c26bfc88
Update SERVER-SECRET-STORAGE.md for clarity on upgrades
Clarified the handling of the 'server.secret' file after upgrade, emphasizing its automatic reading and the need for explicit mode setting.
2026-06-23 16:44:06 -07:00
Adam Brown
a0ba72a75e Add "Server only" publish scope to prepareForRelease
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.
2026-06-23 00:38:08 -07:00
Adam Brown
5dd8400fa0 Rename file to match pattern 2026-06-23 00:09:49 -07:00
Adam Brown
65067460d1 Update key migration docs with findings from the first key migration 2026-06-22 23:36:47 -07:00
Adam Brown
88b7e586fd
Fix formatting of warning message in documentation 2026-06-21 21:11:38 -07:00
Lindsay
1ed25d978f
Minor changes to server run doc (#627)
minor updates, particularly regarding DNS requirements for access and bindHost usage in the reverse proxy configuration for security.
2026-06-21 18:04:07 -07:00
Adam Brown
cbaa7385d4
Add configurable server bind addresses (bindHosts) (#623)
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
2026-06-20 00:58:29 -07:00
Adam Brown
1fd9a016b3 Cleanup some docs files 2026-06-20 00:12:28 -07:00
Wavesonics
d157ca83c4
Explain why self-signed won't work 2026-06-19 19:04:29 -07:00
Wavesonics
d5ea29151c
Remove self signed certs section
They won't work with client API calls, so they are worthless to us
2026-06-19 19:01:54 -07:00
Adam Brown
a08f42d8ee Clean up and clarify how to run server doc 2026-06-19 18:14:47 -07:00
Lindsay
3d52281e7f
Reverse proxy docs (#592)
* 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.
2026-06-19 18:07:36 -07:00
Wavesonics
8d7ae2ab2a
Add testing to Server SSL cert loading
Update Server docs on setting up SSL
2026-06-19 17:25:17 -07:00
Adam Brown
7f5696236e Improved crypto migration story for existing servers 2026-06-18 02:29:42 -07:00
Adam Brown
2424a3d2ba Add prune-key subcommand to remove unused key generations
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.
2026-06-18 00:11:24 -07:00
Adam Brown
936d12fe9d Organize sub-commands into files 2026-06-17 23:18:28 -07:00
Adam Brown
3f6da503ae Improve docs 2026-06-17 22:59:27 -07:00
Adam Brown
408a2b720e Replace secret-storage design docs with a user-facing admin guide
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.
2026-06-17 02:01:58 -07:00
Adam Brown
836e2883cc PR5c: rotate-key CLI, convergence dry-run, crash/no-loss test
- 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.
2026-06-17 00:25:58 -07:00
Adam Brown
06568869c0 PR5b: blocking pre-launch encryption convergence
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.
2026-06-17 00:11:58 -07:00
Adam Brown
560b8c1294 PR5a: key-id-aware cipher tags (aesgcm:<keyId>)
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.
2026-06-16 23:23:34 -07:00
Adam Brown
2d1384db3a PR4: hash auth tokens with the keyring's tokenHmac role
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.
2026-06-16 22:53:00 -07:00
Adam Brown
b03eb43782 PR3: versioned keyring, pluggable secret provider, generation CLI
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.
2026-06-16 22:04:57 -07:00
Adam Brown
ebb3b6e7ed Hard-stop boot when plaintext mode meets encrypted data
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.
2026-06-16 21:14:11 -07:00
Adam Brown
3a98622759 Make review snapshots polymorphic and default to plaintext encryption
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.
2026-06-16 20:32:33 -07:00
Adam Brown
c8c6717e8c PR2: config-selectable content encryption mode
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.
2026-06-16 19:56:56 -07:00
Adam Brown
6897abbd76 PR1: polymorphic content decryption via cipher registry
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.
2026-06-16 19:52:37 -07:00
Adam Brown
9d52fe61c3 docs: add testing strategy + per-PR test gates for secret storage
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.
2026-06-16 19:37:59 -07:00
Adam Brown
7a1c10c446 docs: keyring CLI uses existing kotlinx-cli parser, no new dependency 2026-06-16 19:24:08 -07:00
Adam Brown
b3300db568 docs: finalize secret-storage decisions; drop background sweep
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.
2026-06-16 19:18:22 -07:00
Adam Brown
6264c86c2e docs: add server secret storage implementation plan 2026-06-16 18:08:40 -07:00
Claude
1859d53635 docs: add server secret storage & key management design doc
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.
2026-06-16 18:08:40 -07:00
Adam Brown
ed7ba52bf7
Update ROADMAP.md to reflect completed tasks
Removed completed tasks from the roadmap.
2026-06-16 01:22:00 -07:00
Adam Brown
9b3a2dc7d6
Update roadmap by removing future features
Removed 'Editor Requests' and 'Publish on Web' features from the roadmap.
2026-06-16 01:21:13 -07:00
Lindsay
6ed6782372
Adding systemd example (#581) 2026-06-11 00:46:19 -07:00
Adam Brown
31b98876f7
Pre-sync change probe: skip syncing unchanged projects (#584)
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.
2026-06-10 23:15:50 -07:00
Adam Brown
c632d048fb
Fix project sync session lockout (same-install session reclaim) (#583)
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.
2026-06-10 19:29:01 -07:00
Adam Brown
d1e265a44c
Add Foundation primitives tier; rename Id/Sync/Settings repos (#558)
* 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.
2026-06-06 01:17:13 -07:00
Adam Brown
fb5251fe83
Replace GlobalSearchRepository with SearchProjectUseCase + retained state (#557)
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).
2026-06-06 01:08:17 -07:00
Adam Brown
bc1a66fc1e
Decompose SceneEditorRepository into focused repositories + a service facade
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.
2026-06-06 00:57:35 -07:00
Adam Brown
db4343d8f1
Adds a full monitoring subsystem to the server admin panel
- 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
2026-06-05 19:19:11 -07:00
Adam Brown
cd48c3289e
Allow more run-time config of umami 2026-06-04 23:04:35 -07:00
Adam Brown
b2c42b1aa9
Add configurable web analytics for the server web frontend (Umami) (#534)
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.
2026-05-31 21:46:28 -07:00
Wavesonics
882715c07e Update ARCHITECTURE.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:23:21 -07:00
Adam Brown
dbd0b4d013
Added the ability to publish to a single store
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.
2026-05-24 00:51:40 -07:00
Adam Brown
b9d5ed68f0
Update roadmap 2026-05-24 00:19:16 -07:00
Adam Brown
8f2592ad58
Merge branch 'postgres-server-refactor' into develop
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.
2026-05-22 23:30:23 -07:00
Wavesonics
2517bb80c6 Add manifest-driven asset generation pipeline
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.
2026-05-22 19:26:49 -07:00
Adam Brown
519b8a8542
Document PostgreSQL storage and the SQLite-to-Postgres migration 2026-05-21 02:00:52 -07:00