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.
* Fix the crowdin.yml glob that was skipping strings.xml
Crowdin's * requires at least one character, so strings*.xml matched the
16 strings_*.xml files but never strings.xml itself. That file had been
silently absent from every sync. The composeResources values directory
only holds value resources, so *.xml is the safe form.
The android pattern is left alone: that directory also contains theme.xml
and ic_launcher_background.xml, which must not be uploaded.
* Sync translations from Crowdin
First download since the string cleanup and the export-processor fix.
- Removes the 98 retired keys from all six locales, so every values-* now
carries the same 984 keys as values/.
- Drops Crowdin's spurious \' and \" from the Compose resources. Compose
never unescaped those, so they were rendering on screen.
- Picks up 38 newly added server strings, which arrive as English until
they are translated.
hammer.ink scored 90 desktop / 61 mobile on Performance (LCP 11.1s,
FCP 4.2s) with a 1.3 MiB hero image and ~3.7s of render-blocking CSS
dominating the cost. No build step is added; generated artifacts were
produced by one-time local commands and committed.
Hero image: masthead.jpg (2976x1200, 1.28 MiB) replaced with WebP
variants (163 KiB desktop, 54 KiB mobile) plus a JPEG fallback via
image-set(), preloaded with fetchpriority="high". Preload media queries
exactly complement the .masthead breakpoint so only one variant is
fetched.
Render-blocking: error.css is now delivered per-page via page_stylesheet
instead of on every request; Lora moved out of an @import in base.css to
a head <link> with preconnects; Font Awesome loads at media="print" and
is promoted by a new async-css.js with a <noscript> fallback; htmx is
deferred.
Fonts: font-display: swap on Kingthings, plus a WOFF2 (137 -> 50 KiB)
listed ahead of the TTF.
Caching: asset URLs carry ?v=<build version>. Versioned requests get
max-age=31536000, immutable; unversioned ones keep the existing 1 day /
7 day windows so stylesheet-internal references cannot go stale.
Accessibility: aria-labels on the download selects and links, heading
hierarchy fixed so each page reads h1 -> h2 -> h3 (the per-page
site-title h1 is now a span), and .footer-version raised to 6.9:1
contrast. New keys land in Messages_en.properties only.
Also sets Cross-Origin-Opener-Policy: same-origin.
The class is backed by embedded Postgres, not SQLite, and the old name gave
no hint that the instance is a handle onto one process-wide database shared
by every test in the JVM. Document that its truncate is the only isolation
boundary, so anything that can write on its own schedule must be shut down
before a test ends.
* Serialize access to the shared test FakeFileSystem
The fake is shared by the test thread, the server's Jetty threads and the
client's dispatcher threads, and it is not thread-safe: it tracks open files
in a plain ArrayList. An autosave write on the client's dispatcher thread
while a sync read on the JUnit thread iterated that list threw
ConcurrentModificationException, failing the sync and with it
StaleAllocatorAfterSyncTest.
Wrap it in a SynchronizedFileSystem that locks every call, including the
returned Source/Sink/FileHandle — the fake mutates its open-file list when
those close, not only when they open.
EndToEndTest held the Ktor ApplicationEngine and stopped that in tearDown.
Only EmbeddedServer.stop() destroys the application, and that is the sole
place ApplicationStopping/ApplicationStopped are raised, so the recurring
jobs every e2e test started kept running for the rest of the JVM against
the process-wide Postgres. Their ticks then wrote to tables later tests had
just truncated, which is the intermittent :server:test failures on develop.
Hold the EmbeddedServer and stop that. Parent each recurring task's scope
to the application job and move its graceful stop to ApplicationStopping,
so a teardown that misses the lifecycle event still can't leave a loop
running. Assert in tearDown that no task survived the stop.
The OG card and rendered story HTML caches were hard-coded to
hammer_data/cache. A [cache] block now sets the root directory and the
per-cache size bound, so an admin can move them to a scratch partition.
A configured directory resolves relative to the config file and is probed
for writability at startup: an unusable path aborts rather than degrading
to a permanent cache miss that just looks like a slow server.
* Close the gaps in cache directory validation
Validating after path resolution made the blank-directory guard dead: a
blank value resolves to the config file's own directory, which then looks
like a perfectly good absolute path, so the caches would land next to the
database instead of aborting. The cache block is now validated on the
parsed config, before resolution touches it.
The write probe only covered the cache root, but entries go in a
subdirectory per cache — a writable root holding a subdirectory owned by
someone else still degraded to a silent permanent cache miss, which is
the failure the probe exists to catch. Probing every subdirectory needs
their names in one place, so they move from string literals at the two
injection sites into a DiskCache enum.
Also: bound maxSizeMb, since a size given in bytes by mistake overflowed
the conversion into a negative cap that escaped the positive-value check;
collapse the 200 MB default to one definition; and read the e2e cache
helper's location from the config the server under test actually runs on.
Every hit on /a/{penName}/{projectName} re-rendered the story: a decrypt
and deserialize per scene, word counts for pagination, then markdown
parsing and sanitizing. That ran identically for every visitor of a story
that hadn't changed.
Renders now go through the LruDiskCache added for OG cards, keyed on the
project name plus every scene's stored content hash. A synced edit,
rename, reorder, or deletion lands on a different key and renders fresh,
so there are no eviction hooks in the write path and no upload, delete, or
end_sync path left to forget. Reading those hashes is one indexed query
with no decryption.
The same fingerprint doubles as an HTTP validator. The page shell is built
before the render so it can be hashed into a weak ETag, letting a
revalidating reader be answered 304 without rendering at all. pageETag
hashes the Mustache model itself rather than an enumerated list of inputs,
so a field added to a page joins its validator instead of quietly serving
a stale copy to everyone holding the old one.
Only stories reachable without a password are written to disk — scene
content is encrypted at rest, and a private share's decrypted prose has no
business sitting in a plaintext cache. PublicProjectResult.Success now
carries isPublic, set by the access layer from the branch it actually
took, so callers read it instead of re-deriving it from the request; a
public story fetched with a stray ?p= is no longer treated as private.
Supporting changes:
- LruDiskCache takes the injected okio FileSystem like everything else,
through a TouchableFileSystem that adds the one operation okio omits:
setting a modification time, which the LRU ordering depends on.
- A cache write failure degrades to an uncached render rather than
failing the request, and a render whose scenes didn't all load is
served but never stored.
- One DiskCachePruneJob covers every disk cache instead of a job per
cache, and pruning is amortized over bytes written rather than run on
every put.
* Cache static assets with Cache-Control headers
Add CachingHeaders so static assets get a public max-age — CSS/JS for a day,
images/fonts for a week — letting browsers skip revalidating them on every
navigation (ETags via ConditionalHeaders still catch changes once max-age
lapses). HTML and XML responses get no caching header. Also gate gzip on
minimumSize like deflate.
* Add canonical URLs, per-page titles, and author meta description
- Shared <head> emits <link rel="canonical"> and an optional <meta name="description">.
- Every page gets a self-referential canonical from its request path (query stripped)
via withDefaults; the base URL prefers publicUrl and falls back to the request host.
- Story pages override the canonical to include ?page so each page is independently
indexable (self-canonical), but never the ?p password param.
- Author and story pages get unique <title>s; author pages get a bio-derived description.
* Add OpenGraph and Twitter Card meta tags
Shared <head> emits og:site_name/type/title/description/url/image and the
twitter:card equivalents, reusing the title/description/canonical fields already
set. Defaults (type=website, image=site icon) come from withDefaults; author
pages set og:type=profile and story pages og:type=article.
* Add a branded 1200x630 default OpenGraph image
Ship a wide default share image (Hammer icon + wordmark) so social previews
render as summary_large_image cards instead of the small square icon. og:image
/twitter:image now point at it; pages can still override og:image later.
* Use branded per-type default OG images for authors and stories
Author and story pages now use type-specific static share cards (og-author /
og-story) instead of the generic default, so shared links read as an author
profile or a story at a glance. Zero setup — these ship as static assets.
* Add a generic size-bounded LRU disk cache
Stores arbitrary byte blobs keyed by string (SHA-256-hashed to a filename), with
atomic writes and approximate-LRU eviction by last-access time. put() self-bounds
to maxBytes; prune() and prune(maxAge) expose size/age maintenance for a scheduled
job. Reusable beyond the upcoming OG-image cache.
* Add OpenGraph image renderer and richLinkPreviews flag
Headless-AWT renderer for 1200x630 share cards (icon + wordmark + wrapped title
+ subtitle), loading the Kingthings TTF once. Adds the richLinkPreviews config
flag (default false; needs native font libs). Wiring to routes comes next.
* Wire dynamic OpenGraph images behind richLinkPreviews
With the flag on, author/story pages point og:image at per-entity endpoints
(/a/{pen}/og.png, /a/{pen}/{project}/og.png) that render personalized cards via a
disk cache (OgImageService over LruDiskCache) and 404 non-public entities. A
recurring job prunes the cache by age. With the flag off (default), pages fall
back to the branded static cards, so no font libraries are needed. Documents the
fontconfig/libfreetype6 requirement.
* Fix OG card layout so the subtitle never collides with the accent bar
Use fixed title/subtitle baselines instead of spacing relative to the title
height, so a 3-line (wrapped/truncated) title no longer pushes the subtitle off
the bottom. Slightly smaller title font to fit three lines cleanly.
* Redesign OG story card and localize share-card labels
Story card now leads with the title as the hero (inline open-book mark,
full-width wrap below), the author beneath it, and a footer attribution
pinned to the bottom. Secondary text is larger and darker for legibility
when the card is scaled down to a chat unfurl.
All card labels are localized: the renderer takes them as parameters and
the route resolves them via the existing ResourceBundle i18n. Reuses
public_story_by; adds og_attribution and og_author_subtitle (the latter a
{0} template filled with the server's own host). Cache keys are built from
the exact render inputs, so language and host variations regenerate.
* Add JSON-LD structured data to author, story, and home pages
A typed schema.org builder (kotlinx-serialization) emits ProfilePage/Person
for community author pages, Article for publicly-published stories, and
WebSite for the home page. The header template renders it into a
<script type="application/ld+json"> block when the model carries jsonLd.
Author-supplied text is escaped (< -> <) so it can't break out of the
script block. Structured data is emitted only for indexable pages.
* Add per-page titles and meta descriptions to public pages
Home, About, Community Authors, and Story Feed pages now set descriptive
<title>s and meta descriptions (reusing existing localized subtitles).
Public story pages get a meta description built from the title and author.
These feed the description/og/twitter tags the header already renders.
* Add Subresource Integrity to the htmx and Font Awesome CDN links
Pin the exact bytes of the two third-party assets so a compromised CDN
can't inject altered code: the browser blocks either file if its hash
doesn't match. Hashes verified against the served bytes (htmx cross-checked
across unpkg + jsdelivr, Font Awesome against cdnjs's published SRI);
crossorigin=anonymous added since both CDNs send Access-Control-Allow-Origin.
* Resolve dynamic OG images by stable id instead of caller-supplied strings
The share-card routes now take an account id / project UUID and render only
fields read back from the database — the pen name, story title, and the
subtitle host (from publicUrl config, never the request Host). Nothing the
caller supplies is drawn into the image or mixed into the cache key.
The author/story pages emit the dynamic og:image URL only when the subject
actually qualifies (community author; public, password-free story), matching
what the route will serve — so a share preview is never a broken 404 that
should have fallen back to the static card. A malformed project UUID is
rejected before the query so it can't raise a Postgres cast error. Rendering
moves off the event loop.
Adds findPublicProjectByUuid and route-level gating tests.
* Harden OG image caching, render concurrency, and cache stampede
- Scope Cache-Control by path: dynamic OG cards under /og cache for 30 days
(the disk-prune window), /assets keep their type-based rules, everything
else stays uncached. Previously any image response — including the dynamic
OG PNGs — was matched by content type and cached for a week.
- Make LruDiskCache.getOrPut single-flight per key (striped locks) so a
scraper burst on a viral link renders the card once, not once per request.
- Collapse the duplicated wrapText into a delegation to wrapTextIndented.
Scans compiled route handlers for the Unit coercion the compiler emits
when a respond is the value of an exhaustive when, which is the shape
that broke the bio endpoint and is invisible in source review.
Includes a positive control: a fixture written in the unsafe shape that
the detector must keep flagging, so a codegen change fails the test
instead of silently disabling it. Also asserts a floor on the number of
handlers scanned so a broken filter can't pass vacuously.
* Fix ClassCastException when responding from a when branch
Ktor's respond* functions are tail-call suspend functions that return the
send pipeline's subject instead of Unit. When such a call is the last
expression of a route handler's when/if, the compiler casts that value to
Unit and the handler fails once Compression has rewritten the response.
Route the toast helpers through a respondHtml that genuinely returns Unit,
and keep end_sync's branches out of value position.
* Fix the bio ClassCastException by restructuring, not a no-op call
Drop the forceUnitReturn() barrier in ToastUtils and the return@post in
end_sync. The when in the bio handler now yields a BioOutcome and the
handler responds once at the end, so no respond sits in value position
and nothing relies on a compiler workaround.
end_sync was never affected: its lone checkcast is a spilled-local
restore, present before the change too. Only an exhaustive enum when
materializes its branch values.
Applies the Koin compiler plugin with compileSafety to :server and converts
mainModule from the constructor DSL (singleOf/factoryOf), which the plugin's
indexer does not recognize, to the plugin DSL (single<T>()/factory<T>()).
- /sitemap-content.xml: a urlset of the server's public pages — the home
page, the enabled static pages (/about, /terms, /privacy), and, when
community is enabled, community author pages and published stories
enumerated from the database at request time.
- /sitemap.xml: a sitemap index over the content sitemap.
- robots.txt advertises the sitemap when publicUrl is configured.
Both routes require publicUrl to emit absolute locs (404 otherwise) and never
derive the host from the request, matching publicBaseUrl.
* Bind e2e test server to an ephemeral port to fix flakiness
EndToEndTest bound every test's server to a fixed port (54321). Because
the whole suite shares one JVM and boots/tears down a server per test, a
stopped server's socket lingering in shutdown could collide with the next
test's bind, intermittently failing an unrelated test (e.g. ReviewPageTest's
locale-fallback case) with a non-200.
Bind port 0 so the OS assigns a free port, then read it back via
resolvedConnectors() so the client targets the right port.
* Expose resolved port to integrationTests after ephemeral-port switch
RoundTripTestBase (and its doc/comment neighbors) referenced the removed
TEST_PORT constant to build the client's server URL. Expose the OS-assigned
port as a protected read-only serverPort on EndToEndTest and point the client
settings at it. Refreshed stale 54321 comments; integrationTests keeps
maxParallelForks=1 for shared JVM-global state, not the port.
The /privacy page runs its configured source through MarkdownService and renders the sanitized HTML in the shared .about-content style, matching the About page, instead of showing escaped plaintext.
Adds a static-analysis job to Build CI running two self-contained scanners
as a gate, replacing reliance on out-of-band Codacy:
- Semgrep against hand-written rules in .semgrep/ (raw SQL to
prepareStatement), with --error; honors // nosemgrep.
- gitleaks for committed secrets, pinned binary; fixtures allowlisted in
.gitleaks.toml.
Both tool versions are pinned for reproducible gating. Also fixes and
extends the migrator's SQL suppression (correct placement, both rule
ids), and suppresses the parity-check COUNT false positive.
Supersedes #746.
Whitelist entries gain an optional expiry (null = never, so existing rows and
callers are unaffected). Enforcement lives in the isWhiteListed query so an entry
stops authorizing the instant it lapses; WhitelistExpiryJob then reaps expired
rows hourly and revokes those accounts' sessions.
The admin web UI offers Never / 7 / 30 / 90-day presets plus a custom date on
manually-added entries (Patreon-synced entries are excluded). Existing entries can
be edited: day presets extend the current expiry additively, and it can be set back
to never. forceLogout moved from PatreonSyncService to AccountsRepository so the
expiry job and Patreon sync share one implementation. Optional expires param added
to the REST PUT /api/admin/{userId}/whitelist endpoint (backward compatible).
Rework the overflowing web footer into a content/nav row (About/Terms/Privacy, shown only when configured) above an icon-only social row with accessible tooltips; add Mastodon and Bluesky. Add a public /terms page backed by the same repository as the account-creation gate, and an optional per-instance /privacy page sourced from a startup-validated config-file path. Terms/privacy repos share a CachedTextFile helper and are injected lazily so page renders and route registration stay off the filesystem.
Remove the HTTP/HTTPS protocol picker and all ssl plumbing from the
client. Persisted server settings now always resolve to HTTPS, so a
legacy ssl=false server.json is upgraded on load. ServerSettings.ssl is
kept only as an internal seam for the plain-HTTP integration-test server.
Android: drop the permissive network_security_config so cleartext
traffic uses the secure platform default (blocked).
Server keeps its plain HTTP connector for reverse-proxy deployments. In
--dev with no sslCert configured, it now generates and persists a
self-signed keystore (hammer_data/dev-selfsigned.jks) and serves TLS on
a non-privileged port (8443 by default). The desktop --dev client trusts
that cert for loopback hosts only; remote hosts still get full cert and
hostname validation.
* Add optional Terms of Service gate for account creation
Self-hosters can set an undocumented `termsOfService` path in ServerConfig
pointing at a plaintext file. When set, account creation is gated: the server
answers POST /api/account/create with 451 + the TOS text and a content-hash
version. The client shows a scrollable dialog; accepting resubmits with the
accepted version, declining discards the provisional server settings.
Disabled by default (null path); existing servers are unaffected.
* Cover TOS repository and 451 client handling; harden error-body parsing
Adds direct tests for TermsOfServiceRepository (FakeFileSystem: absent/missing/
blank/populated file, stable and content-derived version) and ServerAccountApi
(201 success, 451 -> TermsOfServiceRequiredException, malformed 451 -> default
failure).
Also broadens error-body parse handling: ktor raises ContentConvertException
(not kotlinx SerializationException) on malformed JSON, so both the create-account
451 path and the shared defaultFailureHandler now catch it and fall back to a
graceful failure instead of letting it escape as an unhandled coroutine exception.
* Fail fast when termsOfService points at a missing or blank file
A configured but unreadable/empty TOS path previously made challenge() return
null, silently disabling the terms gate and letting accounts be created with no
terms at all. resolveServerConfig now validates the path at startup and aborts
(as it already does for unparseable config), so a misconfiguration can't quietly
drop the legal gate.
* Resolve a relative termsOfService path against the config file's directory
A bare `termsOfService = "tos.txt"` previously resolved against the server's
working directory, so a terms file sitting next to config.toml wasn't found.
Relative paths now resolve against the config file's own directory; absolute
paths are unchanged.
* Filter bounces from story reader stats with a dwell-gated beacon
Reader counts for published/shared stories were recorded on the initial
page GET, so every drive-by click counted as a reader — including people
who opened a story and closed it a second later (and non-JS bots).
Move recording off page load onto a best-effort dwell filter: the public
story page now loads a small script (story-reader.js) that fires a beacon
to POST /a/{penName}/{projectName}/read only once the visitor has actually
spent ~10 seconds on the page. The dwell timer only accrues while the tab
is visible, so a story opened in a background tab and never looked at
doesn't count. The beacon endpoint re-runs the exact same pen-name/project
resolution, access, and author-skip checks the GET did, so it can only
record a read the visitor could actually load.
This is a heuristic, not a guarantee — it just drops the obvious bounces.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GApiphvSAKeYLeejenqjTK
* Harden reader beacon: carry dwell across pages, cap collector memory
Two follow-ups from red-teaming the dwell-gated reader beacon:
Multi-page stories were undercounted. Pagination is a full-page
navigation, so every page turn reloaded story-reader.js and reset the
10-second dwell timer — a reader spending a few seconds on each of
several pages never crossed the threshold on any single page and so was
never counted, penalizing exactly the engaged serialized-fiction readers
the metric wants. Accumulate dwell across page loads in sessionStorage
(per-tab, never sent to the server), keyed by the story path, and fire
the beacon once the cumulative total crosses the threshold. Backend dedup
already prevents any double count across pages.
Bound the collector's in-memory key set. The set only drains once a
minute, so a flood of reads with varied user-agents (each a distinct
visitor hash) could grow it without bound between drains. Cap it well
above any legitimate per-minute unique-reader volume and shed excess
reads past the cap: a best-effort metric may undercount under abuse, but
must not exhaust memory. The cap is injectable so a test can exercise the
shedding with a small value.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GApiphvSAKeYLeejenqjTK
* Extract dwell logic to a tested module; reuse project resolution
Split the pure dwell-tracking math out of story-reader.js into a DOM-free
story-reader-logic.js (createDwellTracker), loaded as page_pre_script and
covered by story-reader-logic.test.js under the existing Node jsTest gate.
Also guards against a tab refocus re-firing the beacon after it has been sent.
Resolve the project via the existing findProjectByUrlSegment extension in
both the GET and the /read beacon instead of duplicating the
getProjectsWithSyncDate(...).find { shortId } lookup inline.
* Upload server test report artifact on CI failure
* Fix Koin boot: StoryReaderCollector's Int param broke constructor injection
singleOf(::StoryReaderCollector) makes Koin resolve every constructor
parameter by type, including the maxPendingKeys: Int default, so the graph
failed to boot with "No definition found for type java.lang.Integer".
Register it with an explicit constructor call that honors the default.
* Add e2e coverage for the reader beacon endpoint
Drives POST /a/{penName}/{projectName}/read against the booted server and
asserts what the collector actually records: a published story records one
reader, a story with no public access records nothing, a private share only
records with the correct password, and an unknown pen name records nothing.
---------
Co-authored-by: Claude <noreply@anthropic.com>
Repo-wide audit of the common desktopTest and server test suites:
- Replace vacuous assertions and any()-stub echoes with argument-exact
stubs, captured-argument assertions, and persistence round-trips
- Fix genuine test bugs: cross-test fixture leak, stubs that never
matched, tests exercising the wrong branch or fixture
- Drop brittle pins: private-field reflection, incidental call counts,
locale-dependent formatting
- Delete zero-assertion and duplicate tests superseded by stronger ones
- Extract SYNC_DATE_PATTERN constant shared by prod and test
- Document testing philosophy in DEVELOPMENT.md
The repository moved from github.com/Wavesonics/hammer-editor to
github.com/Darkrock-Studios/hammer-editor. Update all URLs across
source, build scripts, web templates, docs, store metadata, and
test fixtures.
The test relied on review keys missing from the French bundle; Crowdin
has since translated them. Assert against a deliberately incomplete
test-resource bundle (locale xx) so the premise can't drift.
A browser cancelling an in-flight response (HTTP/2 stream reset, tab
close) surfaces as ClosedByteChannelException and was logged and
recorded in error monitoring as a 500. Detect the abort in the
StatusPages handler and skip logging, recording, and the doomed
response. Extract configureStatusPages so the handler is testable.