Skip to content

Accounts: sign-up, sign-in and recovery rebuilt - #34

Closed
codeGlaze wants to merge 746 commits into
developfrom
f1852203-accounts
Closed

codeGlaze wants to merge 746 commits into
developfrom
f1852203-accounts

Conversation

@codeGlaze

Copy link
Copy Markdown
Owner

Accounts: signing up, signing in, recovering — rebuilt, plus the hotfix batch that preceded it.

Base is develop, which is the only base branch on this remote — the integration branch was deleted upstream. That makes the diff wide: it carries the earlier share-links/hotfix batch as well as the accounts work. The accounts work is the last 63 commits, from 2c9c553d onward; everything before that has been reviewed already.

What a reviewer should look hardest at

Auth and sessions (src/clj/orcpub/routes.clj, src/clj/orcpub/security.clj)

  • check-auth is no longer purely stateless: tokens carry a :minted stamp and one minted before an account's password moved is refused. The register is in memory (rebuilt from Datomic at boot and hourly) rather than a per-request DB read, because the live peer is Postgres-backed and :orcpub.user/username is neither unique nor indexed.
  • withdraw-sessions — "sign out everywhere". Deliberately does not require the password; reasoning is in the docstring.
  • Login answers identically for a wrong password and an unknown username. Password reset answers 200 for any address, including once throttled. Both are deliberate — an endpoint that varies its answer is a membership test.
  • The per-account guess limit sits on the failure path on purpose: locking the account after five wrong guesses would let anyone lock anyone out.

Password rules (src/cljc/orcpub/registration.cljc)

  • One shared validator, run server-side on both paths that set a password. No composition rules (NIST SP 800-63B-4). Breach corpus refuses only at 1000+ appearances — the corpus measures commonness, not danger to this person.
  • Regexes use explicit ASCII ranges, not \p{L}/\p{N}, which are Java-only and silently fail in ClojureScript.

Email (src/clj/orcpub/email.clj)

  • New notices on password change and on an email-change request (to the address losing the account). Both run off-thread and swallow their own failures, because each fires after the thing it reports has already been committed.

Known and deliberate

  • The reset token is a full session token for an hour. Pre-existing; scoping it is an open decision, not an oversight.
  • The reset page judges a password against the username but not the email local part — matching the server would mean putting the address in a readable cookie.

Deploy notes

  • Reset links already in inboxes stop working: the window went 24h → 2h and keys are now stored as SHA-256 digests. Fails gracefully onto the expired page, which offers a new one.
  • Anyone who reset in the 24h before deploy is signed out once when the register loads.
  • Signup and reset call api.pwnedpasswords.com. On by default; ORCPUB_PWNED_CHECK=off is the kill switch.

Verification

590 JVM tests / 4265 assertions, 434 ClojureScript / 1956, clj-kondo 0/0.

scripts/e2e/auth-flows.js — 45 browser checks over register → verify → login → reset → sign out. These flows had no coverage and no route to any: the verification and reset keys exist only inside an email, and registration fails outright when none can be sent. scripts/e2e/lib/mail-sink.js is a dependency-free SMTP server that makes them drivable.

Background, with the measurements and the rejected alternatives: docs/kb/account-flows.md on agents/develop.

Banners render markup again, every export runs one gate, homebrew can be
rescued from a broken app, and the footer's diagnostic tools are named
behind a switch.

Consolidates six branches cut over one day. The hiccup-rendering fix
existed only on a branch that never returned to integration, so the break
it fixed stayed live for nine hours; this is the single place that work
lives now.

lein test 492/0. CLJS 332/0. lint 0 errors. changelog lint OK.
Boot rescue 16/16 against three real breakages; builder-card export 11/11.
Found by screenshotting it, which had not been done -- the effect was written
and shipped without anyone looking at it.

Two faults, both from hanging the sparkle off the corner at top:-4px right:-3px.
It landed in the gap between Save and the next header button, where it read as
belonging to neither; and since the buttons are siblings in a row, the next one
along painted over it, so most of it was simply not visible.

