Signed-in readers can leave kudos at the end of a publicly published
story: up to four craft chips and one reaction. There are no comments
or ratings, so there is nothing to moderate.
- Kudos are anonymous. Authors see per-chip totals on their story page;
the public page shows a chip's name, never a count, once three readers
pick it.
- The reader card loads as its own HTMX fragment on the last page, so
the story page's ETag never depends on kudos. Private shares never
show it.
- Authors can turn kudos off per story; existing kudos are kept.
- New story_kudos and story_kudos_opt_out tables (migration 9.sqm).
Chip keys are a code enum, rows are purged in both directions on
account deletion, and soft-deleted givers drop out of the counts.
- Story URL resolution is shared between the story page, the read
beacon, and the kudos routes.
Only count runs of non-whitespace containing a letter or digit, so
horizontal rules, heading, bullet and blockquote markers are not words.
Move countWords to :base and use it on the server too.
Add a project-scoped user spelling dictionary (#939)
Writers can whitelist words per project: "Add to dictionary" on a flagged word in
the scene editor and focus mode, plus an add/remove word list in Project Settings
under Spell checking.
Words live in ProjectData.dictionaryWords so they sync with the project. A conflict
confined to the word list merges both sides by union with no resolver; other
conflicting fields still go through the existing resolver with the dictionary
unioned. The union is verbatim, so a word a newer build stored under laxer rules
is never deleted server-side. Sync writes also re-apply any project-data edit made
after the phase snapshot, so an edit landing mid-sync is no longer clobbered.
ProjectDictionaryService now feeds user words to the checker alongside encyclopedia
words, independent of the encyclopedia toggle. HdHairlineTagField and the new
HdHairlineWordListField share an extracted HdHairlineChipInput.
Encyclopedia entry titles and scene draft names were still on the old
restricted set while project and scene names had moved to the shared
ProjectNameValidator. Both now use that validator, so punctuation like
`. , ! ? : ( ) & - "` works, and draft names accept non-Latin letters.
Both filename formats move to the `~` delimiter, since `-` is now a legal
name character:
encyclopedia type~id~name.toml, images type~id~image.ext
drafts sceneId~draftId~name~timestamp.md
Names round-trip through encodeForFilename/decodeFromFilename, so
OS-forbidden characters become lookalikes on disk. Legacy patterns are
kept for reads, and path resolution falls back to the legacy filename,
because unlike scenes these two rebuild the filename from the def rather
than scanning the tree.
Migration2_3 renames existing files and PROJECT_DATA_VERSION goes to 3.
It canonicalises names through the encoder, otherwise a name ending in a
space (which the old rules allowed) would migrate to a file that no
rebuilt path could ever match. Draft names are now trimmed at the save
and sync boundaries the way entry names already were.
A synced name containing a path separator is no longer rejected outright;
it is encoded to a lookalike and stays one path segment, matching how
scene names already behave. Containment rests on the isWithin guard. The
reserved `~` delimiter is still rejected.
A standalone watch client that dictates notes into subscribed projects
and story ideas, then syncs them on its own session.
- New :wear module: capture activity, tile and complication, project
subscriptions, sync log, and a WorkManager periodic plus
after-capture sync behind a single SyncCoordinator
- Pairing over the Wearable Data Layer: the phone confirms the request
and mints a session for the watch's install via the new
/api/account/pair_install endpoint. Play Services builds only; the
F-Droid build ships without it
- Manual sign-in on the watch, with an Android 17 local network
permission check before contacting a LAN server
- Plaintext sync is opt-in: typing an http:// URL selects it, with a
warning in server setup. HTTPS stays the default, and legacy
settings always restore as HTTPS
- Account sync extracted into SyncAccountUseCase, shared by the phone
project list and the watch
- temporaryProjectTask ref-counts concurrent users so one task no
longer closes a scope another is still using
- Wear version codes are offset from phone codes so both can ship in
one Play listing
- Crash handler and FileLogger moved into common for reuse
The iOS build now ships under the same App Store record as macOS, so the
home page and README link to id6770841038.
* Offer the iOS app with a smart app banner on the home page
* Say why a login failed instead of answering a bare 401
A failed login gave nobody anything to work with. StatusPages matched status 401
and re-responded with a bare status for API calls, discarding the HttpResponseError
the route had just written, and the client's own 401 branch discarded whatever
body did survive in favour of a generic string. Two layers erasing the same
message, so a self-hoster saw "401 Unauthorized" in the log, an empty body on the
wire, and a generic message in the app.
StatusPages now leaves API responses alone, since the routes answer 401 with
their own body, and the client parses the body for every status and only falls
back to a generic message when the server sent nothing usable.
On top of that:
- HttpResponseError carries an optional errorCode from a shared ApiErrorCode
vocabulary. It is optional and the shared serializer ignores unknown keys, so
it is compatible with servers and clients on either side of this change.
- Account creation failures use accurate statuses rather than a blanket 409:
400 for a policy or email violation, 409 for a real conflict, 403 for the
whitelist. Login answers 403 when the whitelist was the problem, since that is
not a credential failure.
- The login path logs which failure occurred. The response still cannot
distinguish an unknown account from a wrong password, because that would let
anyone enumerate users, but the operator's log now can.
The docs gain the password policy (8 to 64 characters, no complexity rule,
nothing stripped or truncated) and the log lines to look for, which is what the
reporter of #835 asked for.
* Allow the round-trip test accounts now that Allowed Users is always on
Only the first account on a server is exempt, so every later address the test
creates has to be on the list or account creation answers 403.
* Point the login-failure note at the Allowed Users section
The section was renamed, so the anchor was dead.
* Cover the account error statuses and the client's failure body
AccountErrorCodeTest walks the create and refresh failures a client branches on:
existing email, email pending deletion, malformed email, short password, unknown
refresh token. ApiFailureBodyTest covers the other end, where a server message
has to survive instead of being replaced by the generic one.
RecordingStrRes moves to its own file so both API tests can use it.
* Mark the password error codes as non-secrets for semgrep
`SYNCING-PROTOCOL.md` makes adding a field to a synced model a client-only
change: the server stores content as an opaque blob, and a peer that predates
the field is expected to drop it on decode. That only holds if the decode
ignores unknown keys.
Every `ContentNegotiation` install used a bare `json()`, which is Ktor's
`DefaultJson`: `encodeDefaults` and `isLenient`, but no `ignoreUnknownKeys`. So
instead of dropping the field, an older peer failed the whole sync. A 3.6.0
client hitting project data written by 3.9 got:
Sync failed: Illegal input: Unexpected JSON token at offset 72:
Encountered an unknown key 'language' at path: $.data
`createJsonSerializer` already sets `ignoreUnknownKeys`, but it is meant for
human-facing files: `prettyPrint` would put tabs and newlines in every request
body, and `coerceInputValues` would silently coerce bad values on the wire.
Split the two: files keep that one, machine-facing content gets
`createNetworkJsonSerializer`, resolved through `NetworkJsonQualifier`.
Applies to the three `ContentNegotiation` installs and the four classes that
decode wire content with an injected `Json`: `ServerProjectApi` (entity
payloads), `ProjectDataApi` and `ServerIdeasApi` (conflict bodies), and
`GithubVersionCheckDataSource`. The file-facing injections are unchanged.
This cannot reach already-shipped builds; 3.6 through 3.9.x keep failing
against projects a newer client has touched.
renderPaginated never walked the scene tree, so share pages came out in
entity id order and any project not written front to back rendered
shuffled. Scenes now flatten depth first, siblings by order, with
unreachable ones appended rather than dropped.
A scene-limited share reads only its own scenes and the groups above
them, found via each scene's path, and its validator covers those groups
so a chapter swap no longer serves a stale order. Bumped RENDER_VERSION,
without which cached shares would keep serving the old order.
Chapter headings render as the author wrote them, unnumbered.
Adds a `timezone` config setting, with `HAMMER_TIMEZONE` and `TZ` as
environment-variable fallbacks, applied at startup so both rendered page
timestamps and log lines use it.
Entry names and aliases become session-only words (AppLocal scope, never
the OS dictionary) while their project is open, cleared on close. Words
are tokenized to single words and filtered against the base dictionary
so only unknown spellings are added.
Three levels of control, all live-reactive:
- Global toggle in Spell Check settings (SpellCheckerSettings, default on)
- Per-project toggle (ProjectData.encyclopediaDictionary, synced;
hashed only when false so existing hashes are stable)
- Per-entry exclusion (EntryContent.excludeFromDictionary, synced;
hashed only when true, same zero-bytes-at-default rule as aliases)
SpellCheckRepository holds session words keyed by ProjectDef and emits a
fresh checker instance whenever the effective word set changes, so open
editors re-run their full scan; the same path re-applies words on locale
change and re-enable. ProjectDictionaryService (ProjectDefScope, eagerly
started in initializeProjectScope) rebuilds the word set from the
encyclopedia on entry changes, debounced, and clears it on scope close.
#909 installs EnglishFallbackMessageResolver, so every call.t/Msg.r site
already resolves an untranslated key against English. The helper added in
#915 duplicated that for three call sites, and its comment described a
hazard that no longer exists.
The German-locale rename conflict test stays; it passes through the
resolver instead.
Account sync pushed renames before deletions, so renaming a project into
a name still held by one queued for deletion violated the server's
UNIQUE(name, user_id). Phases now run delete -> rename -> create.
The violation also escaped as a 500: ProjectDao maps SQLSTATE 23505 to a
ProjectNameTaken failure and /rename answers 409 Conflict.
Also in here:
- Renames queued against a server-tombstoned project id are dropped.
They could only 404 and requeue every session.
- API error messages fall back to English. The locale bundles have no
base Messages.properties to parent through, so a key present only in
English threw MissingResourceException and made the response a 500 for
every other locale.
- Rename's ProjectNotFound reported "Invalid project name".
ktor-i18n's ResourceBundleMessageResolver threw MissingResourceException
for any key a translation had not picked up yet, since the locale bundles
have no parent bundle to inherit from. Every Msg.r/call.t site turned that
into a 500, most visibly on /signup: rejecting a not-allowed email needs
api_allowedusers_rejected, which Crowdin had not yet delivered to de.
Fixes#883 (registration half)
Argon2 hashing went through JNA, which extracts libjnidispatch.so at runtime.
On Linux JNA ignores java.io.tmpdir and uses $XDG_CACHE_HOME, falling back to
<user.home>/.cache, and the Docker image sets user.home=/data. That puts the
shared object on the data volume, so hosts mounting it noexec cannot load it,
and signup and login returned a 500 (#884).
Argon2PasswordHasher derives with BouncyCastle instead, reading the variant,
version, and cost parameters back out of the stored PHC string. Its output is
byte-identical to libargon2 for the same inputs, so existing hashes keep
verifying; the tests pin that against hashes generated by the C implementation.
JNA is still on the runtime classpath via the CLI's terminal library, so the
image also points XDG_CACHE_HOME at its own layer.
Add email search to the Allowed Users admin page
Filter the list by full or partial email, matched case-insensitively anywhere in
the address. The query survives sorting, paging, remove and both edit dialogs.
The input sits in the page shell, not the swapped fragment, so it keeps focus and
caret while typing; the fragment renders its view state as hidden inputs that the
search box and add form pull in via hx-include.
Also fixes three faults on this path: paginated queries ordered on date_added
alone, letting LIMIT/OFFSET repeat and skip rows that share a timestamp; "Page 1
of 0" from a mustache section on a boxed Integer; and a dead htmx:afterSwap
handler reading an always-empty query string.
* Add per-share scene restriction table and DAO plumbing
* Carry scene restrictions through ProjectAccessRepository
* Filter public story renders to a share's scene set
* Add scene selection to the private share dialog
* Mount share and publish-warning dialogs outside the sticky sidebar
* Keep the share dialog open on a rejected create and let expired shares' passwords be reused
* Drop scene path fixes swept in from another branch
These three files belong to the order-padding fix in #896, not to the
share work. They were picked up by a broad add and are reverted here so
this PR carries only the share changes.
* Harden scene-limited shares per code review
Atomic duplicate-password check under a project-row lock, shared scene-set validation that rejects group ids (review requests included), Set-based fail-closed restriction model, one clock for expiry, live scene counts that surface dead shares, symmetric child-row cleanup, surfaced tree-load failures, api_error_unknown fallback, and dedup of the sheet-dialog CSS.
The scene picker was a scroller inside another scroller, capped at 260px, so
it stayed cramped no matter how tall the window was. The dialog body is now
the only scroll region and the list renders at full height inside it.
The "All scenes / Select all" row was position:sticky inside that inner
scroller, with side margins and container padding around it, so rows showed
through the gap above it and the gutters beside it. It is now a plain header
bar above the list, outside the scroller.
The dialog also sat inside the story sidebar, which is position:sticky and
therefore a stacking context its z-index could not escape, leaving it painted
under the site header. Moved the container out to a sibling of <main>.
Short viewports get dvh units, trimmed dialog padding, and a full-bleed sheet
under 600px wide.
A page of dialogue reached readers as one packed block. CommonMark reflows
prose: single newlines become spaces and any run of blank lines collapses to
one paragraph break, so what an author sees while writing was not what anyone
else saw. Prose is now laid out as it was typed, on every surface that renders
it: every newline starts a new line, every blank line is a blank line.
- base/markdown/ProseHtml.kt holds that layout as AST-level generating
providers, shared by the server's pages and the client's EPUB export. Lists,
tables, code and headings keep markdown's own layout by construction, so the
old fence-tracking preprocessor is gone.
- parseProseMarkdown does the same for the DOCX, RTF and PDF exports, with a
ProseBlock.Blank for an authored blank line. A body paragraph carries space
after it only where prose ends, so lines that run on sit tight and the
indent parts them.
- Quoted passages keep their lines, and a quote's '>' markers no longer leak
into the text of a continuation line in the document exports.
Walking every construct the flavour can emit turned up three more faults. A
stripped element keeps its text, so tables (which the sanitizer allowed none
of) arrived as their cells run together; they are allowed and styled now,
column alignment included. An ordered list starting at 5 rendered as 1. Line
endings went unnormalized once the preprocessor that had done it incidentally
was removed.
The story's declared language now reaches the prose on the author's own page
as well as the public one, so a French story is not hyphenated by the rules of
whatever locale the author reads Hammer in.
RENDER_VERSION goes to v4 and moves into the fingerprint: it keyed the disk
cache but not the ETag, so a bump alone would have served stale prose to
anyone holding the old validator.
Always-on Allowed Users list and public signup page
The whitelist toggle is gone: every server now enforces the allowed users
list, and the web UI, routes, and message keys are renamed to "Allowed
Users". The Kotlin, database, and REST names are unchanged.
Adds a public /signup page so an allowed user can create their own account
from the web instead of only through the app. It goes through
AccountsComponent.createAccount, so the allowed users list and the Terms of
Service challenge are enforced, and the POST shares the login rate limit.
A not-allowed email is audited like a failed login.
Wires the dormant DataMigrator into startup with one-time completion markers
in server_config, and adds a migration that seeds the list with every
non-deleted account.
The instance band now runs the message through MarkdownService, so admins can
use links, emphasis, and lists in it. Output is sanitized by the same OWASP
policy the About page uses, and a message that sanitizes down to nothing no
longer paints an empty band.
* Record web sign-ins in the security audit trail
The web login form never called recordLoginAttempt, so only app clients
hitting /account/login showed up on the Security monitoring page.
Wire /login in, auditing what the server actually allowed: a whitelist
rejection is recorded as a failure, matching the API path. Move the
monitoring gate into SecurityRepository so both routes honor the
loginTrackingEnabled and storeLoginIp settings by construction, store
blank emails as null to keep them out of the per-account brute-force
queries, and log rather than propagate a failed audit write so it can't
break the sign-in. Rate limit the web login POST like the other two
login entry points.
* Suppress TooGenericExceptionCaught on the audit write
The catch is deliberately broad: no failure mode of recording an attempt
may break the sign-in that triggered it.
CommonMark has no strikethrough, so `~~struck~~` reached every server
rendered page as literal tildes while bold and italic worked. The editor
that writes the content parses GFM, so the two disagreed.
MarkdownService now parses GFM, matching the editor. GFM emits
strikethrough as `<span class="user-del">`, which the sanitizer would
unwrap for having no allowed attributes, so it is rewritten to `<del>`
and `del` is allowed through. Bumps RENDER_VERSION so cached story pages
regenerate.
The review page had the same gap for its own reason: parseInlineMarkdown
only branched on `*` and `_`. It now handles `~~` and threads the flag
through to both render sites.
* Give rendered stories real book typography
Lists sat at the prose margin with their items packed together, thematic
breaks ran the full column width, and any run of blank lines an author
typed collapsed to a single paragraph gap.
- MarkdownService emits a break for each blank line past the first, so
deliberate white space survives CommonMark's collapsing. Runs are
capped and fenced code is left alone.
- StoryRendererService separates sibling scenes with a blank line;
without it the last paragraph of one scene and the first of the next
parsed as a single paragraph.
- story.css styles every element markdown can emit: indented lists with
accent markers and breathing room, a centered scene-break rule with an
ornament, a tinted blockquote panel, code and links, h3-h6, and ragged
right prose below 600px where justification opens rivers.
* Indent every prose paragraph, including a page's first
Each page opens with the scene's heading, so the flush-left rule for a
paragraph following a heading swallowed the indent on the first line of
every page. Drop the rule entirely: paragraphs after a scene break, list,
or quote now indent too, so the prose is uniform.
* Keep blank-line spacing to the prose that asked for it
Review of the branch turned up four ways the blank-line work reached
further than intended.
- markdownToSafeHtml takes preserveBlankLines, defaulting off. Bios, the
About page, the privacy policy and the review frontend render with
CommonMark's collapsing again; only story rendering opts in.
- A code fence is now tracked by its delimiter and length, so a ~~~ line
inside a ``` block no longer ends it and leaks a literal <br /> into the
code. A blank run between two indented lines is left alone, which covers
indented code blocks and fences nested in list items.
- The chapter heading no longer carries a leading newline; with
appendScene's trailing blank line it made a run long enough to render a
break above every heading.
- RENDER_VERSION goes to v2 so cached story pages re-render.
* Fold the group word count into the scene walker
buildGroupMarkdown had its own copy of writeGroupChildren, differing only
by a word-count accumulator, so every change to how scenes are separated
had to be made twice. writeGroupChildren now returns the count and the
single-group export uses it.
Also brings the Story Prose table in the design system doc back in line
with the rules story.css actually carries.
Adds trustProxyForwarding, which reads each request's client address and scheme from the X-Forwarded-* headers so the login rate limiter, the login audit trail and story reader counts see real clients rather than the proxy.
Reads the last X-Forwarded-For entry rather than Ktor's default first entry, which arrives with the request and can be forged to claim a fresh rate limit bucket per request.
The first-run /setup page now hosts an email/password form that creates
the initial admin account, signs the operator into a web session, and
redirects to /admin. The app-based setup steps remain as a secondary
section. POST is rate limited alongside login and re-checks hasUsers()
to guard races and double submits.
English is the fallback bundle every locale resolves through, so a key the
server looks up but Messages_en.properties does not carry throws
MissingResourceException at request time. A Crowdin sync rewrote the English
source file and dropped 129 live keys, which 500ed the home page, account
refresh, and account deletion.
The test scans msg/Msg.r/localizedMsg call sites and mustache msg.* references
and asserts each resolves in English. Parity is checked against referenced keys
rather than the translation files, which Crowdin owns and which carry strings
that have outlived their use in the templates.
* 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
Both access-control plugins built their htmx denial with createHTML().div, whose result was discarded, so the response was an empty <html></html> and the caller saw nothing happen.
Redirect by header instead of trying to deliver a message in the body. htmx acts on HX-Redirect before it decides what to do with the body, which matters here: it discards the body of a 4xx outright, and StatusPages answers a 401 with the whole unauthorized page, which must not end up swapped into a fragment. Browser requests keep the redirect they already had, so both kinds of caller now land in the same place.
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.