diff --git a/alembic/versions/0001_foundation.py b/alembic/versions/0001_foundation.py index 9f6083a..cf93ab2 100644 --- a/alembic/versions/0001_foundation.py +++ b/alembic/versions/0001_foundation.py @@ -4,8 +4,8 @@ Revision ID: 0001 Revises: Create Date: 2026-07-19 -The M0 identity + sharing-ACL spine. No notes yet (those arrive in M1); the -shares table is polymorphic so notes reuse it without a schema retrofit. +Identity and the sharing ACL. The shares table is polymorphic, so any shareable +entity uses it. """ from alembic import op import sqlalchemy as sa diff --git a/alembic/versions/0002_notes.py b/alembic/versions/0002_notes.py index e246e1b..19ef164 100644 --- a/alembic/versions/0002_notes.py +++ b/alembic/versions/0002_notes.py @@ -4,7 +4,7 @@ Revision ID: 0002 Revises: 0001 Create Date: 2026-07-19 -The M1 capture core: the notes table the masonry board renders. +The notes table the board renders. """ from alembic import op import sqlalchemy as sa diff --git a/alembic/versions/0003_roles_settings.py b/alembic/versions/0003_roles_settings.py index 50a2d12..5271e9b 100644 --- a/alembic/versions/0003_roles_settings.py +++ b/alembic/versions/0003_roles_settings.py @@ -5,7 +5,7 @@ Revises: 0002 Create Date: 2026-07-19 Adds the admin role bit and the DB-backed settings table. No rows are seeded — -defaults live in the code registry (thoughtsync.settings), so an empty table +defaults live in the code registry (inkwell.settings), so an empty table means every setting is at its default. """ from alembic import op diff --git a/alembic/versions/0012_note_display_title.py b/alembic/versions/0012_note_display_title.py index 507f93c..82b91f1 100644 --- a/alembic/versions/0012_note_display_title.py +++ b/alembic/versions/0012_note_display_title.py @@ -16,10 +16,8 @@ depends_on = None def upgrade() -> None: # The note's display NAME: explicit title if set, else the first body line. op.add_column("notes", sa.Column("display_title", sa.Text(), nullable=False, server_default="")) - # Best-effort backfill (kept intentionally simple + unambiguous — CI has no - # Postgres lane, so this only runs on a real deploy). The app recomputes the - # precise value via derive_display_title on the next save (which additionally - # skips leading blank lines). Capped at 200 chars. + # Best-effort backfill, capped at 200 chars. The app recomputes the exact value + # (derive_display_title, which also skips leading blank lines) on the next save. op.execute( r""" UPDATE notes SET display_title = LEFT(btrim( diff --git a/alembic/versions/0015_sync_revision.py b/alembic/versions/0015_sync_revision.py index c69e8d8..6610636 100644 --- a/alembic/versions/0015_sync_revision.py +++ b/alembic/versions/0015_sync_revision.py @@ -1,4 +1,4 @@ -"""sync revision + tombstone infrastructure (M8 sync hub, step 1) +"""sync revision + tombstone infrastructure Revision ID: 0015 Revises: 0014 @@ -11,9 +11,6 @@ forget to bump it. Child-row changes (items / attachments / label membership) bu their parent note's revision, since a note syncs as a whole. `purged_at` is the hard-delete tombstone (distinct from `deleted_at` = recoverable trash) so an offline client learns a row is gone instead of resurrecting it. - -No API behavior changes here — this is pure infrastructure. Triggers/behavior are -operator-verified on deploy (no Postgres CI lane). """ from alembic import op import sqlalchemy as sa diff --git a/alembic/versions/0016_device_tokens.py b/alembic/versions/0016_device_tokens.py index b6ff96d..1d85dc2 100644 --- a/alembic/versions/0016_device_tokens.py +++ b/alembic/versions/0016_device_tokens.py @@ -1,4 +1,4 @@ -"""device_tokens (M8 sync hub, step 2) +"""device_tokens Revision ID: 0016 Revises: 0015 diff --git a/alembic/versions/0017_label_updated_at.py b/alembic/versions/0017_label_updated_at.py index 75d241b..43b6584 100644 --- a/alembic/versions/0017_label_updated_at.py +++ b/alembic/versions/0017_label_updated_at.py @@ -1,4 +1,4 @@ -"""labels.updated_at (M8 sync hub, step 4 — LWW field for the label catalog) +"""labels.updated_at, the last-write-wins field for the label catalog Revision ID: 0017 Revises: 0016 diff --git a/alembic/versions/0018_attachment_sha256.py b/alembic/versions/0018_attachment_sha256.py index 56cbfce..55f5345 100644 --- a/alembic/versions/0018_attachment_sha256.py +++ b/alembic/versions/0018_attachment_sha256.py @@ -1,4 +1,4 @@ -"""note_attachments.sha256 (M8 sync hub, step 5 — attachment blob sync) +"""note_attachments.sha256, for attachment blob sync Revision ID: 0018 Revises: 0017 diff --git a/alembic/versions/0019_attachment_filename.py b/alembic/versions/0019_attachment_filename.py index debb41e..b2db251 100644 --- a/alembic/versions/0019_attachment_filename.py +++ b/alembic/versions/0019_attachment_filename.py @@ -1,4 +1,4 @@ -"""note_attachments.filename (any-file attachments — M6 1900) +"""note_attachments.filename, for attachments of any file type Revision ID: 0019 Revises: 0018 diff --git a/alembic/versions/0020_note_link_previews.py b/alembic/versions/0020_note_link_previews.py index d31abb0..0f55ba4 100644 --- a/alembic/versions/0020_note_link_previews.py +++ b/alembic/versions/0020_note_link_previews.py @@ -1,4 +1,4 @@ -"""note_link_previews (URL unfurl — M6 1901) +"""note_link_previews, cached URL unfurls Revision ID: 0020 Revises: 0019 diff --git a/alembic/versions/0021_saved_filters.py b/alembic/versions/0021_saved_filters.py index 2a30606..9373910 100644 --- a/alembic/versions/0021_saved_filters.py +++ b/alembic/versions/0021_saved_filters.py @@ -1,4 +1,4 @@ -"""saved_filters (richer search + saved views — M6 1902) +"""saved_filters, saved search views Revision ID: 0021 Revises: 0020 diff --git a/alembic/versions/0022_note_recurrence.py b/alembic/versions/0022_note_recurrence.py index 1027d32..18b2f12 100644 --- a/alembic/versions/0022_note_recurrence.py +++ b/alembic/versions/0022_note_recurrence.py @@ -1,4 +1,4 @@ -"""notes.recurrence (recurring reminders — M6 1908) +"""notes.recurrence, for recurring reminders Revision ID: 0022 Revises: 0021 diff --git a/alembic/versions/0023_note_link_target_id.py b/alembic/versions/0023_note_link_target_id.py index 4a21a9d..9d913ba 100644 --- a/alembic/versions/0023_note_link_target_id.py +++ b/alembic/versions/0023_note_link_target_id.py @@ -1,14 +1,11 @@ -"""note_links.target_id — resolve [[links]] to a note, not to a string (M13 step 1) +"""note_links.target_id — resolve [[links]] to a note, not to a string Revision ID: 0023 Revises: 0022 Create Date: 2026-08-22 -A wiki-link stored only as normalized TEXT means a note's name IS the edge: rename -the note and every inbound link stops matching. The old answer was to rewrite the -`[[Old Name]]` text inside every note that linked to it — workable while an explicit -title existed to hold still, untenable once a note's name is just its first body -line (M13). +A wiki-link stored only as normalized text makes a note's name the edge, so renaming +the note breaks every inbound link. Resolving to an id survives a rename. `target_norm` stays: it is what an UNRESOLVED link carries, since linking to a note that doesn't exist yet is a supported way to create one. diff --git a/alembic/versions/0024_drop_note_links.py b/alembic/versions/0024_drop_note_links.py index ab831ff..d3f806e 100644 --- a/alembic/versions/0024_drop_note_links.py +++ b/alembic/versions/0024_drop_note_links.py @@ -1,22 +1,14 @@ -"""drop note_links — [[wiki-links]] are removed (note 2897) +"""drop note_links — [[wiki-links]] are removed Revision ID: 0024 Revises: 0023 Create Date: 2026-08-22 -ThoughtSync is an intermediary surface for capture and recall; a linking system is -organization, which is not what it is for. Backlinks, the graph and the name index -went with it. +0023 stays in the chain: removing an applied revision would strand a database's +alembic_version pointer. -0023 (which added `note_links.target_id`) is deliberately left in the chain rather -than deleted. It shipped in an image and may already be applied, and removing an -applied revision would strand a database's alembic_version pointer. So the column is -dropped here along with the table it lived on, and the history stays honest about the -fact that it existed for a day. - -No down-migration data concern: note_links was always DERIVED from note bodies. The -`[[text]]` is still sitting in every body it was written in; nothing a person typed is -lost by this. +note_links was derived from note bodies, so nothing typed is lost; the `[[text]]` is +still in every body. """ from alembic import op import sqlalchemy as sa diff --git a/alembic/versions/0025_drop_note_kind.py b/alembic/versions/0025_drop_note_kind.py index 08fd2d0..362e975 100644 --- a/alembic/versions/0025_drop_note_kind.py +++ b/alembic/versions/0025_drop_note_kind.py @@ -1,19 +1,11 @@ -"""drop notes.kind — a checklist is something a note HAS (M13 step 2) +"""drop notes.kind — a checklist is something a note has Revision ID: 0025 Revises: 0024 Create Date: 2026-08-22 -`kind` was never a type: a plain TEXT column with no enum and no CHECK, compared -against a hardcoded ("text", "list") tuple in six places. `note_items` was always an -ordinary child table keyed by note_id, serialization always emitted `items` whatever -the kind, and the Android editor already toggled between the two losslessly. The -storage has modelled "a body plus optional checkable items" the whole time; only the -gates forbade it. - -Nothing is lost. Items were already rows in their own table, and a note that was -`kind = 'list'` keeps every one of them — it just stops being a different sort of -thing from the note next to it. +A note is a body plus optional checkable items. Items are rows in their own table, +so a `kind = 'list'` note keeps every one. """ from alembic import op import sqlalchemy as sa @@ -29,7 +21,6 @@ def upgrade() -> None: def downgrade() -> None: - # server_default so existing rows get a value; every note comes back as 'text', - # which is right — a restored note with items would previously have hidden its - # body, and there is no record of which ones were once lists. + # server_default so existing rows get a value. Every note comes back as 'text': + # nothing records which were lists. op.add_column("notes", sa.Column("kind", sa.Text(), nullable=False, server_default="text")) diff --git a/alembic/versions/0026_drop_note_title.py b/alembic/versions/0026_drop_note_title.py index e00bdc5..66d92d6 100644 --- a/alembic/versions/0026_drop_note_title.py +++ b/alembic/versions/0026_drop_note_title.py @@ -4,33 +4,14 @@ Revision ID: 0026 Revises: 0025 Create Date: 2026-08-22 -M13 step 3. A note is a body plus optional checkable items; its NAME is the first -non-empty line of that body, falling back to its first checklist item. There is no -separate field to type into, and `display_title` (already persisted, already what -search results and export filenames read) carries the name. +A note's name is its first non-empty body line, carried by `display_title`. -## The search vector has to be rebuilt, not just left alone +`notes.search_vector` is a stored generated column over `title` (0005, weight A), and +Postgres won't drop a column a generated one depends on. So it is recreated over +`display_title`, keeping the name ranked above the body. That recomputes every row +and rebuilds the GIN index, which takes a while on a large table. -`notes.search_vector` is a STORED GENERATED column whose expression names `title` -(migration 0005, weight A) — Postgres will refuse to drop a column another generated -column depends on, and even if it didn't, the weighting would be wrong. So it is -dropped and recreated over `display_title` instead, which keeps the original -intent: the note's NAME ranks above the rest of its body. - -Rebuilding a stored generated column re-computes every row, and the GIN index is -rebuilt with it. On a personal instance that is milliseconds; it is worth knowing -before running this against something large. - -## What happens to existing titles - -Nothing preserves them, deliberately: `display_title` was already derived from the -title when one was set, so every note keeps the NAME it had. What is lost is the -distinction between "this note has an explicit title" and "this note's first line is -its name" — which is the distinction being removed. - -Imports are the exception and are handled in code, not here: a Keep note's title, or -one in an export taken before this, is folded in as the note's first body line rather -than dropped (see `_create_imported_note`). +`display_title` was already derived from the title, so every note keeps its name. """ from alembic import op import sqlalchemy as sa diff --git a/alembic/versions/0027_checklist_items_into_body.py b/alembic/versions/0027_checklist_items_into_body.py index bae2368..f4d630f 100644 --- a/alembic/versions/0027_checklist_items_into_body.py +++ b/alembic/versions/0027_checklist_items_into_body.py @@ -4,64 +4,31 @@ Revision ID: 0027 Revises: 0026 Create Date: 2026-08-24 -M304. A checklist item becomes a `- [ ] milk` line of `notes.body`, and `note_items` -goes. The reason is positional, not cosmetic: a row had a position in a table and no -position in the text, so a separate list could only ever render AFTER the prose. With -the items in the body, a list can sit between two paragraphs — which is the thing that -could not be built before and no amount of restyling would have delivered. +A checklist item becomes a `- [ ] milk` line of `notes.body`, so a list can sit +between two paragraphs, and `note_items` goes. ## This migration rewrites note bodies -Every note that has items gets its body appended to. The rules below are strict -because rewriting somebody's text deserves it — not, as an earlier draft of this -docstring claimed, because this instance holds imported Google Keep notes. It does -not; note 2916's headline is that nothing here is anyone's work but the operator's -test data. What 2916 actually says about imports is conditional — text arriving from -another app WOULD be real, and any import path has to treat it that way — and the -importer this migration shares a format with is one nobody here has run. + * Rows are read before the table is dropped, in this one transaction. + * The existing body is only appended to. + * The layout (a blank line between prose and the list, nothing between items) is + what the export writes and what `derive::append_item` produces, so the clients + migrate their own stores independently and still agree with the server. -Careful was still the right call. It cost little, and the same care is what the rule -demands the day someone does import something: +The fold is inlined rather than imported from `notes/checklist.py`: a migration must +keep producing what it produced the day it ran. - * Rows are read BEFORE the table is dropped, in this one transaction. - * The existing body is never rewritten, only appended to. - * The layout — a blank line between prose and the list, nothing between consecutive - items — is byte-for-byte what `_note_markdown` has always exported and what - `derive::append_item` produces on every client. All three landing on the same text - is what lets the clients migrate their own SQLite stores independently and still - agree with the server, with no sync required to reconcile them. +## `updated_at` is left alone -## The fold is inlined on purpose +Raw SQL, so `onupdate` never fires, and a client holding an unpushed edit keeps the +newer `updated_at` and wins last-write-wins. The `sync_revision` trigger does fire, +so every migrated note is pulled once and clients converge. -`notes/checklist.py` has this same function and this migration deliberately does not -import it. A migration has to keep producing what it produced the day it ran; if the -app's spacing rule ever changes, this file must not change with it. +## The downgrade is not a true inverse -## `updated_at` is left alone, and that is load-bearing - -Raw SQL, so SQLAlchemy's `onupdate` never fires. Two reasons, and the second matters -more than the first. Every client folds the same rows the same way, so the new body is -news to nobody. And a client holding an UNPUSHED body edit still has the newer -`updated_at`, so when it pulls the migrated note last-write-wins keeps its edit instead -of the migration silently winning. - -The `notes` row's own `sync_revision` trigger (migration 0015) does fire, so every -migrated note becomes pullable once. That is wanted: it is what makes a client whose -local fold somehow differed converge on the server's text. - -## The downgrade is not a true inverse, and says so - -It recreates an empty `note_items` and leaves the bodies alone. Nothing is lost — -every item is still there as text, which is where this migration put it — but the old -code would show those notes as prose with no checklist. A faithful inverse is not -possible: once the items are lines, nothing distinguishes a line this migration wrote -from one somebody typed, and a downgrade that guessed would eat hand-written task -lists. The real rollback is a database restore. - -Recreating the table is not decoration, though. Migration 0015's downgrade runs -`DROP TRIGGER IF EXISTS trg_note_items_bump_note ON note_items`, and `IF EXISTS` -covers the trigger, not the table — against a missing table that statement errors. So -this is what keeps the migration chain runnable all the way back down. +It recreates an empty `note_items` and leaves the bodies alone: nothing tells a line +this wrote from one somebody typed. The real rollback is a database restore. The +table must exist because 0015's downgrade drops a trigger on it. """ import re @@ -100,9 +67,8 @@ def upgrade(): for note_id, items in grouped.items(): body = bind.execute(sa.text("SELECT body FROM notes WHERE id = :id"), {"id": note_id}).scalar() - # An item whose note is already gone has nothing to fold into. The foreign key - # should make this impossible; skipping costs nothing and failing here would - # leave the database half-migrated. + # An item whose note is gone has nothing to fold into. The foreign key should + # prevent it; skipping avoids a half-migrated database. if body is None: continue for text, checked in items: @@ -113,9 +79,8 @@ def upgrade(): def downgrade(): - # Column-for-column as migration 0006 created it, index name included: 0015's - # downgrade names both the table and its trigger, so a near-enough copy is not - # good enough. + # Exactly as 0006 created it, index name included: 0015's downgrade names both + # the table and its trigger. op.create_table( "note_items", sa.Column("id", UUID(as_uuid=True), primary_key=True), diff --git a/alembic/versions/0028_lift_standalone_tags.py b/alembic/versions/0028_lift_standalone_tags.py index fc9663c..63c6a06 100644 --- a/alembic/versions/0028_lift_standalone_tags.py +++ b/alembic/versions/0028_lift_standalone_tags.py @@ -4,73 +4,27 @@ Revision ID: 0028 Revises: 0027 Create Date: 2026-08-26 -M311. A `#tag` was being shown twice — once as the text you typed and once as a chip — -and with the chip moved to the top of the card the text is redundant. This removes it, -but only from notes where the tag was standing on its own. +A tag standing on its own line shows as a chip, so its text is removed, by the same +rule as `notes/tags.py:split_body_tags`: -## This migration rewrites note bodies + * A line containing nothing but tags and whitespace is removed. + * Every other line is left exactly as written, so `remember to call #mom` stays. + * A line inside a ``` fence is never touched. + * A note that is nothing but tags keeps its text, rather than becoming a blank card. -The rule is deliberately narrow, and the same one `notes/tags.py:split_body_tags` -applies from here on: +The label rows flip to `via_tag = false` in the same transaction: the text backing +them is gone, and `_lift_and_reconcile_tags` would otherwise detach them on the next +save. - * A line containing nothing but tags and whitespace is REMOVED. - * Every other line is left exactly as written. +The transform and `_display_title` are inlined: a migration must keep producing what +it produced the day it ran. The title is recomputed only where the body moved. -So `#todo` on its own line goes, and `remember to call #mom tomorrow` does not. The -looser reading — also stripping a trailing tag off a prose line — was rejected because -the text does not say which kind it is: `buy milk #grocery` is filing, `remember to -call #mom` is the sentence's object, and lifting the second leaves "remember to call". -Rewriting somebody's words to save a duplicate chip is a bad trade, and a migration is -the worst possible place to make it. +Raw SQL, so `onupdate` never fires and a client's unpushed edit still wins +last-write-wins. The `sync_revision` trigger fires, so clients pull the server's text. -Two guards, both of which cost a note nothing: - - * A line inside a ``` fence is never touched. A `#tag` there is a shell comment in a - snippet somebody pasted, and deleting it would eat a line of their example. - * A note that is NOTHING but tags keeps its text. Lifting would leave a blank card, - which is worse than the duplication this fixes. - -## The label rows have to graduate in the same transaction - -A `via_tag` row means "this label is backed by text still in the body". Once the text -is gone that is false, and leaving it true is not cosmetic: `_lift_and_reconcile_tags` -detaches any `via_tag` row it cannot find a `#tag` for, so the note would lose the tag -on its very next save. The flip to `via_tag = false` is what makes the label the record -instead — and what makes the chip's × appear in both editors, which is now the only way -to remove a tag whose text no longer exists. - -## The transform is inlined, like 0027's - -`split_body_tags` is deliberately NOT imported. A migration has to keep producing what -it produced the day it ran; if the app's rule is ever loosened, this file must not -loosen with it and start eating prose it previously left alone. - -`_display_title` is inlined for the same reason, and is only recomputed for a note whose -body actually moved — a note named after a `#todo` line needs a new name, and reading it -from the app would couple this migration to a rule that has already changed once (M13). - -## `updated_at` is left alone, and that is load-bearing - -Raw SQL, so SQLAlchemy's `onupdate` never fires. A client holding an UNPUSHED body edit -keeps the newer `updated_at`, so when it pulls the migrated note last-write-wins keeps -its edit instead of the migration silently winning. - -The `sync_revision` trigger (migration 0015) does fire, so every rewritten note becomes -pullable once and clients converge on the server's text. That is wanted here: unlike -0027, the clients do NOT yet apply this rule locally, so the server's copy is the only -correct one until they do. - -## The downgrade is not a true inverse, and says so - -It cannot be. Nothing distinguishes a `#todo` line this migration deleted from one that -was never there, and putting one back would be guessing at where in the note it went. - -Nothing is lost, though, which is why that is acceptable: the tag still exists as a -label on the note, and the chip still shows it. What a downgrade cannot restore is the -DUPLICATE — which is the thing this migration set out to remove. Rolling the rows back -to `via_tag = true` would be actively harmful: the text that flag claims to be backed by -is gone, so the next save would detach the label and lose the tag for real. So the -downgrade leaves both alone. The real rollback is a database restore. +The downgrade does nothing. Restoring the lines would be guessing, and flipping the +rows back to `via_tag = true` would detach every label on the next save. The real +rollback is a database restore. """ import re @@ -152,17 +106,11 @@ def upgrade(): sa.text("UPDATE notes SET body = :body, display_title = :title WHERE id = :id"), {"body": lifted, "title": _display_title(lifted), "id": note_id}, ) - # Even when the body did not move, a tag can be standalone only in the sense - # that its line was already removed by an earlier pass — so the flip is driven - # by the tag list, not by whether the text changed. + # The flip is driven by the tag list, not by whether the text changed: an + # earlier pass may already have removed the line. if standalone: bind.execute(flip, {"nid": note_id, "names": [n.lower() for n in standalone]}) def downgrade(): - """Deliberately empty — see the module docstring. - - Restoring the deleted lines would be guessing, and flipping the rows back to - `via_tag = true` would be worse than doing nothing: the text that flag claims backs - them is gone, so the next save would detach the label and lose the tag for real. - """ + """Deliberately empty; see the module docstring.""" diff --git a/alembic/versions/0029_drop_note_color.py b/alembic/versions/0029_drop_note_color.py index bf0e2e3..f0183bf 100644 --- a/alembic/versions/0029_drop_note_color.py +++ b/alembic/versions/0029_drop_note_color.py @@ -4,50 +4,18 @@ Revision ID: 0029 Revises: 0028 Create Date: 2026-08-28 -M315 step 3. A note's colour was set by a picker and read by three card renderers. -Steps 1 and 2 stopped every one of those reads: the card is one neutral per theme and -the only coloured thing on a board is a tag. This drops the column that nothing has -been reading since, and the picker goes with it. +Cards are one neutral surface per theme; the only colour on a board is a tag's. +`labels.color` is untouched. A colour a note was given is dropped with the column. -`labels.color` is untouched. That is the colour that survived, and the one the whole -milestone was about keeping. +`saved_filters.params` may carry a `"color"` facet, so stored views are swept too. In +Python rather than `params::jsonb - 'color'`, because Postgres has no try-cast and one +malformed blob would abort the migration; a row that doesn't parse is left as it is. -## What is lost, and why that is the change rather than a cost of it - -Any colour a note was explicitly given. There is nowhere to preserve it TO — the field -it would be preserved in is the one being dropped — and nothing renders it, so a -preserved value would be a column kept warm for a feature that was deliberately -removed. A note that had a colour now takes its identity from its tags, which is what -the operator asked for: "strip color from the cards ... and keep the color for tags -just on the tag." - -The palette itself is not lost. `NOTE_COLORS` moved from `models/note.py` to -`colors.py` in the same change — labels still name a colour, and leaving the vocabulary -defined on the model that lost one would be an invitation to put the column back. - -## The saved-filter sweep is not optional - -`saved_filters.params` is opaque JSON mirroring the `GET /api/notes` facet query, and -a stored view could carry `"color": "teal"`. With the facet gone that key would sit -there forever, and `clean_params` only guards what is written FROM here on. A view that -silently filters on a field the app no longer has is worse than one that visibly lost a -criterion, so the stored rows are swept too. - -Done in Python rather than as `params::jsonb - 'color'`, deliberately. Postgres has no -try-cast: one malformed blob would abort the whole migration, and these rows are -somebody's saved views. `json.loads` in a try/except lets a corrupt row keep whatever it -holds and lets every other row be fixed. - -## Search is not affected - -`notes.search_vector` is a stored generated column over `display_title` and `body` -(rebuilt in 0026). It never named `color`, so unlike the title drop there is nothing -here to tear down and recreate. +The search vector never named `color`, so it needs no rebuild. ## Downgrade -Restores the column, empty, at its old default. The values are not recoverable — see -above. It is the schema that comes back, not the data. +Restores the column, empty, at its old default. The values are not recoverable. """ import json @@ -71,9 +39,8 @@ def upgrade() -> None: try: parsed = json.loads(params) except (ValueError, TypeError): - # A blob that does not parse cannot be edited safely. Leaving it is - # correct: it was already unreadable by the app, and this migration is not - # the place to decide what it should have said. + # A blob that doesn't parse can't be edited safely; the app couldn't read + # it either. continue if not isinstance(parsed, dict) or "color" not in parsed: continue diff --git a/alembic/versions/0030_site_name_inkwell.py b/alembic/versions/0030_site_name_inkwell.py index 3751740..258b2c0 100644 --- a/alembic/versions/0030_site_name_inkwell.py +++ b/alembic/versions/0030_site_name_inkwell.py @@ -4,15 +4,9 @@ Revision ID: 0030 Revises: 0029 Create Date: 2026-10-06 -The product is renamed ThoughtSync → Inkwell (Scribe note 5071), and the `site_name` -registry default moves with it. On its own that only reaches servers whose settings -table has no `site_name` row — and most servers have one, because the Settings page -saves EVERY key on Save, not just the one that changed. An admin who opened Settings -to flip registration off has persisted `"ThoughtSync"` without ever choosing it. - -So the row is rewritten, but only when it holds exactly the old default. A name the -admin actually typed is theirs and is left alone; the old default is the one value we -can be sure nobody chose. +The app was renamed from ThoughtSync to Inkwell. The Settings page saves every key, +so most servers have a stored `site_name` of the old default without anyone choosing +it. The row is rewritten only when it holds exactly that; a typed name is left alone. The match is on the stored JSON text (`settings.value` is `json.dumps(value)`), so `'"ThoughtSync"'` is the whole of what it looks for. diff --git a/alembic/versions/0031_previews_bump_note_revision.py b/alembic/versions/0031_previews_bump_note_revision.py index 62710f4..74d1b4b 100644 --- a/alembic/versions/0031_previews_bump_note_revision.py +++ b/alembic/versions/0031_previews_bump_note_revision.py @@ -4,16 +4,9 @@ Revision ID: 0031 Revises: 0030 Create Date: 2026-10-07 -0015 made every child table bump its parent note's `sync_revision`, so a note syncs -as a whole. `note_link_previews` arrived later (0020) and was never added, and two -things have been missing on every linked device since: - -- A preview fetched in the background after a save (`unfurl_queue.py`) never reached - a device that had already pulled the note. The note's revision was assigned when - the TEXT was saved, before the preview existed, and nothing moved it afterwards. -- A preview dismissed on the web stayed on every other device, for the same reason. - -The trigger is the same function 0015 installs on the other child tables. +A note syncs as a whole, so every child table bumps its note's `sync_revision`. With +this, a preview fetched after a save, or dismissed on the web, reaches every device. +The trigger is the function 0015 installs on the other child tables. ## Downgrade diff --git a/alembic/versions/0032_invites.py b/alembic/versions/0032_invites.py index 3490ec2..cdf0789 100644 --- a/alembic/versions/0032_invites.py +++ b/alembic/versions/0032_invites.py @@ -4,9 +4,8 @@ Revision ID: 0032 Revises: 0031 Create Date: 2026-10-07 -Until now the only way to add a second person was to re-open registration to the -whole internet while they signed up (#2939 §1). An invite lets one person register -while registration stays closed. Only the token's SHA-256 hash is stored. +An invite lets one person register while registration stays closed. Only the token's +SHA-256 hash is stored. ## Downgrade diff --git a/alembic/versions/0033_password_resets.py b/alembic/versions/0033_password_resets.py index 5b496b9..b6ea8e4 100644 --- a/alembic/versions/0033_password_resets.py +++ b/alembic/versions/0033_password_resets.py @@ -4,20 +4,19 @@ Revision ID: 0033 Revises: 0032 Create Date: 2026-10-07 -A forgotten password used to need a hand on the database (#2939 §2), and the app has -no mail path to send a reset by. An admin makes a reset link for the account and -hands it over (#5173). Only the token's SHA-256 hash is stored. +An admin makes a reset link for the account and hands it over. Only the token's +SHA-256 hash is stored. `users.session_epoch` is what lets a reset sign the account out everywhere. Sessions are signed cookies held by the browser, so the server can't delete them; each one carries the epoch it was signed in under, and a reset moves the account's epoch on. -It starts at 0, the value a cookie from before this migration is read as, so nobody -is signed out by the upgrade itself. +It starts at 0, the value a cookie without one is read as, so the upgrade signs +nobody out. ## Downgrade Drops the table and the column. Outstanding reset links stop working; sessions keep -working, since nothing checks an epoch any more. +working, since nothing checks an epoch. """ import sqlalchemy as sa from alembic import op diff --git a/alembic/versions/0034_share_constraints.py b/alembic/versions/0034_share_constraints.py index 10181f2..5861e96 100644 --- a/alembic/versions/0034_share_constraints.py +++ b/alembic/versions/0034_share_constraints.py @@ -4,11 +4,10 @@ Revision ID: 0034 Revises: 0033 Create Date: 2026-10-07 -Nothing wrote a share until #5174, so the table never needed these. Now the Share -dialog does: +For the Share dialog: - A unique index on (resource_type, resource_id, shared_with_user_id) where a user is the target, so sharing a note with someone twice updates the one grant rather than - stacking two (the same for a group, ahead of step 15). + stacking two (the same for a group). - A check that `permission` is `view` or `edit`. - An index on `shared_with_user_id` for "Shared with me". diff --git a/alembic/versions/0035_share_revocations.py b/alembic/versions/0035_share_revocations.py index 2ed5c9a..4953e77 100644 --- a/alembic/versions/0035_share_revocations.py +++ b/alembic/versions/0035_share_revocations.py @@ -5,7 +5,7 @@ Revises: 0034 Create Date: 2026-10-07 The change feed carries every note a person can see, including notes shared with -them (#5175). When a share ends, the note stops being visible, so it simply stops +them. When a share ends, the note stops being visible, so it simply stops appearing in the feed. A device that already holds a copy would keep it forever. A revocation is that missing signal: one row per (note, person) who lost the note, stamped from the same `sync_revision_seq` the notes and labels draw from, so it sits diff --git a/alembic/versions/0036_note_user_state.py b/alembic/versions/0036_note_user_state.py index 1151159..19eabd6 100644 --- a/alembic/versions/0036_note_user_state.py +++ b/alembic/versions/0036_note_user_state.py @@ -4,12 +4,10 @@ Revision ID: 0036 Revises: 0035 Create Date: 2026-10-07 -Pin, archive and board order are personal organization, and until now they were -columns on `notes`, so on a shared note they could only ever mean the owner's -(#5176). This table holds them for everyone else: one row per (note, person it is -shared with) who has pinned, archived or moved it. The owner keeps the note's own -columns; a recipient with no row sees the note unpinned, unarchived, in the owner's -order. +Pin, archive and board order are personal. The owner's are columns on `notes`; this +table holds everyone else's, one row per (note, person it is shared with) who has +pinned, archived or moved it. A recipient with no row sees the note unpinned, +unarchived, in the owner's order. `sync_revision` is stamped by the same `ts_set_sync_revision()` trigger the notes and labels use (0015), so a recipient's change reaches their own devices on the one diff --git a/alembic/versions/0037_drop_unused_user_fields.py b/alembic/versions/0037_drop_unused_user_fields.py index e809209..28edd9f 100644 --- a/alembic/versions/0037_drop_unused_user_fields.py +++ b/alembic/versions/0037_drop_unused_user_fields.py @@ -4,12 +4,8 @@ Revision ID: 0037 Revises: 0036 Create Date: 2026-10-07 -Both came with the foundation (0001) for features that were never built (#5178). -Nothing ever set `email_verified`, so it was false for every account on the server -while the offline desktop reported it as true; nothing read or wrote `avatar_path`. -A field whose value means nothing is worse than no field: a later feature would -trust it. If address verification is ever built, it adds its own column, with a -migration that says what the existing accounts are. +Nothing set `email_verified` or read `avatar_path`, and a field whose value means +nothing would be trusted by a later feature. ## Downgrade diff --git a/alembic/versions/0038_drop_saved_filters.py b/alembic/versions/0038_drop_saved_filters.py index c595a28..7400250 100644 --- a/alembic/versions/0038_drop_saved_filters.py +++ b/alembic/versions/0038_drop_saved_filters.py @@ -4,9 +4,7 @@ Revision ID: 0038 Revises: 0037 Create Date: 2026-10-08 -Saved views were removed in #5180 (note 2897: recall first, organizing second). They -lived only on the web; the desktop kept its own set that never synced, and Android -had none. Tags in the drawer already give one-click recall. +Saved views are removed; tags in the drawer give one-click recall. ## Downgrade diff --git a/alembic/versions/0039_audit_events.py b/alembic/versions/0039_audit_events.py index 9737b79..33c2a5d 100644 --- a/alembic/versions/0039_audit_events.py +++ b/alembic/versions/0039_audit_events.py @@ -4,9 +4,8 @@ Revision ID: 0039 Revises: 0038 Create Date: 2026-10-08 -Credential events were only written to the app log, which is not queryable and is -gone at the container's log rotation (#2939 §5). This keeps them in the database for -`audit_retention_days`, for admins to read in Settings → Activity. +Keeps credential events in the database for `audit_retention_days`, for admins to +read in Settings → Activity; the app log isn't queryable and rotates away. ## Downgrade diff --git a/alembic/versions/0040_label_live_names.py b/alembic/versions/0040_label_live_names.py index f358b41..9665edd 100644 --- a/alembic/versions/0040_label_live_names.py +++ b/alembic/versions/0040_label_live_names.py @@ -4,11 +4,9 @@ Revision ID: 0040 Revises: 0039 Create Date: 2026-10-08 -Deleting a tag now leaves a tombstone row (`purged_at` set) so the change feed can -tell linked devices about it (#5382). Before, the web deleted the row outright and -devices never heard. A tombstone must not hold its name: creating `#grocery` again -after deleting it has to make a live tag, on the web and from a device alike. So -`(owner_id, name)` stays unique only where `purged_at IS NULL`. +Deleting a tag leaves a tombstone row (`purged_at` set) so the change feed tells +linked devices. A tombstone must not hold its name, so `(owner_id, name)` is unique +only where `purged_at IS NULL`. ## Downgrade diff --git a/pyproject.toml b/pyproject.toml index 6f82d67..c417b0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "inkwell" version = "0.2.0" -description = "Self-hosted personal thought-capture web app (FabledSword family)" +description = "A self-hosted web app for capturing and finding thoughts" requires-python = ">=3.12" dependencies = [ "quart>=0.19", diff --git a/src/inkwell/__init__.py b/src/inkwell/__init__.py index 599783b..d95c34d 100644 --- a/src/inkwell/__init__.py +++ b/src/inkwell/__init__.py @@ -1,16 +1,5 @@ -"""Inkwell — self-hosted personal thought-capture web app (FabledSword family).""" +"""Inkwell — a self-hosted web app for capturing and finding thoughts.""" -# PACKAGING METADATA, and nothing else. Not the version any running server reports. -# -# A built image carries APP_VERSION in the environment, derived from the server's -# own shipped file set (packaging/version.sh); `app.py` reads that and reports an -# explicit "unknown" when it is absent, so this string never reaches a user and -# bumping it changes nothing anybody sees. -# -# It exists because a Python package needs a version and "unknown" is not a legal -# one here. It used to double as app.py's fallback, which meant a server run from a -# checkout confidently reported `0.2.0` — a real-looking version naming no build -# that exists. Note 3127 §5 is why that matters more than it reads: with version -# tags gone, a build's self-report is the only answer to "which build is this?", -# and there is nothing left to catch it lying. +# Packaging metadata only. A running server reports APP_VERSION from the image, or +# "unknown"; this string is never shown and bumping it changes nothing. __version__ = "0.2.0" diff --git a/src/inkwell/accounts_api.py b/src/inkwell/accounts_api.py index 9cd3e07..fbd10bf 100644 --- a/src/inkwell/accounts_api.py +++ b/src/inkwell/accounts_api.py @@ -1,5 +1,5 @@ """Admin routes over the instance's accounts: list them, make a password reset link -for one (#5173), and read what has happened to them (the audit log, #2939 §5). +for one, and read what has happened to them (the audit log). What a reset link is, and how it is used, is in `password_resets.py`. """ diff --git a/src/inkwell/acl.py b/src/inkwell/acl.py index 04baca4..9cf7949 100644 --- a/src/inkwell/acl.py +++ b/src/inkwell/acl.py @@ -7,9 +7,9 @@ from sqlalchemy import ColumnElement, exists, or_, select from .models.group import GroupMember from .models.share import Share -# What a share grants (#5174). `edit` is the note's body and checklist; everything -# else about a note stays its owner's. Ordered weakest first, so the strongest of a -# person's grants is the one with the highest index. +# What a share grants. `edit` covers the note's body and checklist; everything else +# stays the owner's. Ordered weakest first, so a person's strongest grant has the +# highest index. VIEW = "view" EDIT = "edit" PERMISSIONS = (VIEW, EDIT) @@ -26,9 +26,8 @@ def visible_to_user( """Boolean SQL predicate: is this resource visible to ``user_id``? A resource is visible if the user OWNS it, OR it is shared DIRECTLY with the - user, OR it is shared with a GROUP the user belongs to. Scope every read (and - mutation) of shareable user data through this — never assume a single operator - (family rule 47). + user, OR it is shared with a GROUP the user belongs to. Scope every read and + mutation of shareable user data through this; the server is multi-user. With ``permission``, only shares at that level count: ``permission=EDIT`` is "may this user change it", which the owner always may. diff --git a/src/inkwell/app.py b/src/inkwell/app.py index 013a472..ad915af 100644 --- a/src/inkwell/app.py +++ b/src/inkwell/app.py @@ -41,13 +41,9 @@ from .settings_api import bp as settings_bp from .shares_api import bp as shares_bp from .sync import bp as sync_bp, protocol_advertisement -# Without this, `logger.info` from this package goes nowhere: hypercorn configures its -# own access/error loggers and leaves the root logger at WARNING, so the credential -# events in auth.py would be invisible in `docker compose logs` — which is exactly -# where they are read live. (Settings → Activity keeps most of them, `audit.py`.) -# -# `force=False` (the default) so a host that has already configured logging keeps its -# own setup; LOG_LEVEL lets an operator turn it up without a code change. +# Hypercorn leaves the root logger at WARNING, which would hide this package's INFO +# credential events from `docker compose logs`. A host that already configured +# logging keeps its own setup. logging.basicConfig( level=os.environ.get("INKWELL_LOG_LEVEL", "INFO").upper(), format="%(asctime)s %(levelname)s %(name)s %(message)s", @@ -65,10 +61,8 @@ class _AutoSecureSessionInterface(SecureCookieSessionInterface): """Mark the session cookie `Secure` whenever the request arrived over HTTPS — directly, or via a TLS-terminating reverse proxy that sets X-Forwarded-Proto. - Auto-detecting per request (rather than a fixed SESSION_COOKIE_SECURE flag) - hardens the cookie on HTTPS deployments without breaking a plain-HTTP install on - a trusted LAN, where a hard-forced Secure flag would stop the browser from ever - sending the cookie back — i.e. silently break login. No configuration required. + Per request rather than a fixed flag, because a forced Secure cookie is never sent + back over plain HTTP, which would break sign-in on a LAN install. """ def get_cookie_secure(self, app: Quart) -> bool: @@ -83,19 +77,8 @@ def create_app() -> Quart: # Ephemeral/env secret so the app (and DB-free unit tests) construct without a # database. before_serving swaps in the real, DB-persisted key before serving. app.secret_key = Config.secret_key_env() or secrets.token_urlsafe(48) - # The RUNNING build, or an explicit "unknown" — never the packaging fallback. - # - # This read `os.environ.get("APP_VERSION", __version__)`, so a server started - # from a checkout reported `0.2.0`: a real-looking version that names no build - # anybody could get. `__init__.py` already claimed the honest answer was - # "APP_VERSION being missing, which app.py already handles" — it did not, and a - # comment asserting a behaviour two files away from the code is how that stayed - # true-sounding for months. - # - # It matters more than it used to. Note 3127 §5 removed version tags, so this - # string is the only answer to "which build is this?" and nothing exists to - # contradict it when it is wrong. `__version__` stays where it belongs, as - # packaging metadata, which is the one place "unknown" is not a legal value. + # The running build, or "unknown". Never `__version__`: that names no build, and + # this string is the only answer to "which build is this?". app.config["APP_VERSION"] = os.environ.get("APP_VERSION") or "unknown" app.config["SESSION_COOKIE_HTTPONLY"] = True app.config["SESSION_COOKIE_SAMESITE"] = "Lax" @@ -130,14 +113,11 @@ def create_app() -> Quart: apply_session_ttl(app, await get_setting(db, "session_ttl_days")) except (ValueError, TypeError, KeyError): pass - # The security settings the throttle and the proxy trust read on hot - # paths. Cached rather than queried per request; until this runs they - # hold their registry defaults, which is the correct behaviour for a - # server that has not finished starting. + # Security settings read on hot paths, cached rather than queried per + # request. Until this runs they hold their registry defaults. await refresh_live(db) - # Credentials stored before sealing existed are sealed now (#5927). A data - # folder that can't be written leaves them as they are; it never stops the - # server starting. + # Seal any credentials still stored in the clear. A data folder that can't + # be written leaves them as they are; it never stops the server starting. try: if count := await seal_stored_secrets(db): await db.commit() @@ -145,11 +125,9 @@ def create_app() -> Quart: except SealError as exc: await db.rollback() logger.warning("stored credentials left unsealed: %s", exc) - # Expire old trash in the background (retention.py). One task per process is - # correct because the image serves with a single hypercorn worker (Dockerfile); - # if that ever gains `--workers`, this needs a lock so N workers don't each - # sweep. Duplicate sweeps would be harmless but wasteful — a purged row is - # skipped by `purged_at IS NULL` — so this is about load, not correctness. + # Expire old trash in the background (retention.py). One task per process, + # because the image runs one hypercorn worker; with several, each would sweep, + # which is wasteful but harmless. app.config["TRASH_SWEEPER"] = asyncio.create_task(run_sweeper()) # The daily backup (backup_job.py), on the same single-worker footing. It also # takes a Postgres advisory lock, so a second process could not double it. @@ -172,23 +150,16 @@ def create_app() -> Quart: None of these change how the app behaves for a legitimate caller; they narrow what a browser will do if something else goes wrong. - The CSP is the substantive one. `script-src 'self'` means that even if some - future path did manage to reflect user text into the page, the browser would - refuse to run it — the app has no inline scripts and no third-party scripts, - so nothing legitimate is given up. The exceptions are honest ones: - - `style-src 'unsafe-inline'` — Vue writes inline styles itself (`v-show` - toggling display, TransitionGroup's FLIP setting transforms). Inline - STYLE is not an execution primitive the way inline script is. - - `img-src https: http:` — link previews render the remote og:image of - whatever was linked, which is an arbitrary host by definition. Both - schemes, because a LAN install is served over http and would otherwise - lose every preview image; on an https instance the browser blocks the - http ones as mixed content anyway, so naming it concedes nothing. - - `blob:`/`data:` — attachment previews and the desktop's blob URI scheme. + The CSP allows only the app's own scripts; it has no inline or third-party + ones. The exceptions: + - `style-src 'unsafe-inline'`: Vue writes inline styles (v-show, FLIP + transitions). Inline style is not an execution primitive. + - `img-src https: http:`: link previews show the linked page's image, from + any host. http too, for LAN installs; an https page blocks those anyway. + - `blob:`/`data:`: attachment previews and the desktop's blob URIs. - `frame-ancestors 'none'` replaces the older X-Frame-Options and is what stops - the app being framed for clickjacking; `form-action 'self'` stops a form from - being pointed at another origin. + `frame-ancestors 'none'` prevents clickjacking; `form-action 'self'` keeps + forms from posting to another origin. """ response.headers.setdefault( "Content-Security-Policy", @@ -204,9 +175,7 @@ def create_app() -> Quart: "media-src 'self' blob:; " "connect-src 'self'", ) - # Content-type sniffing turns a file we said was text into whatever the bytes - # look like. The attachment route already sets this; every other response - # deserves it too. + # Stop the browser sniffing a response into a different content type. response.headers.setdefault("X-Content-Type-Options", "nosniff") # Note titles and label names end up in the URL of a search or a label lens, # and a full Referer would hand them to any site a link preview points at. @@ -216,14 +185,9 @@ def create_app() -> Quart: response.headers.setdefault( "Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()" ) - # HSTS only where the request already arrived over TLS — the same detection - # the session cookie uses. Sending it on a plain-HTTP LAN install would tell - # the browser to refuse the only scheme that install serves. - # - # Scoped to this host: no `includeSubDomains` and no `preload`, both of which - # commit domains this app does not own. A browser still remembers the policy - # for up to a year after the header stops being sent, which is the point of - # it — worth knowing before putting a hostname behind TLS temporarily. + # HSTS only on requests that arrived over TLS: on a plain-HTTP install it would + # lock the browser out. This host only, no includeSubDomains or preload. The + # browser keeps the policy for a year, even after the header stops. if is_https(): response.headers.setdefault("Strict-Transport-Security", "max-age=31536000") return response @@ -238,13 +202,12 @@ def create_app() -> Quart: async with session_scope() as db: data = await get_public_config(db) data["version"] = app.config["APP_VERSION"] - # The sync-protocol handshake (M10.6). A native client reads this BEFORE - # linking — while it still has no token and possibly no account — to decide - # whether it can talk to this server, and which optional features to offer. + # The sync-protocol handshake. A native client reads this before linking, with + # no token yet, to decide whether it can talk to this server and which + # optional features to offer. data.update(protocol_advertisement()) - # Which CLIENTS this server can hand out, if any, under `clients`. Absent - # rather than null when it has none, so the web UI hides a download instead - # of offering a button that 404s. + # The clients this server can hand out. Absent when it has none, so the web UI + # hides the download. data.update(client_advertisement()) return jsonify(data) diff --git a/src/inkwell/audit.py b/src/inkwell/audit.py index aa3f594..bac94bd 100644 --- a/src/inkwell/audit.py +++ b/src/inkwell/audit.py @@ -1,4 +1,4 @@ -"""The audit log: what happened to accounts, kept where an admin can read it (#2939 §5). +"""The audit log: what happened to accounts, kept where an admin can read it. Sign-ins, failed sign-ins, password changes and resets, devices linked and unlinked, invites made and revoked. Each is still written to the app log as it diff --git a/src/inkwell/auth.py b/src/inkwell/auth.py index 789ec27..6fb5168 100644 --- a/src/inkwell/auth.py +++ b/src/inkwell/auth.py @@ -32,30 +32,20 @@ from .storage import storage_limit, storage_used bp = Blueprint("auth", __name__, url_prefix="/api/auth") -# Every credential event goes to the app log, which is the live view of whether -# anyone is knocking: `docker compose logs`. Most also go to the audit log -# (`audit.py`), which keeps them for admins to read in Settings → Activity. -# -# The attempted email is included deliberately. It is the operator's own server, and -# "somebody failed a login" without saying against WHICH account tells you nothing you -# can act on. Passwords, obviously, never appear. +# Every credential event goes to the app log (`docker compose logs`); most also go to +# the audit log (Settings → Activity). The attempted email is logged so a failure can +# be acted on; passwords never are. logger = logging.getLogger(__name__) SESSION_KEY = "user_id" # The account's `session_epoch` when this session signed in. A password reset moves -# the account's epoch on, which is how it ends sessions it can't reach: they are -# signed cookies in other people's browsers. A cookie from before epochs existed has -# no key and reads as 0, the epoch every account started at. +# the epoch on, which ends sessions held as signed cookies in other browsers. A +# cookie without the key reads as 0, every account's starting epoch. EPOCH_KEY = "epoch" -# The first account can only be created this long after the server starts (family -# idea #5105, practice 8). Without it, a fresh server on a public address belongs to -# whoever reaches /register first, and a new DNS name is found within minutes. -# -# A constant, not a Setting: there is no admin yet to change one. Anyone who misses -# the window restarts the server, which opens it again; once an account exists the -# window no longer matters. Measured from `create_app` (STARTED_AT), so each start -# counts afresh. +# How long after start the first account may be created. Otherwise a fresh server on +# a public address belongs to whoever reaches /register first. A constant, since +# there is no admin yet to change a setting; restarting the server reopens it. SETUP_WINDOW_S = 30 * 60 SETUP_CLOSED = ( "this server's setup window has closed. Restart the server, then create the " @@ -136,9 +126,8 @@ def login_required(fn): """Guard: 401 unless authenticated. Accepts a web session cookie OR a device bearer token (native clients). Sets g.user_id for the view. - The session path reads one column by primary key. It used to read nothing, which - left no way to end a session short of its expiry: not for a reset password, and - not for a deleted account (#5173).""" + The session path reads the account's epoch, so a password reset or a deleted + account ends the session before its expiry.""" @functools.wraps(fn) async def wrapper(*args, **kwargs): @@ -182,10 +171,8 @@ def require_admin(fn): def _throttled(retry_after: int): """The 429 every throttled credential route returns. - Deliberately says nothing about WHICH limit was hit or how many attempts are - left — that would tell someone probing whether the email they guessed exists. - `Retry-After` is standard and is the one thing a legitimate client (or person) - genuinely needs. + Says nothing about which limit was hit or what is left, which would reveal + whether a guessed email exists. Only `Retry-After`. """ logger.warning("throttled credential attempt from=%s retry_after=%ss", client_address(), retry_after) return too_many("too many attempts — try again shortly", retry_after) @@ -194,10 +181,8 @@ def _throttled(retry_after: int): def _sign_in_block(email: str) -> int | None: """Seconds to wait before this sign-in may be attempted, or None to proceed. - Checked BEFORE the password is verified, so a throttled attempt costs no bcrypt - — which is the other half of what this protects: hashing is deliberately slow, - and an unauthenticated caller who can trigger it without limit has a CPU - exhaustion primitive, not just a guessing one. + Checked before the password is hashed, so a throttled attempt costs no bcrypt: + unlimited hashing would let an anonymous caller exhaust the CPU. """ address = client_address() waits = [ @@ -237,9 +222,8 @@ async def register(): if not display_name: display_name = email.split("@", 1)[0] - # Counted by attempt rather than by failure: a rejected registration still cost a - # round trip and a uniqueness check, and on an instance with signups open the - # thing worth bounding is how fast accounts can appear at all. + # Counted per attempt, not per failure: with signups open, what is bounded is how + # fast accounts can appear. wait = register_by_address.retry_after(client_address()) if wait is not None: return _throttled(wait) @@ -252,11 +236,10 @@ async def register(): logger.warning("registration refused (setup window closed) email=%s from=%s", email, client_address()) await audit.record(audit.REGISTRATION_REFUSED, email=email, detail="setup window closed") return json_error(SETUP_CLOSED, 403) - # The first account bootstraps the admin and is allowed, inside the setup - # window above, even when registration is otherwise closed. After that, an invite lets one person in - # while it is closed (#5172). One that was offered is redeemed even while - # registration is open, so the list still says who used it, and one that - # doesn't hold is refused rather than ignored. + # The first account becomes the admin and is allowed inside the setup window + # even with registration closed. After that, an invite lets one person in while + # it is closed. An offered invite is redeemed even when registration is open, so + # the list says who used it, and an invalid one is refused, not ignored. invite_id = None if not is_first and invite: invite_id = await redeem(db, invite, email) @@ -281,17 +264,9 @@ async def register(): ) db.add(user) if is_first: - # Registration CLOSES the moment the instance has an owner. - # - # Not "defaults closed" — that would still need the first person to get in - # somehow. Closed as a CONSEQUENCE of the admin account existing, which is - # the only formulation with no open window in it. Leaving the setting on - # meant the gap between "my account exists" and "I remembered to turn it - # off in Settings" was wide open, and on a public host that gap is the - # entire exposure — it starts the moment DNS resolves. - # - # An admin who wants a second person sends them an invite (Settings → - # Invites), which works with registration closed. + # Registration closes as soon as the admin account exists, so there is no + # window between "my account exists" and remembering to close it. More + # people join by invite (Settings → Invites). await set_settings(db, {"allow_registration": False}) if invite_id is not None: await db.flush() @@ -312,9 +287,8 @@ async def _check_credentials(db, email: str, password: str, route: str) -> User """The account these credentials open, or None. Counts the failure against the throttle, logs it under `route`, and clears the account's history on success. - A missing account still pays for a hash. Without that, "no such account" returns - in microseconds while a wrong password takes bcrypt's deliberate ~100ms, and the - difference is a reliable oracle for which emails have accounts here. + A missing account still pays for a hash, so response time doesn't reveal which + emails have accounts. """ user = await db.scalar(select(User).where(User.email == email)) if user is None or not user.password_hash: @@ -378,7 +352,7 @@ async def me(): @login_required async def storage(): """How much attachment storage this account uses, and its limit (None when it - has none). For the Account page (#2939 §4).""" + has none). For the Account page.""" async with session_scope() as db: return jsonify( { @@ -394,7 +368,7 @@ FORGOT_SENT = "If an account uses that address, a reset link is on its way. It w @bp.post("/forgot-password") async def forgot_password(): - """Email a reset link to whoever owns this address (#5266). + """Email a reset link to whoever owns this address. Says the same thing, at the same speed, whether or not the address has an account: the link is made and sent off the request. Throttled per visitor address @@ -428,7 +402,7 @@ async def forgot_password(): @bp.post("/reset-password") async def reset_password(): - """Set a new password with a reset link an admin made (#5173), and sign the + """Set a new password with a reset link, and sign the account out everywhere else: every other session, and every linked device. The browser that used the link is signed in under the new password, since holding @@ -497,8 +471,8 @@ def _stay_signed_in(user: User) -> None: @login_required async def change_password(): """Change the account's password, and sign it out everywhere else: every other - web session, and every linked device (family idea #5105, practice 4). The - browser that changed it stays signed in. + web session, and every linked device. The browser that changed it stays signed + in. The current password is required, so a session left open on someone else's screen can't take the account over. A wrong one counts against the sign-in @@ -536,8 +510,8 @@ async def change_password(): @login_required async def sign_out_elsewhere(): """Sign the account out of every other browser and unlink every device, keeping - this one signed in (family idea #5105, practice 4). For a session or a device - the person doesn't recognise, or one on a machine they no longer have.""" + this one signed in. For a session or device the person doesn't recognise, or a + machine they no longer have.""" async with session_scope() as db: user = await db.get(User, g.user_id) if user is None: @@ -551,7 +525,7 @@ async def sign_out_elsewhere(): return jsonify({"ok": True, "devices_unlinked": unlinked}) -# --- Device (bearer) tokens for native clients — M8 sync hub --- +# --- Device (bearer) tokens for native clients --- async def _email_of(db) -> str | None: @@ -592,9 +566,8 @@ async def device_login(): if not email or not password: return json_error("email and password are required", 400) - # Same budget as the web sign-in, and the SAME counters — this route hands out a - # long-lived bearer token, so leaving it unthrottled would just move the guessing - # here from /login. + # The same counters as the web sign-in: this route hands out a long-lived token, + # so an unthrottled one would just move the guessing here. wait = _sign_in_block(email) if wait is not None: return _throttled(wait) @@ -643,21 +616,15 @@ async def list_devices(): async def revoke_own_device(): """Revoke the device token presented on THIS request. - What makes "unlink" on a native client actually stop its access. The client - can't use the id-keyed route below, because it doesn't reliably know its own - device id: a token pasted from the web app arrives without one, and `/me` - describes the user, not the device row. Identifying the row by the presented - token needs nothing the caller doesn't already hold, so it works for both ways - a client can be linked. + How "unlink" on a native client ends its access. A client doesn't reliably know + its own device id (a token pasted from the web app arrives without one), so the + row is found by the token it presents. - Declared above the `` rule for reading order only — Werkzeug ranks a - static rule ahead of a converter regardless of registration order. + Werkzeug ranks this static rule ahead of `` whatever the order. """ token = _bearer_token() if token is None: - # A session-cookie caller holds no device token, so "revoke the one I'm - # using" is meaningless rather than merely unauthorized. The web app - # revokes by id. + # A session caller holds no device token to revoke; the web app revokes by id. return json_error("no device token was presented", 400) # Owner-scoped like every other device route. The hash already pins a single # row; the guarantee shouldn't rest on one column. diff --git a/src/inkwell/backup_api.py b/src/inkwell/backup_api.py index 12dec84..d971c35 100644 --- a/src/inkwell/backup_api.py +++ b/src/inkwell/backup_api.py @@ -1,4 +1,4 @@ -"""Admin routes for Settings → Backups (#5920): where backups stand, Back up now, and +"""Admin routes for Settings → Backups: where backups stand, Back up now, and Test destination. What a backup holds and when it runs is in `backup_job`.""" from __future__ import annotations diff --git a/src/inkwell/backup_data.py b/src/inkwell/backup_data.py index 0b26a19..da7871b 100644 --- a/src/inkwell/backup_data.py +++ b/src/inkwell/backup_data.py @@ -1,4 +1,4 @@ -"""What goes into a backup, and putting it back (#5918, #5921). +"""What goes into a backup, and putting it back. Every table the models define is exported, so a table added later is backed up without anyone remembering to add it here. The few that are left out are named in @@ -74,9 +74,8 @@ def _decoder(column): async def export_contents(db) -> Contents: """Every exported table's rows in primary-key order, and the attachment files. - Primary-key order is load-bearing: Postgres returns rows in heap order, and an - UPDATE moves a row to the end of the heap, so without ORDER BY an unchanged table - would come out shuffled after any edit and nothing would deduplicate.""" + Primary-key order keeps an unchanged table byte-identical between runs: heap order + shifts whenever a row is updated, and then nothing deduplicates.""" tables: dict[str, list[dict]] = {} for table in exported_tables(): query = select(table).order_by(*table.primary_key.columns) @@ -143,7 +142,7 @@ async def restore_tables(db, members: Iterable[Member]) -> dict[str, int]: for member in members: if member.kind == "table": if member.name not in by_name: - # A table this version no longer has. Nothing to put it in. + # A table this version doesn't have. Nothing to put it in. logger.warning("restore: skipping table %s, which this version doesn't have", member.name) member.fileobj.read() continue diff --git a/src/inkwell/backup_destination.py b/src/inkwell/backup_destination.py index dc3e126..e1bb7c7 100644 --- a/src/inkwell/backup_destination.py +++ b/src/inkwell/backup_destination.py @@ -1,10 +1,9 @@ -"""Where the daily backup goes, and getting a backup file there (#5919). +"""Where the daily backup goes, and getting a backup file there. Two kinds of destination, both set in Settings → Backups: -- **folder**: a directory inside the container, usually a mounted share. A backup - service that watches the share (Fabled Archives' folder mode, say) or a person - takes the files from there. +- **folder**: a directory inside the container, usually a mounted share, from which + a backup service or a person takes the files. - **s3**: any S3-compatible bucket, addressed by path. Inkwell only puts and lists, so a key that may only add objects is enough. diff --git a/src/inkwell/backup_file.py b/src/inkwell/backup_file.py index 07fea9b..7123323 100644 --- a/src/inkwell/backup_file.py +++ b/src/inkwell/backup_file.py @@ -1,4 +1,4 @@ -"""The full backup as one file: an uncompressed tar per run (#5918). +"""The full backup as one file: an uncompressed tar per run. A scheduled backup lands somewhere that keeps a history of them: a folder a backup service watches, or an S3 bucket. From there it is usually copied off-site by a @@ -25,9 +25,6 @@ that whatever did not change between two runs is the same bytes both times. can thin the history on its own schedule. - **Written atomically** through a dot-named `.part` file, which a folder watcher ignores until the rename. - -The shape is Scribe's (snippet #5875), with attachment files added: in a notes app -the files are as much the user's data as the rows. """ from __future__ import annotations diff --git a/src/inkwell/backup_job.py b/src/inkwell/backup_job.py index 37bcae1..0b2e2ba 100644 --- a/src/inkwell/backup_job.py +++ b/src/inkwell/backup_job.py @@ -1,12 +1,12 @@ -"""The daily backup: when it runs, running it, and what happened (#5920). +"""The daily backup: when it runs, running it, and what happened. - **When.** A tick every few minutes asks whether a backup is due: switched on, and no good backup since the most recent scheduled hour. A server that was down at that hour backs up as soon as it is back. Switching backups on runs the first one within a tick, which is also the quickest proof the destination works. - **Failure is "not yet".** A failed run is tried again after an hour, then two, - then four, then waits for the next day's hour, rather than at once or never - (rule 193). The destination may simply be restarting. + then four, then waits for the next day's hour, rather than at once or never. The + destination may simply be restarting. - **One at a time.** An in-process lock, plus a Postgres advisory lock in case a second server process is ever started against the same database. Back up now while one is running is answered 409. diff --git a/src/inkwell/backup_s3.py b/src/inkwell/backup_s3.py index da330a1..0ffac41 100644 --- a/src/inkwell/backup_s3.py +++ b/src/inkwell/backup_s3.py @@ -1,14 +1,12 @@ """A small S3 client for sending backups: AWS Signature Version 4, path-style addressing (endpoint/bucket/key), and the two calls a backup needs. -Ported from FabledSteward's and FabledScribe's backup clients (Scribe snippet #5679), -which are tested against the same AWS examples as tests/test_backup_s3.py. Those use -httpx. This one uses the standard library's `http.client`, on a worker thread, -because Inkwell has no HTTP client dependency and two calls don't justify adding one. +Uses the standard library's `http.client` on a worker thread, since two calls don't +justify an HTTP client dependency. The signing is checked against AWS's published +examples (tests/test_backup_s3.py). -Inkwell puts and lists; it never deletes. The bucket keeps the history and thins it -by its own policy, so a key that may only add objects is enough. It is also the safer -key to give a service: a stolen one cannot wipe the history it writes into. +It puts and lists, never deletes, so an add-only key is enough; a stolen one can't +wipe the history the bucket keeps. """ from __future__ import annotations @@ -128,15 +126,15 @@ def _says(status: int, reason: str, body: bytes) -> str: } said = hints.get(code) or message or reason if status == 404 and not code: - # Seen live: an address without its port lands on port 80 and gets a plain - # 404 from whatever answers there (Scribe #5871). + # An address without its port lands on port 80 and gets a plain 404 from + # whatever answers there. said = "nothing answered as S3 at that address; check the port" return f"HTTP {status}{f' {code}' if code else ''}: {said}." class _Throttled: """A file wrapper that reads no faster than `limit` bytes a second, so an upload - leaves room on the connection for everything else (rule 193).""" + leaves room on the connection for everything else.""" def __init__(self, f: IO[bytes], limit: int): self._f = f diff --git a/src/inkwell/client_dist.py b/src/inkwell/client_dist.py index 1587b80..d5a19ab 100644 --- a/src/inkwell/client_dist.py +++ b/src/inkwell/client_dist.py @@ -1,73 +1,33 @@ """The server hands out the clients it is in step with. -## Why the server, and not a release page - -The Fabled-Git instance is private (issue 2091), so `install.sh` already cannot -fetch for anyone but the operator — and a self-hoster should not need an account -on someone else's forge to get the app for their own notes. The server they -already trust with the notes is the obvious place to get the client from. - -It also keeps the two in step by construction. Client and server already -negotiate a sync protocol version before linking, so a server that also serves -the client cannot hand out a phone it is unable to talk to. - -That argument was never Android-specific, which is why this module now serves a -TABLE of platforms rather than the one it was written for. +A self-hoster gets the apps from the server that already holds their notes, with no +account anywhere else. Client and server negotiate a sync protocol before linking, +so a server never hands out a client it can't talk to. ## Where the files come from -Two places, checked in that order, per platform: +Per platform, the first of: -1. `DATA_DIR/client/` — the mounted volume that already holds attachments. An - operator who wants a SPECIFIC build drops it there and it wins. -2. the copy baked into the image at build time — CI fetches the newest published - build of each client into every image, so `:dev` and `:latest` both carry a - full set and `docker compose pull` delivers new ones with nothing copied by - hand. +1. `DATA_DIR/client/`, on the mounted volume: a build dropped there wins. +2. The copy baked into the image, inside the package rather than under DATA_DIR, + which a volume mount would hide. CI bakes the newest build of each client into + every image. -The precedence is the point: the image is the default, and a person who wants to -override it should not have to fight it. The baked copy sits inside the package -rather than under DATA_DIR because DATA_DIR is a volume mount, and anything the -image wrote there would be hidden the moment one is attached. - -**Precedence is decided per platform, not for the set.** A drop-in `.deb` does -not shadow the baked APK. The alternative — first directory holding anything -wins — would mean replacing one client silently retracts the other four. +Precedence is per platform: a drop-in `.deb` doesn't shadow the baked APK. ## What a platform needs on disk -Two files, and both must be present: - -- the artifact, at a FIXED name (`inkwell.deb`, not - `Inkwell_2026.08.30.0307_amd64.deb`) -- its sidecar, `{version_name, version_code, size, sha256}` - -**Fixed names, and the version only in the sidecar.** A version-stamped filename -would force this module to glob, and a glob over a directory an operator can drop -files into is how you serve the older of two builds — `write-manifest.sh` carries -a comment about exactly that, from the time it advertised a new version while -pointing at an old binary. - -The sidecar exists because a version is not reliably readable from the artifact: -an APK keeps it in a binary AXML manifest Python cannot parse without the Android -build tools, and a `.deb` or an AppImage would each need a different unpacker. CI -writes the sidecar where the real value is already known. - -The AppImage needs a THIRD file, `inkwell.AppImage.sig`. That is the minisign -signature the desktop updater verifies before replacing the running binary, and a -bundle that cannot be verified cannot be offered as an update — so a missing -signature makes the AppImage absent rather than merely unsigned. +- the artifact, at a fixed name (`inkwell.deb`). A version in the filename would + mean globbing, and a glob can pick the older of two builds. +- its sidecar, `{version_name, version_code, size, sha256}`, written by CI, because + a version can't be read reliably from an APK, a `.deb` or an AppImage. +- for the AppImage, `inkwell.AppImage.sig`: the signature the desktop updater + verifies. Without it the AppImage counts as absent. ## Absence is normal -A server with no client for a platform advertises none, and the web UI hides that -download rather than offering a button that 404s. Same for a mismatched pair: if -the sidecar's recorded size does not match the file on disk, the two did not -arrive together, and the server says it has nothing rather than serving one build -while describing another. - -This is the ONLY state available before a platform's first build has ever -published, so it is an ordinary answer and never an error. +A platform with no build, or whose sidecar size doesn't match the file, is simply +not advertised, and the web UI hides that download. """ from __future__ import annotations @@ -90,29 +50,17 @@ class Platform: """One installable client, and where its files sit.""" id: str - # What a person calls it. Named for the DISTRO rather than the package format - # ("Debian / Ubuntu", not ".deb") — someone knows which system they run and - # does not necessarily know which packaging it uses. + # What a person calls it: the system ("Debian / Ubuntu"), not the package format. label: str artifact: str sidecar: str mimetype: str - # An updater-verifiable bundle: `.sig` must be present too, and its - # contents are published with the metadata. Only the AppImage, because it is - # the only bundle that can replace itself in place — a package-manager install - # cannot, by design (see the desktop's update.rs). + # An updater-verifiable bundle: `.sig` must be present, and is published + # with the metadata. Only the AppImage can replace itself in place. signed: bool = False - # Whether this platform's ordering key is an INTEGER. - # - # `version_code` is "whatever this platform's comparator reads", and that is not - # one type. Android's is an int because Android's own install gate compares one, - # and it must stay a JSON number — `ClientRelease` in core/src/sync/client.rs - # declares it `i64` and a string would fail to deserialize on every phone in the - # field. The desktop's is Tauri's semver key, `1.0.`, which is the value - # its updater compares and is not an integer at all. - # - # Coercing everything to int was inherited from the days when Android was the - # only platform, and would have rejected every desktop sidecar written. + # Whether this platform's ordering key (`version_code`) is an integer. Android's + # is, and must stay a JSON number: core/src/sync/client.rs reads it as `i64`. The + # desktop's is Tauri's semver key, `1.0.`. code_is_int: bool = True @property @@ -124,17 +72,14 @@ class Platform: return f"/api/client/{self.id}/download" -# ONE definition of what this server can hand out. Every route, the /api/config -# advertisement and the tests all read this table; adding a platform is adding a -# row. +# What this server can hand out. Every route, /api/config and the tests read this +# table; adding a platform is adding a row. PLATFORMS: tuple[Platform, ...] = ( Platform( id="android", label="Android", - # UNCHANGED, and it must stay unchanged: the Android lane publishes these - # exact names, CI bakes them in under them, and clients in the field poll - # `/api/client/android`. Renaming them to match the pattern below would - # buy tidiness and strand every installed phone. + # Fixed: installed phones poll `/api/client/android`, and the Android lane + # publishes these exact names. artifact="inkwell.apk", sidecar="inkwell-android.json", mimetype="application/vnd.android.package-archive", @@ -160,9 +105,7 @@ PLATFORMS: tuple[Platform, ...] = ( label="Other Linux (AppImage)", artifact="inkwell.AppImage", sidecar="inkwell-linux-appimage.json", - # No registered type for an AppImage, and guessing one buys nothing: it is - # served as an attachment either way, and octet-stream is the answer that - # cannot be wrong. + # AppImage has no registered type; it is served as an attachment either way. mimetype="application/octet-stream", signed=True, code_is_int=False, @@ -189,10 +132,8 @@ bp = Blueprint("client_dist", __name__) def _read(root: Path, platform: Platform) -> dict | None: """One platform's build in one directory, or None. - Never raises. A missing directory, an unreadable sidecar, malformed JSON, a - sidecar that describes a different file and — for a signed bundle — a missing - signature are all the same answer to the only question being asked, "is there - a client here I can honestly offer?", and that answer is no. + Never raises. A missing directory, an unreadable or malformed sidecar, a sidecar + describing a different file, or a missing signature all mean "no client here". """ try: size = (root / platform.artifact).stat().st_size @@ -211,9 +152,8 @@ def _read(root: Path, platform: Platform) -> dict | None: # not a version, and it would render as a blank on the download card. return None - # The pair has to describe one build. A sidecar left behind by a previous - # release would otherwise advertise a version this server cannot serve, and the - # client would download something other than what it was promised. + # The pair must describe one build, or a stale sidecar would advertise a version + # the file isn't. if recorded != size: return None @@ -225,22 +165,17 @@ def _read(root: Path, platform: Platform) -> dict | None: # sorts like one, which is not how "is this newer" works. "version_code": code, "size": size, - # Computed by CI over the same bytes it uploaded, so a client can tell a - # truncated download from a complete one BEFORE handing it to an installer. - # Not a trust anchor — the signature is that. + # Lets a client spot a truncated download before installing it. Not a trust + # anchor; the signature is. "sha256": digest, - # A PATH, never an absolute URL: the client joins it to the base it is - # already linked to, so a compromised or misconfigured server cannot - # redirect the download somewhere else. `core/src/sync/client.rs` relies on - # this and says so. + # A path, never an absolute URL: the client joins it to the server it is + # linked to, so a misconfigured server can't redirect the download. "url": platform.download_path, } if platform.signed: - # The signature travels WITH the metadata rather than behind its own route. - # It is ~100 bytes, it is public wherever these bundles are published, and - # the updater needs the version and the signature in the same breath — one - # request that cannot return a signature belonging to a different build. + # The signature travels with the metadata, so one response can't pair a + # version with another build's signature. try: sig = (root / platform.signature).read_text(encoding="utf-8").strip() except OSError: @@ -255,14 +190,11 @@ def _read(root: Path, platform: Platform) -> dict | None: def _resolve(platform: Platform) -> tuple[Path, dict] | None: """Which directory this server serves a platform from, and what is in it. - The operator's drop-in beats the baked copy — someone who deliberately put a - build on the volume wants that build, older or not. A directory holding a - broken or half-copied pair does NOT shadow the image: it simply is not a - client, so the search moves on. + A drop-in on the volume beats the baked copy, older or not. A broken or + half-copied pair there doesn't shadow the image; the search moves on. """ - # Both wrapped in Path(), not just the first. The asymmetry was arbitrary and it - # fails quietly: `_read` catches the TypeError a str `/` str raises and reports - # "no client here", so a directory that is perfectly fine reads as empty. + # Both must be Paths: `_read` swallows the TypeError of str / str and would report + # a good directory as empty. for root in (Path(Config.client_root()), Path(BAKED_ROOT)): release = _read(root, platform) if release is not None: @@ -282,9 +214,7 @@ def release(platform_id: str) -> dict | None: def releases() -> dict[str, dict]: """Every client this server can hand out, keyed by platform id. - Platforms it holds nothing for are ABSENT rather than present-and-null, so a - caller can test for the key instead of distinguishing "no build" from "a - server that never had this platform". + A platform with no build is absent, not null. """ found = {p.id: _resolve(p) for p in PLATFORMS} return {pid: r[1] for pid, r in found.items() if r is not None} @@ -295,9 +225,7 @@ def advertisement() -> dict: whole table, which the web UI renders the downloads section from. Phones ask `/api/client/android` for their own update instead. - ABSENT rather than empty when the server holds none, so a client testing for the - key gets one unambiguous answer instead of having to distinguish "no client" from - "an old server that never had this field". + Absent, not empty, when the server holds none. """ found = releases() return {"clients": found} if found else {} @@ -305,11 +233,7 @@ def advertisement() -> dict: @bp.get("/api/client") async def client_index(): - """Everything this server holds, in one request. - - The downloads UI needs all five to decide what to lead with, and five requests - to answer one question is five chances to render half a page. - """ + """Everything this server holds, in one request, for the downloads UI.""" return jsonify({"clients": releases()}) @@ -317,12 +241,8 @@ async def client_index(): async def client_metadata(platform_id: str): """Version and digest without the payload. What an updater polls. - Public, because a client has to be able to ask "is there something newer?" - cheaply — before it has a token, in the case of a first pairing. - - An unknown platform and a platform with no build both 404. They are the same - answer to the caller ("not here"), and distinguishing them would only tell an - unauthenticated stranger which platforms this build of the server knows about. + Public, so a client can ask "is there something newer?" before it has a token. + An unknown platform and one with no build both 404. """ found = release(platform_id) if found is None: @@ -334,25 +254,17 @@ async def client_metadata(platform_id: str): async def client_update_manifest(platform_id: str): """The same build, in the shape the desktop's in-app updater reads. - Tauri's updater takes a manifest, not this module's metadata: `version` is the - ordering key it compares (the desktop's `1.0.`, i.e. `version_code`), - and `url` + `signature` at the top level are its single-build form. So this is a - translation of `release()`, never a second description of the build. + A translation of `release()` into Tauri's single-build manifest: `version` is + the desktop's `version_code`, with `url` and `signature` at the top level. - Only for a SIGNED platform. The updater verifies the signature against the key - baked into the app before it replaces anything, so a server mirrors builds here - and cannot sign them: the trust anchor does not move with the feed. A platform - with no signature has no manifest, and a server holding no AppImage 404s, which - the desktop reports as "this server has no update to offer", never as "up to - date". + Signed platforms only. The updater checks the signature against the key built + into the app, so a server can mirror builds but not sign them. No AppImage: 404, + which the desktop shows as "no update to offer", not "up to date". - `url` must be absolute for the updater, which is the one place this module - hands out more than a path. It is built from the address THIS request arrived - on, not from Settings, because the updater sends the same Authorization header - to it that it sent here: the token goes back to the host that was asked, and - nowhere else. + `url` is absolute, built from the address this request arrived on rather than + Settings, because the updater sends the same Authorization header there. - Public, like the metadata above. The bytes `url` names are not. + Public; the bytes `url` names are not. """ platform = BY_ID.get(platform_id) if platform is None or not platform.signed: @@ -376,13 +288,9 @@ async def client_update_manifest(platform_id: str): async def client_download(platform_id: str): """The bytes themselves. - Authenticated — by session cookie from a browser, or by device bearer token - from a client updating itself; `login_required` accepts either. The metadata - above is public; the bytes are not for anyone who can reach the port. - - Throttled per account (Settings → Security), because each answer is tens of - megabytes: one account or a leaked device token looping here could fill the - server's uplink (family idea #5103, practice 6). + Needs a session or a device token. Throttled per account (Settings → Security), + because each answer is tens of megabytes and a looping client could fill the + uplink. """ platform = BY_ID.get(platform_id) if platform is None: @@ -406,23 +314,16 @@ async def client_download(platform_id: str): async def send_artifact(root: Path, platform: Platform, found: dict) -> Response: """The artifact, with its sha256 as the ETag and Range honoured against it. - Quart's own ETag is the file's mtime, size and path. That changes when a - container restarts on the same bytes, so a phone resuming a download across a - redeploy would be told its partial copy is stale. It also says nothing about - content. The sidecar's sha256 is the identity the client already checks, so - `If-Range` is answered against the bytes themselves (family idea #5103, - practice 6). - - So Quart's ETag and conditional handling are both turned off, and the - conditional pass runs here after the right ETag is set. Run before it, Range - and If-None-Match would be judged against the wrong one. + Quart's ETag is mtime, size and path, which changes when a container restarts on + the same bytes, so a resumed download would be told its partial copy is stale. + Quart's ETag and conditional handling are off, and the conditional pass runs here + after the sha256 ETag is set. """ response = await send_from_directory( root, platform.artifact, mimetype=platform.mimetype, add_etags=False, conditional=False ) response.set_etag(found["sha256"]) - # Only behind a login, so no shared cache may keep a copy (family idea #5105, - # practice 11). Quart marks every file it sends `public`. + # Behind a login, so no shared cache may keep a copy. Quart marks files `public`. response.cache_control.public = False response.cache_control.private = True # Without this some browsers try to render it, and Android's download handler diff --git a/src/inkwell/colors.py b/src/inkwell/colors.py index 96fa9fd..3b914a0 100644 --- a/src/inkwell/colors.py +++ b/src/inkwell/colors.py @@ -1,15 +1,8 @@ from __future__ import annotations -# The colour palette, and the one place that clamps to it. -# -# It lived on `models/note.py` until M315, when a note stopped having a colour. A -# palette defined on the model that lost one would be a standing invitation to put the -# column back; here it reads as what it now is — a LABEL's vocabulary, shared with the -# import path that still names a colour. -# -# Keys, not tints. The actual colours live in each client (frontend/src/notes/colors.ts -# and NoteTint.kt), so they can be retuned without a schema migration — which M315 spent -# two steps doing. +# The label colour palette, and the one place that clamps to it. Keys, not tints: the +# colours live in each client (frontend/src/notes/colors.ts, NoteTint.kt), so they can +# be retuned without a migration. NOTE_COLORS = { "default", "red", @@ -29,8 +22,6 @@ __all__ = ["NOTE_COLORS", "normalize_color"] def normalize_color(color: object) -> str: """Return `color` if it's a known palette key, else the default. - `default` is no longer something anybody can CHOOSE — nothing offers a colour - picker since M315 — but it is still where unrecognised input has to land, so this - fallback is unreachable by choice rather than dead. + Nothing offers `default` as a choice; it is where unrecognised input lands. """ return color if color in NOTE_COLORS else "default" diff --git a/src/inkwell/common.py b/src/inkwell/common.py index b6d72ad..7d86add 100644 --- a/src/inkwell/common.py +++ b/src/inkwell/common.py @@ -3,9 +3,7 @@ from __future__ import annotations import asyncio from datetime import datetime, timedelta -# Small, dependency-free value coercions shared across the blueprints. Kept in one -# place so the "parse an ISO date" / "is this flag truthy" logic has a single -# definition instead of a near-identical copy per module. +# Small value coercions shared across the blueprints. def iso(dt: datetime | None) -> str | None: @@ -65,10 +63,8 @@ def detach(running: set[asyncio.Task], coro) -> asyncio.Task: def expired_before(now: datetime, retention_days: int) -> datetime | None: """The cutoff: anything older than this has expired. `None` = retention is off. - Kept separate from the query so the window arithmetic — including the two ways - to say "never" (0 and negative, the latter reachable by typing a stray minus in - Settings) — is testable without a database. Trash and the audit log both expire - through it. + 0 and negative both mean "never". Separate from the query so it is testable + without a database. Trash and the audit log both expire through it. """ if retention_days <= 0: return None diff --git a/src/inkwell/config.py b/src/inkwell/config.py index ddc1471..08d692a 100644 --- a/src/inkwell/config.py +++ b/src/inkwell/config.py @@ -7,19 +7,14 @@ from pathlib import Path class Config: """Bootstrap configuration. - For a basic install, ``INKWELL_DATABASE_URL`` is the ONLY required env var — - every other tunable lives in the DB-backed Settings UI (rule 25). The only other - env var is an optional "break-glass" item: + ``INKWELL_DATABASE_URL`` is the only required env var; everything else is in + the Settings UI, stored in the database. Optional: - - ``INKWELL_SECRET_KEY`` — optional override for the cookie-signing secret. - If unset, a key is generated and persisted in the DB (see - ``inkwell.settings.load_or_create_secret_key``), so sessions survive - restarts with no volume required. + - ``INKWELL_SECRET_KEY`` overrides the cookie-signing secret. Unset, a key is + generated and stored in the database, so sessions survive restarts. - Uploaded media lives under ``DATA_DIR`` — a fixed, authoritative path - (``/var/inkwell``), intentionally NOT configurable (a mutable data path only - invites breakage). Mount a volume there if you want uploads to persist across - container recreation; a text-notes-only install never writes to it. + Uploads live under ``DATA_DIR`` (``/var/inkwell``), which is fixed. Mount a + volume there to keep them across container recreation. """ DATA_DIR = "/var/inkwell" @@ -39,15 +34,11 @@ class Config: @classmethod def client_root(cls) -> Path: - """Where an operator DROPS IN clients for this server to hand out. + """Where client builds can be dropped in to override the image's own. - One directory for every platform; `client_dist.py` picks files out of it by - name. Under DATA_DIR because it is a mounted volume: a build placed here - survives container recreation, and it beats the copy baked into the image, - which is the whole point of the directory existing. - - Empty is the ordinary case — the image ships its own set and most operators - never touch this. + One directory for every platform; `client_dist.py` picks files by name. On + the data volume, so a build placed here survives container recreation. + Usually empty. """ return Path(cls.DATA_DIR) / "client" diff --git a/src/inkwell/dbwait.py b/src/inkwell/dbwait.py index 59ef140..f61de0b 100644 --- a/src/inkwell/dbwait.py +++ b/src/inkwell/dbwait.py @@ -24,10 +24,8 @@ async def _probe(url: str) -> None: async def wait_for_db(attempts: int = ATTEMPTS, delay: float = DELAY_SECONDS) -> bool: """Poll the database until it accepts a connection, up to `attempts` tries. - Startup runs `alembic upgrade head` as the container's first DB touch, so a - database that is a moment slow to accept connections would otherwise crash the - container. This gives it a short window to come up, verifying once per `delay` - seconds, instead of failing on the first missed connection. + Runs before `alembic upgrade head`, so a database that is slow to come up delays + the start instead of failing it. """ url = Config.DATABASE_URL for attempt in range(1, attempts + 1): diff --git a/src/inkwell/groups_api.py b/src/inkwell/groups_api.py index 6260919..def4ecd 100644 --- a/src/inkwell/groups_api.py +++ b/src/inkwell/groups_api.py @@ -1,4 +1,4 @@ -"""Admin routes over groups: named sets of people a note can be shared with (#5177). +"""Admin routes over groups: named sets of people a note can be shared with. A group is the instance's, not anyone's: the admin makes it and says who is in it, and every member of the instance can share a note with it. Sharing with a group diff --git a/src/inkwell/installer.py b/src/inkwell/installer.py index cbcc3eb..4ae1461 100644 --- a/src/inkwell/installer.py +++ b/src/inkwell/installer.py @@ -2,28 +2,16 @@ curl -fsSL https://notes.example.com/install.sh | sh -## Why the server templates it +`curl | sh` gives a script no way to learn where it came from, so the server writes +its own address into the copy it hands out. -`curl | sh` gives a script no way to learn where it came from: no argv, no referrer, -nothing. So the server writes its own address into the copy it hands out, and the -script installs from there without being told. Anything else means a self-hoster -editing a URL by hand, which is the friction `client_dist.py` exists to remove. +Arbitrary text in a piped script is the whole attack, so exactly one variable is +substituted, `INKWELL_SERVER_DEFAULT`, with a value that passed [SAFE_ADDRESS] +(nothing a shell reads as more than characters). The script checks the shape again. +An address that can't pass is refused. -## Which way it fails - -A server that can put arbitrary text into a script a person pipes to `sh` is the -whole attack. So the substitution is ONE variable, `INKWELL_SERVER_DEFAULT`, given a value -that has already passed [SAFE_ADDRESS] — no quote, no space, no `$`, nothing a shell -reads as anything but characters — and the script checks the shape again before it -uses it. If the address cannot pass, the route refuses rather than serving a script -that would install from somewhere else. - -## Where the script comes from - -ONE script. The forge serves `desktop/packaging/install.sh` raw, and that is how the -operator installs. The image carries a copy beside this module (the Dockerfile copies -it in), and a source checkout reads the original. Same bytes either way; this module -changes exactly one line of them. +The script is `desktop/packaging/install.sh`; the image carries a copy beside this +module, and a source checkout reads the original. """ from __future__ import annotations @@ -47,11 +35,10 @@ SOURCE_SCRIPT = Path(__file__).resolve().parents[2] / "desktop" / "packaging" / # `render` refuses a script that does not, rather than serving one with no address in. PLACEHOLDER = 'INKWELL_SERVER_DEFAULT=""' -# What a server address may look like before it goes into a shell script: a scheme, -# a host name or IPv4 address, an optional port and an optional path. Deliberately -# narrower than a URL. Every character it admits is inert inside single quotes, and -# an address it turns away (an IPv6 literal, a path with `%` in it) is answered by -# setting Settings → Public address, not by widening this. +# A server address safe to put in a shell script: scheme, host name or IPv4, optional +# port and path. Narrower than a URL on purpose; every character is inert inside +# single quotes. An address it refuses (IPv6, `%` in a path) is fixed by setting +# Settings → Public address. SAFE_ADDRESS = re.compile(r"https?://[A-Za-z0-9.-]+(:[0-9]{1,5})?(/[A-Za-z0-9._~/-]*)?") @@ -68,10 +55,8 @@ def script_text() -> str | None: def server_address(public_url: str, scheme: str, host: str) -> str | None: """Where the installer should fetch from, or None if no safe answer exists. - Settings → Public address when it is set: it is the address the operator says - people use, and behind a reverse proxy the request's own Host may be an internal - one. Otherwise the address this request arrived on, which is the one the person - running `curl` just reached. + Settings → Public address when set, since behind a proxy the request's Host may + be internal. Otherwise the address this request arrived on. """ address = (public_url or f"{scheme}://{host}").rstrip("/") return address if SAFE_ADDRESS.fullmatch(address) else None diff --git a/src/inkwell/invites.py b/src/inkwell/invites.py index b7477b9..a5405b3 100644 --- a/src/inkwell/invites.py +++ b/src/inkwell/invites.py @@ -1,16 +1,12 @@ -"""Invites: an admin lets one person register while registration is closed (#5172). - -Before these, adding a second person meant turning registration back on for the -whole internet while they signed up, with no record of who was let in (#2939 §1). +"""Invites: an admin lets one person register while registration is closed. An invite is a link carrying a random token. It works once, until it expires, and optionally only for one email address. Only the token's hash is kept, so the link is shown once, when it is made; an admin who loses it revokes it and makes another. -This module is the invite itself: its status, its wire shape, and redemption, which +This module is the invite itself: its status, wire shape, and redemption, which `auth.register` calls inside the transaction that creates the account. The admin -routes are in `invites_api.py`, the same split as `settings` and `settings_api`, and -for the same reason: the routes need `auth`, and `auth` needs this. +routes are in `invites_api.py`, because they need `auth` and `auth` needs this. """ from __future__ import annotations diff --git a/src/inkwell/invites_api.py b/src/inkwell/invites_api.py index 211c200..f63db04 100644 --- a/src/inkwell/invites_api.py +++ b/src/inkwell/invites_api.py @@ -1,4 +1,4 @@ -"""Admin routes for invites: make one, list them, revoke one (#5172). +"""Admin routes for invites: make one, list them, revoke one. What an invite is, and how it is redeemed, is in `invites.py`. """ diff --git a/src/inkwell/labeling.py b/src/inkwell/labeling.py index 9b9b5a6..a0389cb 100644 --- a/src/inkwell/labeling.py +++ b/src/inkwell/labeling.py @@ -1,10 +1,8 @@ -"""Shared note↔label membership reconciliation. The "set a note's MANUAL (picker) -labels, leave the tag-sourced ones alone" logic was duplicated line-for-line between -the labels-picker API (notes.set_note_labels) and sync push (sync._apply_note_manual_labels). -Single home so both stay in lockstep. via_tag=True rows track the body #tags and are -governed by _lift_and_reconcile_tags — this function never touches them. +"""Note↔label membership. Sets a note's manual (picker) labels for both the labels +API and sync push, leaving via_tag=True rows, which track the body's #tags, to +_lift_and_reconcile_tags. -Also the one definition of a LIVE tag and of deleting one (#5382).""" +Also the one definition of a live tag and of deleting one.""" from __future__ import annotations from datetime import datetime, timezone diff --git a/src/inkwell/labels.py b/src/inkwell/labels.py index a3dbff0..9e07c01 100644 --- a/src/inkwell/labels.py +++ b/src/inkwell/labels.py @@ -33,13 +33,11 @@ async def _label_note_count(db, label_id) -> int: async def _merge_into(db, source: Label, target: Label) -> None: """Move every note tagged with `source` onto `target`, then tombstone `source`. - Shared by the explicit `/merge` route and by a rename that lands on a name - another tag already holds — those are the same operation, and having one body - is what stops them drifting into two answers for one question. + Used by `/merge` and by a rename onto a name another tag holds, which are the + same operation. - Note-body `#tags` are NOT rewritten, so a note whose body still literally - contains the source `#tag` will re-mint that tag on its next edit. Retiring a - tag means editing it out of the text; a known, documented nuance. + Body `#tags` are not rewritten, so a note still containing the source `#tag` + re-creates it on its next edit; retiring a tag means editing it out of the text. """ # Notes already carrying the target: a note can't hold the same label twice # (composite PK), so the source attachment there is just dropped as a dup. @@ -81,9 +79,8 @@ async def list_labels(): ) ).all() ) if labels else {} - # When each was last used: the newest edit among the notes carrying it, trash - # aside. The rail shows the few most recent (#5931), so a tag in daily use - # stays in reach however many others there are. + # When each was last used: the newest edit among its notes, trash aside. The + # rail shows the most recent few, so a tag in daily use stays in reach. last_used = dict( ( await db.execute( @@ -114,10 +111,9 @@ async def create_label(): if not name: return json_error("tag name is required", 400) async with session_scope() as db: - # Idempotent: creating an existing tag just returns it. Case-INSENSITIVE, - # like the clients' `find_or_create_label` — a case-sensitive match here was - # the path that MINTED the "Groceries" beside "groceries" pair that no synced - # client can hold, since their `labels` index is unique on `lower(name)`. + # Creating an existing tag returns it. Case-insensitive, like the clients: + # their `labels` index is unique on `lower(name)`, so they can't hold both + # "Groceries" and "groceries". existing = await db.scalar( select(Label).where(named(g.user_id, name)) ) @@ -146,11 +142,8 @@ async def update_label(label_id: str): if label is None: return not_found() if has_name: - # Case-INSENSITIVE, matching the clients' `find_or_create_label` and the - # local store's `lower(name)` unique index. The Postgres constraint here - # is on the raw name, so the database would happily hold "Groceries" - # beside "groceries" — but no synced client can store both, so letting - # one be made is letting a pull fail later on a phone. + # Case-insensitive, like the clients' unique `lower(name)` index. Postgres + # would accept both spellings, and a phone's pull would then fail. clash = await db.scalar( select(Label).where( named(g.user_id, name), @@ -158,12 +151,9 @@ async def update_label(label_id: str): ) ) if clash is not None: - # Renaming onto an existing tag MERGES the two rather than failing: - # typing an existing tag's name onto this one says they are the same - # thing. The OLDER row survives and takes the new spelling — age is - # the one property that does not depend on which of the two the - # caller happened to be renaming, so A→B and B→A agree. Ties go to - # the incumbent. Mirrors `store::rename_label` exactly. + # Renaming onto an existing tag merges the two. The older row survives + # and takes the new spelling, so A→B and B→A agree; ties go to the + # incumbent. Mirrors `store::rename_label`. if clash.created_at <= label.created_at: survivor, doomed = clash, label else: @@ -195,9 +185,7 @@ async def delete_label(label_id: str): async def merge_label(label_id: str): """Merge `label_id` (source) INTO the label given by body {"into": }: move every note tagged with the source onto the target, then tombstone the source. Both - must be owned by the caller. Note-body `#tags` are NOT rewritten, so a note whose - body still literally contains the source #tag will re-mint that label on its next - edit — retire a tag by editing it out of the text (a known, documented nuance).""" + must be owned by the caller. Body `#tags` are not rewritten (see `merge_into`).""" data = await request.get_json(silent=True) or {} into = data.get("into") async with session_scope() as db: diff --git a/src/inkwell/mail_templates.py b/src/inkwell/mail_templates.py index 83d8815..2887127 100644 --- a/src/inkwell/mail_templates.py +++ b/src/inkwell/mail_templates.py @@ -1,18 +1,15 @@ -"""What the server's emails say, and the one layout they all share (#5917). +"""What the server's emails say, and the one layout they all share. Each email is a function returning an `Email`: a subject, a plain-text part and an HTML part. `mailer.send` sends both as one multipart/alternative message, so a client that shows HTML gets the branded version and one that doesn't still gets every word and the link. -The HTML is deliberately email-shaped rather than web-shaped. It uses tables and -inline styles, because many mail clients drop `