Inset into the button's own top-right corner now, and the button raised a layer
so its glow and sparkle draw above the sibling rather than under it.
clj-kondo flagged it. The value was always the literal, so the compiled CSS
does not change.
Watched in a real browser for the first time, the heal worked when a character
was opened and failed twice in ways the unit tests could not see.

A reload never healed. :initialize-db restores the builder's in-progress
character with a bare assoc straight into app-db, which never reaches
:set-character, so the reconcilers did not run: a character that healed when
opened from the list stayed broken after a refresh. Boot now goes through the
same set-character, so both paths are one code path.

The toast never painted. Every way a saved character reaches the builder
dispatches [:route ...] beside :set-character, and :route queues a
[:hide-message] that runs after anything :set-character queued, so the message
was set and cleared in the same frame. :set-character now only records the
heal, and :route announces it after its own clear. dispatch-n is FIFO, which
makes that the one place navigation cannot erase the toast it delivered.
Builder only, and once per heal.

test/browser/character_heal_e2e.js drives both paths against lein e2e-server
and asserts the stored key is rewritten, the option renders bound by name, the
toast is on screen and the save button carries its cue. It found both bugs,
passes 13/13 with the fix, and is registered with the probe runner so a later
change cannot quietly undo either. Saving needs a verified account the
in-memory server does not seed, so the save-clears-the-cue half stays in
events_test.
Restoring the builder's draft on startup repaired it in memory and stopped
there. The copy in browser storage stayed broken, so every refresh repaired the
same keys again and announced it again: three refreshes, three repairs, three
toasts, found by watching it in a browser.

The repaired draft is now written back to browser storage when startup repaired
something, and only then. That is the builder's own working copy, which it
already rewrites on every edit. The character saved on the server is not
touched; keeping the fix there still takes Save, and the save button still
says so.
Loading sets an invalid entry aside for repair, and it did save that set-aside
copy, but it left the entry in the library copy as well. So every load found
the same entry and set it aside again, until some unrelated homebrew save
happened to rewrite the library. Watched in a browser: set aside on load 1, set
aside again on load 2, gone only after an ordinary save.

The library copy is now rewritten on the load that sets the entry aside. The
order is the safety: it is rewritten only after the set-aside copy is written.
If storage is full and that write fails, the entry stays exactly where it was,
which costs a repeat on the next load instead of the entry.

The ordering lives in persist-set-aside!, which takes the storage writer as an
argument, so the test drives a refused write directly. Faking a full
localStorage by patching Storage.prototype did not take effect in the CLJS
test page, although the same patch worked from a plain page, which is how the
real behaviour was confirmed before this was written.
The save banner's "Export this source" link carried a copy of the source from
the moment of saving and handed it to the export check. Since export began
saving its cleanup back to the library, clicking that link after any later
change wrote the old copy over the library: a source turned off came back on,
an entry added since was gone, and the file lacked it too. This is the third
time this link has broken, each time by trusting what it was handed.

The export check now works from what the library holds whenever it holds the
source (export-source), and falls back to a copy handed in only when it does
not. The banners pass just the source name. The save always lands before the
banner can be clicked, so the older bug where the file came out missing the
item just saved cannot return by this route.

builder_card_export_e2e.js now changes the source after saving and clicks the
link again. The stored spell carries curly quotes so cleanup has something to
rewrite, which is the only condition under which the old link wrote back.
Checked both ways on lein e2e-server: 13/13 with this change; without it both
new checks fail, the source back on and the added spell missing from the
library and the file.
A content section stored as text or as a list still holds everything that was
in it, and nothing recovered it. Salvage kept any section that was not a map
exactly as it was, so it loaded silently, its source looked fine, and export
later refused the source with no fix in sight.

mend-section puts those back in shape: text read back, a list keyed by each
entry's own key or the key its name derives, an empty section dropped. It
refuses to guess -- two entries claiming one key, or an element that is not an
entry, leaves the section for a person. mend-plugin, mend-library and
mend-import-data run it over a source, a library, and freshly read import data,
including a whole file stored as text.

What nothing can read is now set aside instead of kept: a damaged section, and
a whole source that is not a map. Salvage used to drop the latter from both
sides, which a write-back of the library would have turned into an erasure.

merge-plugins lets the newer value win wherever either side is not a map.
Merging set-aside data called merge on text, which throws, and it runs while
the app starts. For sections that are maps the result is unchanged.
Import now mends right after reading the file, before any other step walks the
data. A section stored as text or as a list imports with everything in it and
exports. A file whose whole content was stored as text used to crash inside the
cleanup steps and import nothing, with no message. The notice adds "Repaired N
damaged sections", the import log lists each repair, and a section nothing could
read no longer counts as an imported item -- it said "Imported 1 items" for one.

Loading mends before salvage and writes the repaired library back through the
same ordering as the set-aside write-back, so each repair happens once.

A section nothing could read is set aside, and the import notice and the
needs-attention panel both count it. The panel names the damaged section and
offers Export raw and Discard, since there is no name to type for it. Both used
to count only entries, which would have read "0 entries couldn't load".

Checked on lein e2e-server with every shape imported through the real import
event: 28 of 28, including the startup repair written back on the first load
and nothing left to repair on the second.
An import that skipped items arrived in the green success card with a check
mark, so the one thing worth noticing looked like good news. It said
"Skipped 1 invalid items" and sent people to the browser console.

A partial import, or one with key clashes, now uses the warning card. Its lines
carry a green check for what came in, an orange triangle for what was skipped
and a wrench for what was repaired. The colour sits on the icon, not the text,
so the text stays readable on the tinted box. The skipped line points at the
import log, which lists each skipped item and why. Counts read "1 item" and
"2 items".

A clean import keeps the success card unchanged.
An item with no name showed as its bare key, ":no-name", which reads as code.
It now shows as the placeholder Export & Auto-Fix will give it -- "Unnamed
Spell", "Unnamed Monster" -- with the key beneath it in small type when there is
one. The header also said "1 item need attention"; it reads "needs" now.
…er open menus

The round import-log button sat in the bottom-right corner of every page, even
with nothing in the log. On a laptop-height window the My Content menu reaches
that corner, and the button, drawn at z-index 900, covered the menu's last item:
clicking Selection Builder opened the empty log instead.

The button now appears only when the log has something in it, or while its panel
is open so the panel can be closed from it. Its z-index is 150: under an open
header tab (200), still above the sticky button row (100). The panel it opens
stays at 950. Its styles move from inline to garden.

header_menus_e2e.js runs the shortest window a second time after a real partial
import puts the button on the page. With the old z-index that pass fails on
Selection Builder; with this change all 30 checks pass.
… is none

Twenty-six probes carried their own copy of findChrome, a dozen variants between
them. All of them looked only for chrome-linux/chrome, which Playwright 1.57 names
chrome-linux64, so none found an installed browser by path. Five of the copies
threw when /opt/pw-browsers did not exist. Eight more probes skipped the finder and
hardcoded /opt/pw-browsers/chromium or read one env var each: CHROME, CHROME_PATH
or PLAYWRIGHT_CHROMIUM. Outside the web sandbox those failed at launch unless all
four variables were set by hand.

test/browser/lib/find-chrome.js is now the one copy, used by every probe and by
the CLJS test runner. It takes a path from any of those env vars (or E2E_CHROMIUM,
which scripts/e2e/run.js reads), a binary or an install folder. Then it tries the
newest chromium-<n> under PLAYWRIGHT_BROWSERS_PATH or /opt/pw-browsers, in either
folder layout, and otherwise leaves it to Playwright's own browser. A variable
that points at nothing is named on stderr rather than silently used.

run-browser-probes.js resolves the browser once. With none, each probe is a SKIP
naming the fix, not thirty identical launch failures; STRICT still fails the run.

Checked with no browser variables set: 14 of 14 probes pass, and header_menus
runs 30 of 30.
… just once

