1b3e29e4f6af97954154fc47bd354e3a2d0c5206
18
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
09b5f874b6 |
Security values move into the Settings UI
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Failing after 9s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Failing after 12s
CI & Build / Build & push image (push) Successful in 32s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m14s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Operator: *"proxy hops defaults to 1 and should be in the settings UI not in the envs, we need the security values to be in the UI."* Overrules the call I made yesterday, and rule 25 is on your side — I argued deployment-topology, but the operator has to be able to SEE what protects them, and reading a container's environment is not seeing. Six new settings in a **Security** group: trusted proxy hops (default 1), the per-account and per-address sign-in limits with their shared window, and the sign-up limit with its own. `THOUGHTSYNC_TRUSTED_PROXY_HOPS` is gone; the rate limits are no longer hardcoded constants. **The hard part was keeping the throttle cheap.** It consults these BEFORE opening a database connection — deliberately, because a refused attempt is meant to cost nothing, and the hop count is needed to know who is even asking. A query per attempt would undo both. So there is a small cache seeded from the registry defaults (the app works with no database at all, which is what the DB-free unit lane relies on), loaded at boot, and refreshed on every settings save — the same live-update contract `session_ttl_days` already had. `SlidingWindow` now takes its limit and window as SUPPLIERS rather than values, so a saved number applies to the next attempt instead of the next deploy. **Bounds are rejected, not clamped.** A hop count of 99 would trust anything a caller sent; a sign-in limit of 0 would lock every account out permanently. Both now fail validation with a message naming the range, and the number input carries min/max so the browser objects first. Silently storing a different number than the one typed is how somebody ends up believing a protection is set to something it is not. `MAX_BUCKETS` stays a constant on purpose: it protects the limiter from itself rather than the app from a caller, and there is no operator judgment to apply. Two integration tests, because the whole point is the round trip: a dangerous value refused, a legitimate one reaching the cache the throttle reads and persisting; and every Security row reaching the admin payload with bounds and a description that explains itself. |
||
|
|
a85c53ba2c |
Trust proxy headers by hop count, and log every credential event
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 12s
CI & Build / Build & push image (push) Successful in 32s
Operator, before exposing the instance: *"I'd expect that we should have a proxy hops setting for how many proxy hops we should trust a shared real-ip at… and is there any session logging."* Neither existed, and the first one was a real hole. **The address was forgeable.** `client_address()` read the LEFTMOST `X-Forwarded-For` entry — nominally "the original client", and precisely the one a caller controls, because anything they send arrives before what proxies append. So `curl -H "X-Forwarded-For: 1.2.3.4"`, rotated per request, minted a fresh rate-limit bucket every time. Concretely: stuffing ONE account stayed limited (the account key is unforgeable and that is why it exists), but spraying MANY accounts from one source was not — each account got its own budget, and the per-address cap meant to bound the total was defeated by a header. On a LAN that is nothing. It is not nothing on a public host. Now it counts in from the RIGHT by `THOUGHTSYNC_TRUSTED_PROXY_HOPS`, default 1. Each hop appends what it saw, so the rightmost entries are the ones our own infrastructure wrote and a forged prefix lands to the left of them where it can never be selected — proven for the honest, forged, padded, CDN and shorter-than-configured cases. 0 ignores the header entirely; 2 is Cloudflare in front of a proxy. Too high is the dangerous direction, so a header shorter than configured falls back to the socket address rather than reaching further left. `X-Forwarded-Proto` had the same bug and now shares the same rule. Both live in a new `proxy.py` rather than being written twice — two places holding one decision is how issue 2183 happened, and this is the same decision. Env rather than the Settings UI, against rule 25's usual pull: it is deployment topology rather than preference, and the limiter consults it BEFORE opening a database connection, which is the entire point of checking a throttle before doing expensive work. Easy to move if that reads wrong. **And there was no logging at all** — `auth.py` had no logger, and the only record of anything was `device_tokens.last_used_at`. Sign-ins, failures, throttle trips, new accounts and device-token issuance now all log, with the attempted email and the trusted address. Deliberately including the email: it is the operator's own server, and "somebody failed a login" without saying against which account is not actionable. `basicConfig` at INFO in `create_app`, because hypercorn configures its own loggers and leaves the root at WARNING — without it every line above would have gone nowhere, which is a worse failure than not writing them. This is the app log, not an audit table. Not queryable, not retained past log rotation. The table is task 2939; this is what makes the next few days observable. |
||
|
|
bc22f8e249 |
Remove [[wiki-links]], backlinks and the graph
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 31s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 37s
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 6s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 8s
Android / Kotlin + Rust (APK) (push) Failing after 1m56s
Operator, 2026-08-22 (note 2897): ThoughtSync is an intermediary surface. You
write here because it's easy — a notebook in your pocket — and later you recall
the thing and go finish it somewhere else. Recall is the product; organization
is secondary. A linking system is organization, and it isn't what this is for.
So: `[[wiki-links]]`, backlinks, the `[[` autocomplete, the note_links table,
`/api/notes/link-search`, `/api/notes/<id>/backlinks`, the whole graph blueprint
and GraphView. Rust core loses `extract_links`, `backlinks`, `link_search` and
`create_titled`; the desktop loses the three Tauri commands that exposed them.
This subsumes
|
||
|
|
b6152ec18b |
server: harden the surfaces a public deployment leaves exposed
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 9s
CI & Build / Python tests (push) Failing after 11s
CI & Build / Build & push image (push) Successful in 33s
On a LAN the login form is reachable by people you already trust. Exposed, it is reachable by everyone, and nothing in front of it was counting. Three credential routes — /login, /register and /device-login — now throttle. Every attempt is counted against BOTH the account and the calling address, and either can refuse it. The account key is the one that matters and the one that cannot be forged: it stops stuffing against a known email no matter how many addresses the attempts arrive from. The address key bounds one source spraying many accounts, and is best-effort by nature — behind a proxy it comes from X-Forwarded-For, which a caller can set to anything if the app is exposed directly. That is exactly why it isn't the only key. The check runs BEFORE the password is verified, which is the other half of what this protects. bcrypt is deliberately slow; an unauthenticated caller who can trigger it without limit has a CPU exhaustion primitive as well as a guessing one. Sliding rather than fixed windows, because a fixed one lets twice the limit through across a boundary. Bucket count is capped so a rotating forged header can't turn the limiter into the exhaustion it prevents. A sign-in against an email with no account now spends a real bcrypt against a throwaway hash first. Without it "no such account" returned in microseconds while a wrong password took ~100ms, which is a reliable oracle for which emails are registered here. Every response carries a CSP with script-src 'self', object-src 'none' and frame-ancestors 'none', plus nosniff, a referrer policy and a permissions policy. The app has no inline and no third-party scripts, so this concedes nothing; the exceptions are honest — inline STYLE (Vue writes it itself for v-show and the FLIP), and remote images (a link preview renders the og:image of an arbitrary host, over either scheme, since a LAN install is served over http). HSTS only where the request already arrived over TLS, and scoped to the one host: no includeSubDomains, no preload, neither of which is this app's to commit. X-Forwarded-Proto detection moved into one `_is_https()` — the session cookie's Secure flag and HSTS are the same question, and answering it twice is how the two drift apart. docs/public-hosting.md is the rest of it: the four things only the operator can do (close registration, terminate TLS and forward the scheme, stop publishing the app port, back up the attachment volume as well as the database), and an honest list of what the app does NOT have — no email verification, no password reset, no second factor, no per-user quota, no audit log. Those aren't blockers for an instance whose accounts are people you know. They're the reason not to leave signups open to strangers. |
||
|
|
d77a79859c |
server: hand out the Android client this server syncs with (2726)
CI & Build / Python tests (push) Successful in 11s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Build & push image (push) Successful in 50s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 3m8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 5m32s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 8m8s
A self-hoster should not need an account on someone else's forge to get the app
for their own notes. The Fabled-Git instance is private — which is why
`install.sh` already cannot fetch for anyone but the operator — so a release page
is no use as a distribution point. The server holding the notes is something the
person already trusts and already reaches.
It also keeps the pair in step by construction. Client and server 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.
**Two files, and both must be present**: `thoughtsync.apk` and a
`thoughtsync-android.json` sidecar carrying `{version_name, version_code, size,
sha256}`. The sidecar exists because an APK keeps its version in a binary AXML
manifest, which Python cannot read and which is not worth putting `aapt` on a
Quart server to reach. CI writes it beside the APK, where the values are already
known — including the digest, computed over the same bytes it uploads, so a
phone can tell a truncated download from a complete one before handing it to the
installer. Not a trust anchor; the signature is that.
**Under DATA_DIR, not baked into the image.** Baking charges ~55 MiB to every
self-hoster including everyone who never touches Android. `/var/thoughtsync` is
already the mounted volume that holds attachments, so a build dropped there
survives container recreation.
**Absence is an ordinary state, not an error.** No APK means the key is absent
from `/api/config` — absent rather than null, so a client testing for it cannot
confuse "this server has no client" with "this server predates the field" — the
web UI hides the card instead of offering a button that 404s, and the metadata
route answers 404. A server whose owner does not use Android is not misconfigured.
**A mismatched pair also counts as no client.** If the sidecar's recorded size
does not match the file on disk, the two did not arrive together; serving one
build while advertising another is worse than serving none, because the phone
would compare versions against a promise the bytes do not keep. That makes the
copy order in docs/android-distribution.md load-bearing, and it is written down
there: APK first, sidecar last.
**The version is public, the bytes are not.** An updater has to be able to ask
"is there something newer?" cheaply and before it has done anything; 55 MiB is
not for anyone who can reach the port. `login_required` already accepts either a
session cookie or a device bearer token, so the browser and a linked phone both
work with no second auth path.
The Android lane now publishes both files to the same rolling `dev` release the
desktop bundles use, reusing `publish-release.sh` — its nullglob asset list was
already built for several jobs in separate workspaces publishing to one release,
which is exactly this. Signed builds only: publishing an unsigned APK would offer
people something they cannot install over what they already have.
Nine tests, DB-free like the rest of the suite — this lane runs no Postgres, so
the advertisement is asserted through `advertisement()` rather than through
`/api/config`, whose other half needs a database. Both routes ARE exercised,
because neither opens a session.
|
||
|
|
e64d67e904 |
Expire trash after 30 days, and make the deadline something you can see
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 12s
CI & Build / Build & push image (push) Successful in 44s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m45s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m12s
Trash had no end. A note sat in /trash until someone emptied it by hand, and its attachment BYTES sat on disk the whole time — the pile-up the operator asked about. Nothing purged; there was no scheduler at all. Retention is server-owned: `trash_retention_days` (default 30, 0 = keep forever) in the settings registry, so it lands in admin Settings with no migration and takes effect without a restart. A background sweep started in before_serving does the work. Clients learn about a purge the way they learn about any deletion — as a tombstone on the delta feed. An auto-purge nobody can see coming is data loss on a timer, so the window is now visible: /api/config publishes it, notes carry `deleted_at`, Trash leads with the policy, and each card counts down. The countdown rounds DOWN — saying "1 day left" for a note with ten minutes on the clock is the one error here that actually costs someone a note. Three things this turned up on the way: - `DELETE /api/notes/<id>` hard-deleted the row, leaving no tombstone at all. A permanent delete in the web UI never reached a linked device, which would keep its copy forever and push it back on the next edit. It now purges through the same path as everything else. - The purge left `note_revisions` and `note_link_previews` behind. A revision holds the full body, so the text of a "permanently deleted" note was still sitting in the database. - `deleted_at` now SURVIVES a purge instead of being cleared. It's still true, and it means every query that says "not trashed" excludes tombstones for free — without it a content-less row reads as a perfectly normal active note and shows up on the board as a blank card. Desktop keeps its own clock only when there's nobody else to keep one: the sweep runs at startup on an UNLINKED device and refuses otherwise. A linked client that expired notes on its own schedule could destroy something the server was deliberately keeping, then push that delete upstream. Local policy must never outrank the server's — so it also adopts the server's window for the countdown rather than showing its offline default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi |
||
|
|
fbbe877c46 |
M10.6: client↔server sync protocol handshake (task 1995)
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 42s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m34s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 14s
Version the sync WIRE PROTOCOL separately from either program's release
version, so a self-hosted server and the desktop app can sit on different
releases and still work out whether they can talk.
Each side declares two numbers — what it speaks, and the oldest counterpart
it accepts. Either side can therefore mark a change breaking without the
other shipping in step, which is the whole point: no app↔server lockstep.
Server advertises on the existing public /api/config (a client must be able
to ask "can I talk to you?" before it holds a device token, or even has an
account): sync_protocol_version, min_client_protocol_version, sync_features.
sync_features exists because a version number can only say newer/older. An
ADDITIVE change earns a capability name instead of a minimum bump, so a
newer client meeting an older server drops that one feature and syncs the
rest, rather than refusing. Raising a minimum is reserved for genuinely
breaking changes — it's the switch that hard-blocks the other side.
Client half is pure decision logic (sync/compat.rs), no I/O, so every branch
is unit-testable — there's no live-server lane in CI. Three outcomes: ok /
degraded{unavailable} / incompatible{reason, client_must_update}. The last
names which side can fix it, so the message is actionable. A server that
predates the handshake sends no protocol fields at all; that reads as
"update the server", deliberately not as a parse error, which would look to
the user like they mistyped the URL.
normalize_base_url defaults a bare host to https://, never http:// —
silently downgrading would put a long-lived device token on the wire in
cleartext because someone omitted five characters. Plain HTTP on a trusted
LAN stays supported; the user types http:// and thereby chooses it.
Transport (the actual fetch) lands next, separately: it needs an HTTP/TLS
stack, and that's a real risk to the Windows cross-compile lane, so it gets
its own CI run to bisect against rather than riding along with this.
No UI here by design — the link/settings surface it feeds is M10.7's, per
this task's own sequencing.
Policy documented in docs/sync.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
|
||
|
|
2abed7132c |
S1: shared value helpers (parse_dt/coerce_bool) + auto-Secure session cookie
M9 hardening/DRY pass — section S1, commit 1 (the shared-toolkit foundation): - Add src/thoughtsync/common.py with parse_dt() and coerce_bool(): one home for the ISO-date and truthy-flag coercions that were duplicated across modules. notes.py adopts them and deletes _parse_iso_dt, _iso_to_dt and _truthy (rule 22 — old copies removed; callers, incl. tests, updated). - Security: the session cookie is now marked Secure automatically on any request that arrived over HTTPS (directly or via a proxy's X-Forwarded-Proto), via a SecureCookieSessionInterface override. Hardens HTTPS deployments without breaking plain-HTTP LAN installs — no config. Behavior-preserving refactor + one security hardening. The backend serialization layer, the json_error sweep, and the notes.py split follow as their own commits. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
5fdc124c77 |
M6 1902a: richer facet query + saved-filters storage (backend)
GET /api/notes gains combinable, AND-ed facets alongside the existing filter/date/sort: multiple ?label= (notes with ALL), ?color, ?kind, ?has_reminder, ?has_attachment, and ?q (full-text over title+body, ranked) — so the facet bar's text box searches, not just filters. All optional; invalid color/kind → 400. saved_filters table (migration 0021) + /api/saved-filters CRUD (list / create / rename+repoint / delete, owner-scoped). `params` is a JSON facet dict mirroring the query surface; clean_params() whitelists facet keys so a saved view can't accumulate junk. Tests (DB-free): _truthy, clean_params key-whitelisting, saved-filters auth-guards. UI (facet bar + saved-views sidebar) lands next. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
b5f545f655 |
M6 1900: any-file attachments + audio memos (broaden beyond images)
A note can now carry any file, not just images — PDFs, documents, audio memos, etc. "Dump anything" capture. Backend: - note_attachments.filename (migration 0019) records the original name for download + display. - Upload drops the image-only mime gate: accepts any type, derives the storage extension from the filename, and enforces a DB-backed per-file cap — new setting max_attachment_mb (default 25, rule 25). App body ceiling raised 12→64 MB (also lifts the import-zip / sync-push limits); the per-file cap is the effective attachment limit. - Serve sets Content-Disposition: images inline, everything else downloads with its original (header-sanitized) filename. - Import (native + Keep Takeout) now brings in ANY attachment, not just images — completing the Keep audio-memo gap; preserves filename + sha256. - Attachment metadata (delta feed + REST) carries filename. Frontend: - Editor renders attachments by kind: images inline (thumbnail), audio via an inline <audio> player, any other file as a download chip (paperclip + filename + size). File picker accepts any type; "Attach a file". - Card previews the first image; non-image files show as compact chips. Tests (DB-free): _safe_filename, _attachment_ext, _header_filename. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
8e40ea1188 |
Sync 3: pull endpoint GET /api/sync/changes (M8)
Delta pull for native clients: returns every note + label the caller owns whose sync_revision advanced past ?since=<cursor>, ascending by revision, paginated (?limit, default 500 / max 1000), with the next cursor + has_more. since=0 is a full initial sync. Web app unaffected (new blueprint). Notes and labels share one revision sequence, so the cursor is a single watermark. _page_cursor() handles the two-stream paging: when either stream fills its page, it advances only to the SMALLER of the two page boundaries so nothing between the cursor and the next pull is skipped. Notes reuse _serialize_notes (items/labels/attachments inline) + sync_revision + purged_at (tombstone); labels carry name/color/purged_at/sync_revision. Returns ALL of the owner's notes regardless of state (active/archived/ trash/purged) — a client mirrors everything. Registered sync blueprint. Tests (DB-free): changes auth-guard; _parse_since / _clamp_limit validation; _page_cursor across empty / drained / one-full / both-full. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
3e9b6095dd |
m4: PWA install — manifest, icons, service worker
Make ThoughtSync installable ("Add to Home Screen") without going
offline-first (the Android app is the real offline client, M5):
- web app manifest (name, icons incl. maskable + SVG, standalone, theme)
- generated PNG icon set + apple-touch-icon + favicon, from committed
SVG sources (a linked-thoughts constellation on the brand tile)
- minimal service worker: installable shell only — caches just an
offline fallback page, never the app shell / hashed assets / API, so
data stays fresh and deploys never serve a stale shell
- register the SW in main.ts (progressive enhancement; failures ignored)
- index.html: manifest/icon links, apple-mobile meta, description
- backend: register the .webmanifest MIME type so it serves as
application/manifest+json
- README: note that install needs a secure context (HTTPS/localhost)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
|
||
|
|
ad006ccb58 |
M3 graph view: /api/graph + force-directed SVG
- graph blueprint: GET /api/graph resolves note_links to target notes by
normalized title (owner-scoped, non-trashed, self-excluded) → {nodes, edges}
of connected notes.
- GraphView: hand-rolled force simulation (repulsion + edge springs + centering,
cooling over ~400 frames), SVG nodes/edges, click a node to open it (reuses the
editor with link navigation). Sidebar Graph entry + route; empty state.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
|
||
|
|
e4c898cd1b |
M2 attachments: image upload + owner-scoped media serving
- Migration 0007: note_attachments (path/mime/size). Upload POST /api/notes/<id>/attachments (multipart, png/jpeg/gif/webp, 12MB cap via MAX_CONTENT_LENGTH) stored under Config.media_root() (first use of DATA_DIR); owner/ACL-scoped GET serves the file (nosniff); DELETE removes row + file. Note responses include attachments[]. - Frontend: notes store uploadAttachment (FormData)/deleteAttachment; editor image button + paste-to-upload + thumbnail grid with remove; card shows the first image as a cover. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
4d1fc1bdf9 |
M2 labels backend: labels + note_labels, CRUD, note-label set, filter
- Label + NoteLabel models; migration 0004 (labels unique per owner + note_labels join, cascade). - /api/labels: list/create(idempotent)/rename(clash-checked)/delete, owner-scoped. - PUT /api/notes/<id>/labels to set a note's labels (validated against owned). - Note responses now include labels[] (merged via one explicit join query — no lazy relationship); GET /api/notes?...&label=<id> filters by label. - DB-free auth-guard tests for labels endpoints. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
b46bda38ee |
M1.5 backend: admin role + DB-backed settings, DB-URL-only install
- users.is_admin; first registered user becomes admin; registration gated by the allow_registration setting (first account always allowed). is_admin in /api/auth/* responses; require_admin guard (live DB check). - settings table + code registry (site_name, allow_registration, session_ttl_days) with typed defaults — empty table = all defaults (rule 26). get/set/validate service; GET /api/config (public) + GET/PATCH /api/settings (admin), live session-TTL apply with no restart (rule 25). - Cookie-signing secret now persisted in the DB (before_serving load-or-create), so sessions survive restarts with no volume. Config: DATABASE_URL is the only required env; SECRET_KEY + DATA_DIR are optional break-glass items. - Migration 0003; DB-free tests for settings validation + admin guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
df59a30cca |
M1: notes model + migration 0002 + notes API (CRUD, trash/restore)
- Note model (owner_id, title, body, color-key, pinned, archived, deleted_at soft-delete, timestamps) + board index; NOTE_COLORS palette keys. - Migration 0002 (notes table + ix_notes_owner_board). - /api/notes blueprint (login_required): list (?filter=active|archived|trash, pinned-then-updated, read via visible_to_user ACL), create, get, patch (title/body/color/pinned/archived), trash, restore, permanent delete (trash-only). Mutations owner-scoped; empty note rejected (400). - DB-free unit tests: is_empty_note, normalize_color, palette, serialize, auth-guard on list/create. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
04e3ab20cf |
M0: backend skeleton — Quart factory, async DB, auth, ACL spine, migrations
Foundation & Identity backend for ThoughtSync: - Quart app factory (create_app) with /api/health + SPA history-fallback - async SQLAlchemy 2.0 + asyncpg engine/session (lazy; boots without a DB) - native email+password auth via signed-cookie session (register/login/logout/me + login_required guard); bcrypt password hashing (72-byte safe) - multi-user sharing-ACL spine (rule 47): users, groups, group_members, and a polymorphic shares table + visible_to_user() SQL predicate (owner OR direct share OR group share) that M1's notes will scope through - Alembic async env (adapted from family pattern) + 0001 foundation migration - DB-free unit tests (app/health/auth-guard, password roundtrip, ACL compile) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |