22 commits. The through-line is scope: this release takes things out of ThoughtSync so that capture and recall are what's left, and then makes what's left safe to put on the public net.
The note is simpler
The title field is gone. A note is named by its first line, with no bold on it. A title you can skip is friction that makes every note look half-finished.
A checklist is something a note has, not something a note is.list is no longer its own kind. Any note can carry checkbox items, and body text and items coexist — which also fixes lists never having had a body.
[[wiki-links]], backlinks and the graph are removed — code, callers, UI and settings rows. Organisation was secondary to recall, and this was the bulk of the overbuild.
Capture and recall
URLs unfurl. A note that is nothing but a link becomes the link; a link mentioned inside a note gets a compact slot at the bottom. Up to five per note, fetched in the background.
Search is a facet on the board, not a place you go.
The web board is usable on a phone — safe-area insets, 100dvh, and controls that place themselves differently on touch than on hover.
Ready to host
Registration closes itself once the instance has an owner, and that first account is granted admin.
Proxy headers are trusted by hop count, counting from the right of X-Forwarded-For so a caller cannot forge their own address. Every credential event is logged.
The security tunables live in the Settings UI, not the environment: trusted proxy hops (default 1), sign-in and registration rate limits and windows. No restart to change them.
docker-compose.yml says which of the three deployment shapes you're in — LAN, proxy in Docker, proxy on the host — because the right answer differs and only one of them is the default.
CI
An integration lane, so the Alembic migrations are finally executed by something rather than assumed. Schema is built by alembic upgrade head, data reset by TRUNCATE between tests.
Version is 0.2.0: the desktop version comes from Cargo.toml, not the tag, so releasing at 0.1.0 would have shipped a downgrade below the 0.1.269 dev builds.
CI green on c851b90 (run 4296) — all six jobs, including the first real integration run since the security work landed.
22 commits. The through-line is scope: this release takes things *out* of ThoughtSync so that capture and recall are what's left, and then makes what's left safe to put on the public net.
## The note is simpler
- **The title field is gone.** A note is named by its first line, with no bold on it. A title you can skip is friction that makes every note look half-finished.
- **A checklist is something a note *has*, not something a note *is*.** `list` is no longer its own kind. Any note can carry checkbox items, and body text and items coexist — which also fixes lists never having had a body.
- **`[[wiki-links]]`, backlinks and the graph are removed** — code, callers, UI and settings rows. Organisation was secondary to recall, and this was the bulk of the overbuild.
## Capture and recall
- **URLs unfurl.** A note that is nothing but a link becomes the link; a link mentioned inside a note gets a compact slot at the bottom. Up to five per note, fetched in the background.
- **Search is a facet on the board, not a place you go.**
- **The web board is usable on a phone** — safe-area insets, `100dvh`, and controls that place themselves differently on touch than on hover.
## Ready to host
- **Registration closes itself** once the instance has an owner, and that first account is granted admin.
- **Proxy headers are trusted by hop count**, counting from the right of `X-Forwarded-For` so a caller cannot forge their own address. Every credential event is logged.
- **The security tunables live in the Settings UI**, not the environment: trusted proxy hops (default 1), sign-in and registration rate limits and windows. No restart to change them.
- **`docker-compose.yml` says which of the three deployment shapes you're in** — LAN, proxy in Docker, proxy on the host — because the right answer differs and only one of them is the default.
## CI
- **An integration lane**, so the Alembic migrations are finally executed by something rather than assumed. Schema is built by `alembic upgrade head`, data reset by `TRUNCATE` between tests.
Version is `0.2.0`: the desktop version comes from `Cargo.toml`, not the tag, so releasing at `0.1.0` would have shipped a downgrade below the `0.1.269` dev builds.
CI green on `c851b90` (run 4296) — all six jobs, including the first real integration run since the security work landed.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
The controls a card carries were always-visible overlays on a touch device —
correct as far as it went (task 2697: a finger cannot hover, and the pill is
the only way to pin or archive), but they were still absolutely positioned, so
they sat ON the note's own title. A card reading "thought sync tauri app"
rendered as "ught sync tauri app" with the grip parked over the first three
characters, and the four-icon pill covering the right half of the first line.
Placement is now CSS's decision. One element each, two placements: where a
pointer can hover they lift out of flow into the floating top-corner pills they
have always been; where nothing can hover they stay in flow as a footer row,
which cannot overlap anything by construction. Keyed on hover rather than
width, for the same reason `.hover-reveal` already is — a narrow window on a
laptop still hovers, a wide tablet still doesn't. The colour popover moved
inside the action set so it follows it, and opens into the card from either
end.
The header was sharing one phone-width row between a menu button, the logo, the
lens name, a search field and four icons; everything in it was truncated, the
lens down to "N…" and the search box to an empty pill. It wraps now, so search
takes its own line below sm, and account / settings / sign-out move into the
drawer where there is room to name them rather than guess at a glyph. One input,
moved by CSS — duplicating it would have meant two `searchInput` refs and a `/`
shortcut that focuses the wrong one.
Also closes the other half of task 2706, which was waiting on a device to look
at: `viewport-fit=cover` together with the `env(safe-area-inset-*)` padding
that makes it safe (sides on body, top on the sticky header, bottom on the
board and the drawer), and `100dvh` behind an @supports so the app box follows
the visual viewport when the keyboard opens instead of the layout viewport.
Both halves in one change, as that task insisted.
And the composer no longer tells a phone to "Press Enter".
On a LAN the login form is reachable by people you already trust. Exposed, it is
reachable by everyone, and nothing in front of it was counting.
Three credential routes — /login, /register and /device-login — now throttle.
Every attempt is counted against BOTH the account and the calling address, and
either can refuse it. The account key is the one that matters and the one that
cannot be forged: it stops stuffing against a known email no matter how many
addresses the attempts arrive from. The address key bounds one source spraying
many accounts, and is best-effort by nature — behind a proxy it comes from
X-Forwarded-For, which a caller can set to anything if the app is exposed
directly. That is exactly why it isn't the only key.
The check runs BEFORE the password is verified, which is the other half of what
this protects. bcrypt is deliberately slow; an unauthenticated caller who can
trigger it without limit has a CPU exhaustion primitive as well as a guessing
one. Sliding rather than fixed windows, because a fixed one lets twice the limit
through across a boundary. Bucket count is capped so a rotating forged header
can't turn the limiter into the exhaustion it prevents.
A sign-in against an email with no account now spends a real bcrypt against a
throwaway hash first. Without it "no such account" returned in microseconds
while a wrong password took ~100ms, which is a reliable oracle for which emails
are registered here.
Every response carries a CSP with script-src 'self', object-src 'none' and
frame-ancestors 'none', plus nosniff, a referrer policy and a permissions
policy. The app has no inline and no third-party scripts, so this concedes
nothing; the exceptions are honest — inline STYLE (Vue writes it itself for
v-show and the FLIP), and remote images (a link preview renders the og:image of
an arbitrary host, over either scheme, since a LAN install is served over http).
HSTS only where the request already arrived over TLS, and scoped to the one
host: no includeSubDomains, no preload, neither of which is this app's to
commit.
X-Forwarded-Proto detection moved into one `_is_https()` — the session cookie's
Secure flag and HSTS are the same question, and answering it twice is how the
two drift apart.
docs/public-hosting.md is the rest of it: the four things only the operator can
do (close registration, terminate TLS and forward the scheme, stop publishing
the app port, back up the attachment volume as well as the database), and an
honest list of what the app does NOT have — no email verification, no password
reset, no second factor, no per-user quota, no audit log. Those aren't blockers
for an instance whose accounts are people you know. They're the reason not to
leave signups open to strangers.
The three route tests stamped their pre-loaded hits at t=0..9 through the
injected clock, then called a route that reads `time.monotonic()`. Against a
trailing window those hits are fifteen minutes stale on arrival, so they were
pruned before they could refuse anything, the request carried on to the database
that this suite doesn't have, and the assertion read `500 == 429`.
The window's own tests keep the injected clock — they pass the same one to both
sides, which is what makes them deterministic and instant. Only the tests that
hand off to a route need the real one.
A wiki-link was stored only as normalized TEXT, so a note's NAME was the edge.
Renaming it broke every inbound link — and the fix that shipped for that
(task 1848, option b) was `_rename_inbound_links`: rewrite the `[[Old Name]]`
text inside the body of every note that linked to the renamed one.
That works while an explicit title exists to hold still. It stops being
defensible the moment a note's name is just its first body line, which is where
M13 is going: fixing a typo in your opening sentence would silently edit other
notes' words, with nothing to opt out to. So this lands first, before the title
comes out, and that window never ships.
`note_links` gains `target_id`, bound when the link is written. `target_norm`
stays and is what an UNRESOLVED link carries — linking to a note that doesn't
exist yet is a supported way to create one, so a link has to be able to name a
target that isn't there. Resolution reads the id, falling back to the name only
where nothing was bound, which is what lets a forward link connect the moment
its target appears. `_claim_unresolved_links` then binds it, so the fallback is
a transitional state rather than a permanent one.
`_rename_inbound_links` and `rewrite_link_title` are gone. What replaced them
touches link rows only: a note's text is never modified by something happening
to a different note.
The client can no longer resolve links for itself, and that is the point. It
used to look `[[text]]` up in a client-side name index, which only held together
BECAUSE renaming rewrote the text everywhere. Now the written text can name
something the target is no longer called, and only the server holds the binding
— so each note serializes its resolved links (`norm`, `id`, and the target's
name as it stands NOW). A renamed note reads correctly everywhere it is linked
from, without a single body having been edited. Unresolved links are simply
absent and fall through to the create-on-click affordance that already existed;
so does the offline desktop store, which derives links at query time and has no
binding to send.
The name-fallback join is owner-scoped everywhere it appears. Bound ids were
resolved owner-scoped when written, but matching on display_title alone would
have let two users who each have a note called "Groceries" see the other's id
and name through an unresolved link (rule 47).
The new behaviour is all SQL and this suite runs without a database, so the
dead helpers' tests are removed rather than replaced. This repo has no
integration lane to hold that ground — noted, not papered over.
Operator, 2026-08-22 (note 2897): ThoughtSync is an intermediary surface. You
write here because it's easy — a notebook in your pocket — and later you recall
the thing and go finish it somewhere else. Recall is the product; organization
is secondary. A linking system is organization, and it isn't what this is for.
So: `[[wiki-links]]`, backlinks, the `[[` autocomplete, the note_links table,
`/api/notes/link-search`, `/api/notes/<id>/backlinks`, the whole graph blueprint
and GraphView. Rust core loses `extract_links`, `backlinks`, `link_search` and
`create_titled`; the desktop loses the three Tauri commands that exposed them.
This subsumes 982d24c rather than reverting it. That commit bound links to a
note id so a rename would stop rewriting other notes' bodies — real infra, but
infra for a feature that is now gone, and nothing it added survives. Alembic
0023 stays in the chain anyway: it shipped in an image and may already be
applied, and deleting an applied revision strands a database's version pointer.
0024 drops the table and takes the column with it. The history stays honest
about the fact that it existed for a day.
Two things deliberately kept, because they were serving recall and only
incidentally serving links:
- `/api/notes/titles` and the titles store. The command palette lists them so
you can jump to a note by name. `resolve()` — the name→note lookup that only
linking needed — is gone.
- `display_title`. Every note still has a name for search results and export
filenames. What that name is FOR changed; that it exists did not.
`notes/links.py` is now `notes/tags.py`, holding the #tag→label reconciliation
it always also owned. A file called links.py with no links in it would have been
exactly the drift this removal is meant to end.
Also swept out on the way: `_escape_like`, whose only caller was link-search,
and the `graph` icon. Nothing lost that a person typed — note_links was always
derived, and the `[[text]]` is still sitting in every body it was written in.
`repo` reached the editor for exactly two calls — `repo.notes.backlinks` and
`repo.notes.linkSearch` — and both left with the linking system. vue-tsc runs
with noUnusedLocals, so one stale import failed the whole shared-frontend build
and took both desktop lanes down with it (TS6133).
My local sweep checked for dangling *references*; it never checked the inverse,
that every import still has one. It does now, across all fifteen files that
removal touched — `repo` was the only one.
`_apply_note_items` didn't ignore items on a non-list note — it deleted them.
That was survivable only because nothing in the product could produce a note
holding both a body and items.
M13 makes exactly that the normal shape: a checklist is something a note HAS,
not something a note IS. Against that shape this guard is a data-loss path — the
first sync after adding a checklist to a note would wipe it.
Landing it before the UI that can create the state, so there is never a window
where the two disagree. `kind` itself, and the rest of the merge, follow.
`kind` was never a type. A plain TEXT column with no enum and no CHECK behind
it, compared against a hardcoded ("text", "list") tuple in six places;
`note_items` was always an ordinary child table keyed by note_id; serialization
already emitted `items` whatever the kind; and the Android editor already
toggled between the two losslessly, saying so in a comment. The storage has
modelled "a body plus optional checkable items" the whole time. This deletes the
gates that forbade it.
Every surface: the create/PATCH gates, the ?kind= filter and its saved-filter
facet, the three import/export branches, the column (alembic 0025); the core's
`kind` field, its SQLite column (user_version 6), the sync wire, push and pull;
the FFI records and `NoteEdit::Kind`; and on Android `NoteKind.kt`, `DraftKind`,
the compose sheet's Note/List switch, and the branches in the card, the editor
and the chrome.
The editor's note⇄list toggle becomes "Add a checklist" — on both the web and
Android. It is not a conversion any more: nothing moves, nothing is swapped, the
body stays exactly where it is and the note gains somewhere to put items. The
card renders both, in order.
Two things that fell out of the merge rather than being aimed at:
- The Keep importer was DISCARDING `textContent` whenever a note also had
`listContent`, because the target could only hold one. Both survive now, and
the test says so.
- Markdown export wrote the body OR the checklist. It writes both.
Protocol goes to v2, floor included: dropping a field a v1 client sends and
expects back is breaking. `title` leaves in step 3 and lands in the same
generation, so it needs no further bump. This is the change that will make the
0.1.227 build on the operator's phone refuse to sync — the in-app updater is
independent of the handshake and remains the recovery path.
The V1 SQLite schema deliberately KEEPS the kind column. V1 is the historical
schema and every later block alters it, so removing it there would make a fresh
database run V1 without the column and then v6's DROP COLUMN against a column
that never existed — "no such column: kind" on every new install.
**`snapshot_revision` was deleted with `create_titled`** (bc22f8e). I sliced the
function out by scanning to the next `pub fn`, and the private `fn` sitting
between them went too. Nothing in Python or TypeScript compiles Rust, so it sat
undetected until the first lane that does. Restored verbatim.
**An orphaned serde attribute** in push.rs: removing `pub kind: Option<String>`
left its `#[serde(skip_serializing_if)]` behind, which then stacked onto the
next field's. That failed the derive, which is why three follow-on errors all
said `Change: Serialize is not satisfied` — one cause, four messages.
**An unbalanced `</div>`** in NoteEditor.vue, orphaned when the "Links / Linked
from" footer was cut. `vue-tsc --noEmit` type-checks the SCRIPT block and never
parses the template, so the typecheck lane passed it and `vite build` caught it
two workflows later. Worth remembering: a green typecheck says nothing about
template structure.
I also pushed step 2 without waiting for ad21eac to go terminal, which is what
let the Rust break travel a commit further than it should have.
Each fix comes with the check that would have caught it: a scan for stacked
serde attributes and called-but-undefined fns across every .rs, and a tag
balance pass over every .vue. Both are clean.
Two more from the step-2 removals:
**Two unused Kotlin imports** — `FilterChip` (the Note/List switch) and
`Icons.Filled.Create` (the "switch to a note" icon), both orphaned when their
callers went. ktlint treats them as errors.
**`server_info_tolerates_unknown_and_absent_fields`** pinned
`sync_protocol_version: 1` as a literal, so bumping the protocol to v2 made it
fail for a reason that has nothing to do with what it tests. It is about unknown
FIELDS; the versions now come from `CLIENT_PROTOCOL_VERSION`, like every other
test in that file already did.
The bigger fix is the habit. `ci-requirements.md` has documented since
2026-08-18 that the operator authorised running fmt/clippy/test against the CI
image locally, and I had not been doing it. All three now pass here — 116 tests,
clippy clean, fmt clean — and every Rust failure in this milestone so far would
have been caught by them in under a minute instead of by CI, several commits
downstream. Noted in ci-requirements so the next session doesn't relearn it: a
removal is exactly the change that looks too safe to check.
Operator (note 2897): "notes shouldn't have a title field." The concept of a NAME
stays — search results, export filenames and the command palette all need one —
but nothing is typed into it any more. `display_title` is now the first non-empty
line of the body, falling back to the first checklist item.
That fallback is what step 2 bought, and the reason this could not go first: a
checklist had no body to be named from, so the title was its only name. Now every
note has a body, and a note that is only a checklist is named by its first item.
Gone everywhere: the column and note_revisions.title (0026), the field on the
core's Note/NoteCreateInput/NoteRevision and its SQLite columns (user_version 7),
`normalize_title`, the wire field, the FFI record and `NoteEdit::Title` /
`ClearTitle`, the web editor's "Title (optional)" input and the card's <h3>, and
the Android title field in both the compose sheet and the editor.
**The search vector had to be rebuilt, not just left alone.** `notes.search_vector`
is a STORED GENERATED column whose expression names `title` — Postgres refuses to
drop a column another generated column depends on. It is dropped and recreated over
`display_title` at weight A, which keeps the original intent: a note's NAME ranks
above the rest of its body.
**An imported title becomes the note's first body line.** Keep notes carry one, and
so does any ThoughtSync export taken before this. Dropping it would silently lose
text someone wrote; folding it in puts it exactly where a name now lives, so the
note arrives named as it was. Skipped when the body already opens with that line,
so re-importing an export this code produced doesn't stack duplicates.
Two smaller things fell out. The Android editor loses its bold first field — one
weight throughout, because the first line is the note's name but not a different
KIND of text, which is most of step 4 arriving early. And `ClearTitle`'s
justification comment moved to `ClearRemindAt`, which is now the surviving example
of why NoteEdit is a list rather than a struct of options.
Protocol note corrected to say what actually shipped: v2 is "no kind, no title",
one bump for the pair.
Verified with the local Rust gate this time, not by CI: fmt, clippy and 116 tests
all green before pushing. It caught four things — orphaned serde attributes where
fields were removed, a `wire::Preview.title` I deleted by mistake (a link preview
still has one), nine retention fixtures inserting a dropped column, and four
rustfmt diffs.
The one thing step 3 missed, and the typecheck lane caught it: `saveEdit`'s
parameter type still declared `title`, so the editor's call — correctly no
longer passing one — didn't match.
I gated Rust locally and not the frontend. Both are now in ci-requirements,
including WHY the frontend one has to be `npm run build` rather than
`vue-tsc --noEmit`: the typecheck only reads the script block, so a malformed
template sails past it and fails `vite build` in a different workflow, which is
exactly how the stray `</div>` got two commits away from where it was written.
26 Alembic revisions and none had ever been executed by CI. `alembic upgrade
head` ran for the first time when the operator's container started, and the
schema the migrations build had never been checked against the models that read
it. M13 dropped three columns and rebuilt a STORED GENERATED column with nothing
watching but a server boot.
Copied from FabledScribe's `integration` job, which had already solved the parts
that are easy to get wrong — and which are family rules precisely because they
were: a separator-free job key with no `name:` (act_runner derives the service
container name from the truncated display name, and the discovery step filters
`docker ps` by it), bridge-IP resolution because service hostnames aren't
routable on this runner, and a Python readiness wait because `run:` is busybox
sh with no `/dev/tcp`.
`postgres:16-alpine` to match the production compose. The schema is built by
real migrations, never metadata.create_all — that step IS the migration test.
Six tests, each pinning something that has only ever been checked by hand:
- an ORM insert against the migrated schema, which is the model/migration
agreement nothing has verified until now;
- `notes.title`, `notes.kind` and `note_revisions.title` are actually gone, and
`note_links` with them — a silently no-op migration shows up here;
- the rebuilt `search_vector` indexes both the name and the body, which matters
because 0026 had to DROP and recreate a generated column rather than alter it;
- a note keeps its body AND its items, the shape step 2 made normal;
- `_apply_note_items` leaves items alone when a change doesn't mention them —
the data-loss path step 2 removed, pinned so its return would be caught;
- a note with no body is still named by its first item, the hole that made
removing the title unsafe until checklists stopped being their own kind.
Runs for visibility; does not gate the build, matching `test` and Scribe.
No local equivalent: running it means standing up Postgres on the workstation,
which rule 12 reserves for an explicit request. Documented in ci-requirements
alongside the Rust, Kotlin and frontend gates.
M13 step 4 asked for no bold first line, and step 3 already delivered that —
removing `note.title` took the card's <h3> and the Android editor's bold field
with it. What step 4 also asked for, and hadn't been done, was the other half:
"be willing to spend something small on legibility that isn't weight on the
first line."
The web card rendered the entire body. Android has always clamped to eight lines
(`MAX_PREVIEW_LINES`), so one long note produced a card taller than the screen on
the web and pushed the rest of the board off it — a real asymmetry between two
surfaces that are supposed to be peers.
It matters more without a title. The first line used to be what your eye caught;
with one weight throughout, an unbounded card is just a wall, and the note beside
it is the one you were actually looking for.
Clamped in the STRING, not with CSS `line-clamp` — that needs a `-webkit-box` and
behaves unreliably around the block elements MarkdownText emits (lists, quotes,
fenced code). Doing it before the parse is deterministic, matches Android's
semantics exactly, and skips parsing a body the card was never going to show.
Operator: *"I'd like for URLs to unfurl. To be the whole note when the note is a
single URL, and to be a compact slot on the bottom of the note when the URL is
inline. We also need to support multiple URLs in a single note."*
Less new machinery than it sounds: `unfurl.py` already fetched and parsed OG
tags, SSRF-hardened, and `note_link_previews` was already `UNIQUE(note_id, url)`
— so several URLs per note has worked at the storage layer all along. What was
missing was that it needed a button, had one size, and drew that size in the
wrong place.
**Automatic, and never in the way.** New `unfurl_queue.py` detects a body's URLs
and fetches them on a background task AFTER the note is committed. Capture speed
is the product: an unfurl is a five-second timeout against a host nobody
controls, and a note has to persist the instant someone stops typing. Scheduled
from create, from a body edit, and from a synced push — so a linked desktop or
Android client gets previews too, on its next pull. An unlinked one has no server
to ask and simply has none, which is the honest consequence of being offline.
Safe to call on every save: it re-reads what's cached and does nothing when
nothing is new. Capped at five URLs per note, silent on every failure (a link
that won't fetch isn't an error the person needs — the note is fine, the link is
still there), and it re-checks before storing, so a slow fetch can't resurrect a
preview for a URL that was deleted while it was in flight.
**Two presentations.** A note whose body is nothing but a URL renders as its
preview and nothing else — printing the raw URL under a card that already says
where it goes is saying the same thing twice, badly. Until the fetch lands, or if
it never does, the URL stands in, so the card is never blank. Anything else gets
a compact strip.
**And the strip moved.** Previews were rendered ABOVE the body, which put a
stranger's headline where the note's own first line should be — worse now that
the first line IS the note's name. They sit at the foot of the card now, under
the note's own words.
The editor's "Preview example.com" button is gone with the manual path; removing
an unwanted preview stays, and stays editor-only.
Nine tests: three on detection (order, dedupe, sentence-punctuation trimming,
non-http rejection) in the unit lane, and three in the integration lane for what
only a real database shows — the upsert landing on the right row, a second pass
fetching nothing, and a preview NOT being stored for a URL that left the body.
Operator (note 2930): tags exist so you can *"filter during a search"*. The
server has always been able to do that — `GET /api/notes` composes `?q=` with
`?label=` and the rest into one AND-ed query. The frontend never reached it.
The header search box navigated to `/search`, and that view called a DIFFERENT
endpoint — `GET /api/notes/search?q=`, full text only, no facets at all. So the
one screen you landed on when you searched was the one screen where you could not
narrow by tag. Tag filtering lived on the board's FilterBar, which is where you
weren't searching. Two search boxes, two endpoints, and only the hidden one did
what tags are for.
Now the header box writes `?q=` into the board's URL beside whatever labels are
already there, and stays on the lens you're in — searching while looking at Trash
searches Trash. The box READS from the URL rather than holding its own copy, so
it stays in step with the Filters panel's Clear and with a saved view opened from
the sidebar.
Deleted: `SearchView.vue`, its route, `GET /api/notes/search`, `repo.notes.search`
and both adapter implementations, and the `notes_search` Tauri command whose only
caller was the adapter entry. FilterBar loses its own "Search text…" input — it
was the same facet, hidden behind a collapsed panel, duplicating a box that is
always on screen. Filters now does what its name says: narrowing. The header does
searching.
`core::store::search` STAYS. Android calls it through the FFI (`search_notes`) and
has its own search surface — which has the same no-tag-filter gap the web just
lost, and deserves the same fix on its own terms rather than as a rider here.
Found while preparing the release, and it would have quietly defeated the point
of cutting one.
The version does NOT come from the tag. `desktop/packaging/build-version.sh`
reads `desktop/src-tauri/Cargo.toml`, and on `dev` it appends the CI run number
(`0.1.269`) while on a tag or `main` it ships the file's value verbatim — which
was still `0.1.0`.
So tagging today would have published a "release" numbered BELOW every dev build
already out there, and below the 0.1.227 on the operator's phone. The desktop
updater compares semver: an installed build would have read the stable manifest,
seen a version older than its own, and correctly concluded it was already
current. The APK would have installed (versionCode is the run number and keeps
rising) while displaying a version that reads as going backwards.
build-version.sh predicted this in its own comment: "Bumping the minor in
Cargo.toml still wins over any dev build on the old line, which is the ordering
you want: 0.2.0 > 0.1.2932."
Bumped in four places, which is every one that can be read by something:
- `desktop/src-tauri/Cargo.toml` — the actual source; everything else derives
- `tauri.conf.json` — overridden at build time by `--config`, but a checked-in
value that lies is exactly how issue 2183 happened
- `pyproject.toml` + `__init__.py` — the server's APP_VERSION fallback when no
BUILD_VERSION is injected
`core` and `android/ffi` stay at 0.1.0 deliberately: internal library crates whose
version reaches no surface, and versioning workspace libs independently of the
app is normal.
Cargo.lock regenerated with `cargo fetch` per ci-requirements — one line, the
version itself. Verified: a tag build now yields 0.2.0 and a dev build 0.2.270,
so stable is an upgrade for every existing install and dev stays ahead of stable.
Operator: *"registration should be open only for the first user and they get
granted admin privileges. then registration is closed."*
The old shape had a window in it. The first account was always allowed and became
admin; every account after that was gated by `allow_registration` — which
defaulted to ON. So the door stayed open between "my account exists" and "I
remembered to turn it off in Settings", and on a public host that gap is the
entire exposure: it starts the moment DNS resolves and lasts until someone
remembers.
Now the door shuts as a CONSEQUENCE of the admin account existing, in the same
transaction that creates it. Not "defaults closed" — that would still need the
first person to get in somehow. There is no window to remember, because there is
no window.
Re-opening it is a deliberate act in Settings → Access: turn it on, have the
person register, turn it off. Crude, and it is the only mechanism there is —
**there is no invite system**, not even a stub. That is real work (a token table,
admin create/revoke, a redemption flow, expiry) and is filed as later work rather
than smuggled into a release.
An integration test covers it, because it is the interaction between two writes
in one transaction: first register → 201 and `is_admin: true`; the setting is
then false; a second register → 403; re-open deliberately and a third → 201, not
admin.
**This does not retroactively close an instance that already has users.** The
close fires on first-account creation, so a server whose admin predates this
keeps whatever the setting was — which was on. `docs/public-hosting.md` now says
so explicitly, and step 1 of the checklist is "check" rather than "do" for
exactly that reason.
Operator, before exposing the instance: *"I'd expect that we should have a proxy
hops setting for how many proxy hops we should trust a shared real-ip at… and is
there any session logging."* Neither existed, and the first one was a real hole.
**The address was forgeable.** `client_address()` read the LEFTMOST
`X-Forwarded-For` entry — nominally "the original client", and precisely the one a
caller controls, because anything they send arrives before what proxies append. So
`curl -H "X-Forwarded-For: 1.2.3.4"`, rotated per request, minted a fresh
rate-limit bucket every time.
Concretely: stuffing ONE account stayed limited (the account key is unforgeable
and that is why it exists), but spraying MANY accounts from one source was not —
each account got its own budget, and the per-address cap meant to bound the total
was defeated by a header. On a LAN that is nothing. It is not nothing on a public
host.
Now it counts in from the RIGHT by `THOUGHTSYNC_TRUSTED_PROXY_HOPS`, default 1.
Each hop appends what it saw, so the rightmost entries are the ones our own
infrastructure wrote and a forged prefix lands to the left of them where it can
never be selected — proven for the honest, forged, padded, CDN and
shorter-than-configured cases. 0 ignores the header entirely; 2 is Cloudflare in
front of a proxy. Too high is the dangerous direction, so a header shorter than
configured falls back to the socket address rather than reaching further left.
`X-Forwarded-Proto` had the same bug and now shares the same rule. Both live in a
new `proxy.py` rather than being written twice — two places holding one decision
is how issue 2183 happened, and this is the same decision.
Env rather than the Settings UI, against rule 25's usual pull: it is deployment
topology rather than preference, and the limiter consults it BEFORE opening a
database connection, which is the entire point of checking a throttle before doing
expensive work. Easy to move if that reads wrong.
**And there was no logging at all** — `auth.py` had no logger, and the only record
of anything was `device_tokens.last_used_at`. Sign-ins, failures, throttle trips,
new accounts and device-token issuance now all log, with the attempted email and
the trusted address. Deliberately including the email: it is the operator's own
server, and "somebody failed a login" without saying against which account is not
actionable.
`basicConfig` at INFO in `create_app`, because hypercorn configures its own loggers
and leaves the root at WARNING — without it every line above would have gone
nowhere, which is a worse failure than not writing them.
This is the app log, not an audit table. Not queryable, not retained past log
rotation. The table is task 2939; this is what makes the next few days observable.
Operator: *"proxy hops defaults to 1 and should be in the settings UI not in the
envs, we need the security values to be in the UI."* Overrules the call I made
yesterday, and rule 25 is on your side — I argued deployment-topology, but the
operator has to be able to SEE what protects them, and reading a container's
environment is not seeing.
Six new settings in a **Security** group: trusted proxy hops (default 1), the
per-account and per-address sign-in limits with their shared window, and the
sign-up limit with its own. `THOUGHTSYNC_TRUSTED_PROXY_HOPS` is gone; the rate
limits are no longer hardcoded constants.
**The hard part was keeping the throttle cheap.** It consults these BEFORE opening
a database connection — deliberately, because a refused attempt is meant to cost
nothing, and the hop count is needed to know who is even asking. A query per
attempt would undo both. So there is a small cache seeded from the registry
defaults (the app works with no database at all, which is what the DB-free unit
lane relies on), loaded at boot, and refreshed on every settings save — the same
live-update contract `session_ttl_days` already had.
`SlidingWindow` now takes its limit and window as SUPPLIERS rather than values, so
a saved number applies to the next attempt instead of the next deploy.
**Bounds are rejected, not clamped.** A hop count of 99 would trust anything a
caller sent; a sign-in limit of 0 would lock every account out permanently. Both
now fail validation with a message naming the range, and the number input carries
min/max so the browser objects first. Silently storing a different number than the
one typed is how somebody ends up believing a protection is set to something it is
not.
`MAX_BUCKETS` stays a constant on purpose: it protects the limiter from itself
rather than the app from a caller, and there is no operator judgment to apply.
Two integration tests, because the whole point is the round trip: a dangerous
value refused, a legitimate one reaching the cache the throttle reads and
persisting; and every Security row reaching the admin payload with bounds and a
description that explains itself.
The operator asked why `THOUGHTSYNC_BIND` isn't just defaulted to the safe value.
Fair question, and the answer exposed that my own advice was incomplete: I told
them to set it to 127.0.0.1 without asking where their proxy runs, and for a
proxy inside Docker that is the wrong fix.
There are three shapes, not two:
1. **LAN, no proxy** — the default. Binds every interface so a phone and a desktop
can reach the server. This is why the default is NOT the locked-down value: a
server reachable only from the machine it runs on isn't hardened, it's broken,
and that is the primary documented use of this app.
2. **Proxy in Docker** — delete the `ports:` block entirely. The proxy reaches the
app over the compose network; publishing a host port is a second,
unauthenticated way in that bypasses whatever the proxy does about TLS. Safer
than 127.0.0.1, because there is no host port to reach even from the host.
3. **Proxy on the host** — `THOUGHTSYNC_BIND=127.0.0.1`.
The compose file now spells out all three where the decision is made, and
`docs/public-hosting.md` item 4 asks where your proxy runs before telling you what
to do, plus how to check: `curl http://<lan-ip>:5000/api/health` from another
machine should NOT answer once you're proxied.
No default changed. Changing it would silently break every LAN install on the next
`docker compose pull` — the phone would just stop syncing, with nothing saying why.
09b5f87 moved trusted_proxy_hops out of the environment and into the
settings registry, but tests/test_proxy.py kept asserting against
Config.trusted_proxy_hops() — which no longer exists. The unit lane has
been red since that commit.
Assert through live() instead. That is what proxy.py actually calls, and
it is seeded from the defaults at import time, so the test covers the
case that matters: a boot that has not reached the database yet still
counts one hop rather than zero.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
22 commits. The through-line is scope: this release takes things out of ThoughtSync so that capture and recall are what's left, and then makes what's left safe to put on the public net.
The note is simpler
listis no longer its own kind. Any note can carry checkbox items, and body text and items coexist — which also fixes lists never having had a body.[[wiki-links]], backlinks and the graph are removed — code, callers, UI and settings rows. Organisation was secondary to recall, and this was the bulk of the overbuild.Capture and recall
100dvh, and controls that place themselves differently on touch than on hover.Ready to host
X-Forwarded-Forso a caller cannot forge their own address. Every credential event is logged.docker-compose.ymlsays which of the three deployment shapes you're in — LAN, proxy in Docker, proxy on the host — because the right answer differs and only one of them is the default.CI
alembic upgrade head, data reset byTRUNCATEbetween tests.Version is
0.2.0: the desktop version comes fromCargo.toml, not the tag, so releasing at0.1.0would have shipped a downgrade below the0.1.269dev builds.CI green on
c851b90(run 4296) — all six jobs, including the first real integration run since the security work landed.🤖 Generated with Claude Code
`kind` was never a type. A plain TEXT column with no enum and no CHECK behind it, compared against a hardcoded ("text", "list") tuple in six places; `note_items` was always an ordinary child table keyed by note_id; serialization already emitted `items` whatever the kind; and the Android editor already toggled between the two losslessly, saying so in a comment. The storage has modelled "a body plus optional checkable items" the whole time. This deletes the gates that forbade it. Every surface: the create/PATCH gates, the ?kind= filter and its saved-filter facet, the three import/export branches, the column (alembic 0025); the core's `kind` field, its SQLite column (user_version 6), the sync wire, push and pull; the FFI records and `NoteEdit::Kind`; and on Android `NoteKind.kt`, `DraftKind`, the compose sheet's Note/List switch, and the branches in the card, the editor and the chrome. The editor's note⇄list toggle becomes "Add a checklist" — on both the web and Android. It is not a conversion any more: nothing moves, nothing is swapped, the body stays exactly where it is and the note gains somewhere to put items. The card renders both, in order. Two things that fell out of the merge rather than being aimed at: - The Keep importer was DISCARDING `textContent` whenever a note also had `listContent`, because the target could only hold one. Both survive now, and the test says so. - Markdown export wrote the body OR the checklist. It writes both. Protocol goes to v2, floor included: dropping a field a v1 client sends and expects back is breaking. `title` leaves in step 3 and lands in the same generation, so it needs no further bump. This is the change that will make the 0.1.227 build on the operator's phone refuse to sync — the in-app updater is independent of the handshake and remains the recovery path. The V1 SQLite schema deliberately KEEPS the kind column. V1 is the historical schema and every later block alters it, so removing it there would make a fresh database run V1 without the column and then v6's DROP COLUMN against a column that never existed — "no such column: kind" on every new install.