A library that reads back as text of itself loaded nothing: the loader saw a
string, copied it to plugins:corrupt and moved on. mend-library now reads it back
when the text is a map keyed by source names, and the load writes the library
back, so the repair happens once.

A stored value that is still not a library was copied to plugins:corrupt on every
load, because the active slot was never cleared; the unreadable-text case beside
it always cleared its slot. It is now cleared as well, but only once the copy is
saved, so a full storage cannot turn the copy into a loss.
Save anyway with placeholders in the selection builder derived the key straight
from the name. A name like "9 Lives" gave :9-lives, which the save accepted and
the next load set aside, so the placeholder save undid itself on refresh. The
other builders run sanitize-item-names first; the selection builder now does too.
… kept

Resolving an import conflict can rename or disable the copy already in the
library. Those changes sit in the base library handed to the import store, and the
store only wrote the library when at least one incoming entry was kept. When every
incoming entry was set aside, the renames and disables went with it, in storage
and on screen.
… export

fixes_persist_e2e.js applies each repair through the real app, then reads
localStorage, reloads, confirms the reload has nothing left to repair, and exports
where there is a file. Paths: import cleanups, the loader's repairs and set-asides,
a library stored as text, a stored value that is not a library, conflict renames,
Auto-name & Restore, Export & Auto-Fix, Save anyway for a spell and a selection,
and a character repaired on refresh.

Against the build before the two load fixes and the selection fix it passes 25 of
33, failing exactly those paths; with them, 33 of 33.
An entry stored as text, or anything else that is not a map, reached the step that
fills missing fields. That step assoc'ed into it and threw "No protocol method
IAssociative.-assoc defined for type string", so the import stopped partway. It
now leaves such an entry alone; validation skips it and the import log lists it,
as with any entry the import cannot use.

The full-import tests used an entry with no source as their broken example, which
the next commit makes importable, so they use an entry that is text instead.
The July rule sends content with no source to Default Option Source, but only an
:option-pack that was present and blank or nil got filled. An entry that left the
field out was skipped on a single-source import, counted as imported and then set
aside on a multi-source one, and set aside when found in stored homebrew.

An entry with no usable :option-pack now takes the name of the source it is filed
under. A single-source file has no source key, so there it takes the one source
the other entries name. Only when there is no name to take does it get Default
Option Source, as before. Import and load share the rule (e5/source-for), and the
loader writes the filled library back with its other repairs, so it happens once.
single-plugin-source now asks e5/declared-source, the same reading of which source
a file's entries name.

The persistence probe checks the import and the load path (35 of 35). The load
test and the header-menu probe used a sourceless entry as their broken example and
now use an entry that is text. The import cleanup table in ORCBREW_FILE_VALIDATION
also said a nameless source became Default Option Source; it becomes Unnamed
Content, as decided in b5ba38d.
A stored race whose :key was text left every page on the loading spinner, with
only the rescue download bar. At startup core calls init-template-cache!, which
builds the character template. The races subscription keeps races in a set sorted
by key, and compare threw comparing :dwarf with "text". That throw happened outside
render, where no error boundary can catch it, and the rest of core never ran, so
the app was never mounted.

Two layers. Each startup step in core runs through boot-step, which logs a failure
and carries on, and the template cache catches its own failure; the save handler
already works without a cache. The sorts that see homebrew keys or names use
common/safe-compare, which orders mixed types by type and then by printed form
instead of throwing: races, spells, class options, subclass names, template
options and the spell list.

boot_rescue_e2e.js stores such a race and checks the app still draws. It failed on
the build before this change and passes on this one. A sweep of wrong types and
shapes through import, reload, the template and export went from 43 crashes to 31:
every key crash is gone, and what remains is traits and options lists, and a
source that is not a map.
…port crashing on them

A sweep of wrong types and shapes through import, reload and export found 31
crashes left, all in three places:

- A :traits list (classes, subclasses, races, subraces, backgrounds) or a
  selection's :options holding something that is not a card. [42] or ["text"]
  made the import throw with nothing saved and no message; a bare 42 imported and
  then made export throw.
