Commit graph

100 commits

Author SHA1 Message Date
Adam Brown
2f49ae2f8d feat(server): auto-load config.toml from data directory
When no --config is passed, load ~/hammer_data/config.toml if present, else fall back to defaults. Doc renamed serverConfig.toml -> config.toml throughout.
2026-07-08 13:19:19 -07:00
Adam Brown
b4e4a08702
Story Ideas: account-level idea capture with sync (#720)
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.
2026-07-05 01:49:04 -07:00
Adam Brown
54f04c99ef Add Project Tags with tag search on the project list
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.
2026-07-03 23:12:04 -07:00
Adam Brown
df0efa7775
Move account sync endpoints from GET to POST with legacy fallbacks (#716)
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
2026-07-02 22:29:37 -07:00
Adam Brown
7a17ccfbd0 Add same-install reclaim to account-level sync sessions
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).
2026-07-02 02:03:55 -07:00
Adam Brown
c765b47c55 Heal a server-lost entity by re-uploading the surviving local copy
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.
2026-07-02 01:32:46 -07:00
Adam Brown
ed9638b3b1 Replace the 412 stale-hash dance with server-side read-repair
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.
2026-07-02 01:15:39 -07:00
Adam Brown
0deb358e8c Correct the sync protocol doc to match the implementation 2026-07-02 00:32:30 -07:00
Adam Brown
a2f1b3b09a Add the new client driven self healing to the sync doc 2026-07-01 23:54:02 -07:00
Adam Brown
efb0f62c41
Identify projects by projectId in sync API URLs (#679)
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.
2026-06-26 22:21:33 -07:00
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