- A source that is a number or unreadable text made the import throw.
- A :key that is not a keyword, which used to stop the app starting.

mend-plugin now repairs inside entries as well as sections, so import and load
share it and a load writes a repair back once. A :key that is not a letter-first
keyword takes the key the entry is filed under, which keeps characters that point
at it working. A card list keeps its cards, turns text into a card with that name,
drops anything else, wraps a lone card, and is removed when it is none of these.
:options is only treated as a card list on a selection. A healthy entry is not
touched and reports no repair.

The import steps that walk every source or card now step past ones of the wrong
shape, so a non-map source reaches the store, which already sets it aside. The
import notice counts damaged entries apart from damaged sections, and the import
log names each repair.
… still breaks

Startup built the character builder's whole template before anything was drawn, outside
every error boundary, only so autosave had a cached copy. A throw there stopped the app
from mounting. Startup now reads storage and builds nothing from it:

- The template cache starts on the first character save (ensure-template-cache!), from
  the sheet, list, parties or combat tracker. A save retries while it fills and says so
  if it never does, instead of skipping silently.
- If loading the library itself throws, core copies it to plugins:corrupt (clearing the
  slot only once the copy is saved), starts without it and shows a notice with a
  download. If startup fails without homebrew too, the library is put back.

For an entry no repair can mend, each conversion into a character option is guarded
(homebrew-guard): races, subraces, backgrounds, feats, classes, subclasses and spell
lists. A throw skips that entry and reports it; the report sets it aside in
plugins:rejected, which is saved, and a notice names it with a link to My Content.

Option builders return lazy sequences, so some damage only throws when a page draws the
option, outside the guard. Fully realizing every entry to catch that costs about 2 s
with a 12-source library against about 40 ms unrealized, so the everyday path does not.
A deep check (homebrew-check) realizes entry by entry, only: after an import, for the
sources it changed; after a Restore, for that source; and when a page has already
failed, over everything, after which the page tries again. The conversions it runs are
registered by the subscriptions, so the events namespace still does not load them.

The startup notice's download goes through save-orcbrew-blob! (new :raw? option) and is
declared as a hatch in export_paths_test, like the other unvalidated downloads.

The error boundary's retry never redrew the page: Reagent does not re-render on React
state alone, so it now forces the update. A spell whose :spell-lists is not a map is
repaired on import and load like the other entry damage.

homebrew_safety_net_e2e.js makes one race throw on conversion and another throw only
while drawn. With this change all 16 checks pass; on the build before, 1 of the first 13.
Three defects, one cause: the save derived the key from the name every time.

save-collision tells an edit returning to its own slot from a name landing on
somebody else's by comparing :key on the item in the builder, and the save never
stamped that key back onto it. So author an item, save, fix a typo, save again
-> "already uses the name ... Saving would replace it", with no way forward but
renaming.

Renaming was worse. save-into-plugins knows a rename is a MOVE, but it is told
what to remove by (and (:key item) (not= (:key item) key)) -- and a newly
authored item never had :key. So the move degraded to a copy: the old entry
stayed in the library, in exports and in the source list, and with no :former-key
a character that had picked it had nothing to rebind to.

The key is now minted once -- (or (:key item) (name-to-kw name)) -- so editing
the Name field edits the name. Key and name may diverge; the key is an address,
the name is display text. The item is stamped back into the builder (and its
localStorage draft, so a refresh keeps it), which is what makes a second save
land in its own slot.

Changing a key stays possible where it is deliberate: import conflict resolution
and the manual relink, through rename-key-in-plugin.

:former-key was one slot, so A->B->C kept only the last hop and stranded anyone
still on A. It is now :former-keys, oldest first, capped at 4, and the cap drops
from the MIDDLE -- entry 0 is the key the item was minted under, which is what a
character nobody has opened since then still points at. The singular is still
read and folded in at the next key change, so existing libraries keep healing.
former-key-index needed one line: it already maps {former -> current} in one hop,
so a chain resolves directly.

Also corrects save-collision's docstring, which claimed the cross-source case
"informs rather than blocks". It blocks, and should: a key is a global address,
the combines that dedupe pick their winner by the hash order of source names,
and the ones that do not show both copies.
Fourteen tests over the scenarios that produced the three defects in the
previous commit and had no coverage at any layer: save, save again, rename,
come back to a restored draft, edit a saved item, mint a key another item holds
(same source and other source), a chain of key changes, the cap, and an item
still carrying the old singular :former-key.

Layer is re-frame events through dispatch-sync. GOTCHA in there for the next
person: the save returns its work as :dispatch-n and re-frame's dispatch is
async, so a bare dispatch-sync returns before the save has applied. The fixture
captures the queue and drains it.

test/e2e/cljs-harness.js comes with them -- the cljs suite has no runner on this
branch, and a test that cannot be run is not a test. It serves target/test over
a static server and drives it with Playwright; run lein fig:test first.

GOTCHA it was built with: serve the JS as charset=utf-8. A classic script with
no charset is decoded as windows-1252, and orcpub/common.js's accent-range regex
then fails to parse, which leaves orcpub.common undefined and every test that
touches it erroring.
Editing saved homebrew: a second save was refused as a collision with itself, a
rename left the old entry behind as an orphan, and a chain of renames stranded
anyone still on the first key. One cause -- the save re-derived the key from the
name every time -- and the key is now minted once.

Comes with the lifecycle tests for the scenarios that produced them, and the
cljs test harness needed to run them.
Four comments cited the owner's decisions by number only, and one told how an
export click used to close its banner. They now state the behaviour and link the
decision in homebrew-keys-design.md.
comment_discipline_test parses src/ and web/ (comments and docstrings only,
never data strings) and flags history phrasing, docstrings over 6 lines and
comment blocks over 4. FIELD NOTE blocks are exempt from length.

The 336 existing hits are recorded in test/comment-baseline.edn and listed on
every run as the cleanup list. A hit not in the baseline fails; a baseline entry
no longer found fails until it is deleted, so the baseline only shrinks. The
allowlist needs a reason per entry. rewrite-clj is declared in :dev-config at
the version already on the classpath.
…ct text

An entry is now [file signal text hash], the hash of the whole comment block or
docstring, so an approval covers the text that was reviewed: editing it makes
the hit new again and the old approval stale, and both fail. Recorded debt that
is edited has to be fixed or reviewed the same way.

The baseline can no longer be re-recorded wholesale. COMMENT_BASELINE=prune only
removes cleaned entries and stale approvals; seed works only when there is no
baseline. A new hit is approved one entry at a time in :allowed, and a missing or
placeholder reason ("REVIEW: ...", "TODO") fails. A failing run prints each new
hit's entry ready to paste.

State wording ("a previously-corrupted spot") is filtered out of the history
check. The decision logic is a pure function with its own tests. Re-seeded: 335.
reconcile-former-keys takes the map former-key-indexes returns ({:flat :typed})
and refuses one without :typed, so a type-blind heal cannot be written by
accident; the three heal sites and one subscription build it the same way.

pick-homes is the only list of pick types: picked-only-at-home is derived from
it, and the missing-content check reads each extracted pick's :path through it
instead of a second content-type map. Tests: a heal without the typed index is
refused; every extracted pick but the subclass has a home; the missing check
runs from a real character.

The upgrade test reads ver.js values by function name, not position.
Each ;; FIELD NOTE (id): is recorded in test/comment-baseline.edn :field-notes as
{[file id] hash}, the hash of the note's text from its marker to a blank comment
line, the next note or a GOTCHA. A note that is new or edited fails until its
hash is recorded by hand, a recorded note that is gone fails, and two notes with
one id in a file fail. The failing run prints each line to paste. Prune leaves
field notes alone. This replaces field_notes_test's hand-kept phrase lists.
- move-homebrew-keeps-dependents.js moves to test/e2e and uses its lib (it
  required a scripts/e2e/lib.js that never existed, so it could not start).
- The upgrade test's footer check fails with a message, instead of throwing,
  when the served bundle has no out/orcpub/ver.js (a production build).
- Restore keeps what the author typed: a restore whose library write fails no
  longer clears the fields. quarantine-restore.js makes the write fail and
  checks the typed name is still there.
- Every on/off switch takes keyboard focus and toggles with Space or Enter,
  through one helper (switch-attrs) that calls the switch's own click handler.
  switches-keyboard.js covers all of them.

Both new checks fail on the bundle from before this change.
It reads the version, date and description from the file the served build keeps
them in: out/orcpub/ver.js behind the dev loader, or the production orcpub.js.
The footer passes when all three appear there, in either mode.
…ecked

- A FIELD NOTE is exempt from the length check for its own lines only; the rest
  of its block is counted, so a long comment beside a note is still flagged.
  Note spans are computed once (note-spans) for both the length check and the
  note recorder.
- Docstrings given as ^{:doc ...} on the name or as an attr-map's :doc are
  checked like positional ones.
- A blank line already ends a comment block (rewrite-clj gives it its own
  :newline node, top level and inside forms); tests now pin that, and that a
  note does not absorb text after a blank line.

The baseline is unchanged: integration has no blocks with notes, and none of its
metadata docstrings trips a rule.
The check found 25 comments and docstrings here that broke the rule. 24 are
trimmed to spec, their history already kept in key-collision-behavior.md and
homebrew-keys-design.md; the library-links `links` field table is approved as
spec with its reason. The four FIELD NOTEs were checked against the code and
recorded, so field_notes_test and its phrase lists go. The 26 recorded entries
this branch cleaned are pruned. Only comments and docstrings changed.
scripts/fold-branch-changelog.sh "Summer Patch": the entries land as
### port/save-gate with its Highlights, and docs/branch-changelog.md is removed so
the changelog guard passes once this lands on integration.
A deliberate delete or a re-import goes ahead and reports the links it left
pointing at nothing, and the first-load fetch writes only into an empty library.
The entry and the Highlights claimed every write was refused.
Homebrew keys: one gate for library writes, links that follow keys, characters that heal. Includes the comment-rule check (from #38).
add-character and remove-character pulled the party from the request's
:db, the snapshot the db-interceptor takes before the handler runs, so
the response showed the party as it was before the change. Pull from the
transaction's :db-after, as update-party-name already reads a fresh db.
The client updates optimistically and ignores the body, so nothing
visible broke; the new party_test fails on the old pull.
Every comment and docstring the check recorded as debt on integration is
trimmed to spec: what the code does, its args and return, and a short
GOTCHA at most. History, rationale and measurements move to the
knowledge base; existing KB pointers stay. Docstrings found stale
against the code while trimming are corrected (CSP dev-mode notes,
spell-packing and PDF return shapes, share-url names, among others).

Only comment and docstring text changes: with comments stripped and
strings masked, every file tokenizes the same as before, and each
changed string is a docstring. COMMENT_BASELINE=prune removed all 309
entries.

.gitignore ignores the linter's cache rather than the whole .clj-kondo
directory, so config.edn stays addable.
Fixes the comment check's 309 recorded hits (baseline now empty) and makes party add/remove answer with the updated party.
Brings save-gate (#37: builder origin, move-or-refuse saves, the shared
library-links model that repoints every link on a key change) and
comment-debt (#39). Where both sides changed one place:

- homebrew save, collision and links: integration's, which supersedes the
  two relocate fixes and the gating revert made here
- accounts, auth and sharing: this branch's (share line, party share
  tokens, image-probe limits, reg-api-sub logging out on 401)
- the library loader keeps this branch's repair step (mend-library,
  persist-set-aside!, fail-soft startup), which integration never had;
  integration's additions beside it (relinks, revision, cross-tab watch,
  pre-fix copy, builder origin) are all kept
- combined: party add/remove answer with the party after the write;
  imports and restores still run the homebrew build check through
  commit-library; the selection save-anyway sanitizes names under the
  save gate; export reads the live library and no longer writes back;
  an import that keeps nothing still stores renames of existing items
- the heal toast stays in :route, where a route change cannot clear it
- dropped a doubled set-aside notice the auto-merge produced
Integration's comment check (#37) fails docstrings over 6 lines, comment
blocks over 4 and history phrasing. 77 hits here, all from code written
before the rule. Docstrings are spec now, with a GOTCHA where a reader
would otherwise write a bug; the rationale moved out, and each trimmed
comment names the KB page that holds it (account-flows.md,
homebrew-safety-net.md, share-links.md, homebrew-keys-design.md). Code
is unchanged, checked token by token against the merge commit.

Also marks the startup write that restores a set-aside library as part
of the library load, the one write outside the gate the gate test allows.
set-aside-broken-homebrew wrote the library directly, bypassing the
gate's revision count and cross-tab merge, which library_gate_test now
refuses. It writes the set-aside copy first as before, then commits the
smaller library through commit-library with :deleting?, since removing
the entry is deliberate. If storage refuses the set-aside copy, the
entry now stays in the library and the author is told; before, it was
removed for that session only. Also trims this file's comments to the
comment rule.
Integration's heal of a renamed pick waits for the template's offered
keys, and integration built the template at startup. This branch builds
nothing at startup, and only a save asked for the template, so merged, a
character with a renamed pick stayed unhealed until it was saved.

:set-character and the routes to the builder and to a character's page
now ask for the template; other pages still build nothing. A heal that
lands when the template arrives comes after the route change, so it
announces itself through ::e5/announce-heal, which shares one rule with
the :route announcement (heal-announcement).

Measured with scripts/e2e/template-on-open.js, three runs each: opening
a character heals in 3 ms (before: not within 15 s); login and the
character list build nothing; the builder's first load goes from about
1.05 s to 1.22 s, the template's cost moving from the first save to the
first builder visit. test/browser/character_heal_e2e.js passes 13/13.

The heal tests now follow integration's model, and the test runner
registers the template request as a no-op so the watcher it starts
cannot dispatch into later tests.
The template's 230-320 ms build ran just after the builder appeared, so
the page was briefly unresponsive right as it showed. On the builder and
character pages it is now built at startup, before the app mounts: the
spinner stays up about a quarter second longer and the page is
responsive when it appears. Measured over three loads: the template
lands before the spinner clears, and what follows is the usual 60-150 ms
of drawing. Other pages still build nothing at startup; the step is
guarded like the lazy build, so a failure leaves the cache empty.
The stale-bundle check kept its own list of source folders, and the list
missed web/cljs, so startup edits were tested against an old bundle with
no warning. It now reads :watch-dirs from the build config of whichever
bundle is on disk, the same list the compiler uses, and stops the run if
it cannot read it rather than skipping the check. Proved by making
web/cljs/orcpub/core.cljs newer than the bundle: the run names it.
The boot spinner was spiral.gif: a 60 KB separate download, stair-stepped
against the gradient. It is now drawn inline by the server, so it shows
with the first bytes of the page, picked at random per load for now so
all three get seen; ?spinner=spiral|d20|spell forces one, and anything
else falls back to random.

The shapes are computed at load (orcpub.loading-spinner): the spiral's
curve, the d20's twenty face placements, and a ring of made-up runes.
Numbers are formatted with Locale/ROOT so a server locale cannot write
100,5 into the SVG. Only transform and opacity animate, each moving piece
its own element, so all three keep moving while the app builds: measured
on the real page with the main thread blocked for two seconds, the spiral
and spell circle stay smooth and the d20 drops to about 10 frames/s.
@codeGlaze

Copy link
Copy Markdown
Owner Author

Superseded by #48, which merges this work into integration (this PR targeted develop because integration had been deleted at the time). #48 also rewrites 37 commits' author email to github@codeglaze.com, so its hashes differ from this branch's. Closing in favour of #48.

@codeGlaze codeGlaze closed this Oct 4, 2026
@codeGlaze
codeGlaze deleted the f1852203-accounts branch October 5, 2026 01:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants