Compare commits

...
10 Commits
Author SHA1 Message Date
MarkusandClaude Opus 5 9769702585 Merge branch 'fix/security-review-remediation'
Answers the independent review in SECURITY_ANALYSIS.md: both critical findings,
all twelve high, all eighteen medium, and the lower-severity plan drift. The
headline defects were a write path whose lifecycle and version checks sat
outside the serialized operation, an unterminated frontmatter block that was
"repaired" into a new note, delayed embeddings landing on reused chunk ids, and
a startup that could not bind its listeners while the embedding backend was
unreachable.

Also in here, found while deploying rather than by the review:

- Referrer-Policy: no-referrer made browsers send `Origin: null` on form
  submissions, so the CSRF guard refused every write in the UI. It had never
  worked in a browser on any build; the tests missed it by setting Origin by
  hand.
- Renaming a note orphaned its inbound links at the next reindex of each source,
  silently and long after the rename.
- An idempotency claim abandoned by a cancelled request wedged its request_id
  for the full 24h TTL.

The UI is translated to English and rebuilt around design tokens, and the test
suite grows from 227 to 406, including the deterministic concurrency and barrier
tests the review asked for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139vHnNpS2sLNELjrAHX5Cu
2026-09-13 16:48:08 +00:00
MarkusandClaude Opus 5 f6de7845ad fix(ui): stop our own Referrer-Policy from breaking every write
This is why approving a note returned {"error":"bad origin"}, and it was never
the Host comparison: the UI shipped `Referrer-Policy: no-referrer`.

Per the Fetch standard, that policy makes a browser send `Origin: null` on an
HTML form submission — and suppress the Referer the CSRF guard falls back to.
Every write in this UI is a form POST and the guard fails closed, so both of its
inputs were destroyed by our own security header and every approve, reject,
edit, delete and health action was refused. On master too: this has never worked
in a real browser, on any build.

The tests did not catch it because they set Origin by hand, which is exactly
what a browser under that policy will not do.

`same-origin` sends the real Origin on same-origin requests and nulls it only
cross-origin — precisely the case the guard exists to refuse — while still
sending no referrer information off-site. The header test now asserts the
policy is not any of the values that null the Origin on a form submission, so
the reason is pinned rather than the string, and a new test posts the shape a
browser actually sends: a real Origin and no Referer.

`Origin: null` is now named in the refusal log, since it means something
upstream stripped the origin rather than the caller being on the wrong host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139vHnNpS2sLNELjrAHX5Cu
2026-09-13 16:38:34 +00:00
MarkusandClaude Opus 5 85783c360b feat(ui): review an inbox note from its own page
The inbox card only edits the body, so correcting frontmatter an agent got wrong
— a bad type, invented tags, a malformed valid_from — means opening the note and
using the full edit form. But the note page had no Approve or Reject, so the
owner had to navigate back to the inbox to act on what they had just fixed.

Both now appear on the note page while the note is in the inbox, posting to the
same routes the inbox uses, so revision merges and supersede chains behave
identically. An active note still shows only edit/archive/delete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139vHnNpS2sLNELjrAHX5Cu
2026-09-13 16:25:56 +00:00
MarkusandClaude Opus 5 10acfe76e6 feat(ui): log refused writes instead of failing silently
A CSRF rejection returned 403 {"error":"bad origin"} and wrote nothing anywhere.
uvicorn's access log is off for both listeners, so a refused write left no trace
on the server at all — the Host/Origin mismatch that made the deployed UI
read-only was only visible by opening browser devtools, and looked like a bug in
the UI rather than a header spelling difference.

The rejection now logs what was actually compared: path, method, whether the
header was missing or merely disallowed, the candidate origin, Host,
X-Forwarded-Host, and the configured allow-list. All of those are non-secret,
and none of them exist without them. The allow-list is now built in one place
that both the check and the log line use, so they cannot drift apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139vHnNpS2sLNELjrAHX5Cu
2026-09-13 16:25:09 +00:00
MarkusandClaude Opus 5 647f8ceb9b fix(ui): compare Host and Origin as authorities, not as raw strings
The Host cross-check added for M-10 compared the Host header to the Origin's
netloc with ==. That does not survive a reverse proxy: a browser omits the
default port from Origin, while Traefik forwards Host (and X-Forwarded-Host)
with it, so `memory.example.com` and `memory.example.com:443` are one authority
spelled two ways. The result was a 403 {"error":"bad origin"} on every
state-changing POST in the deployed UI — approve, reject, edit, delete and every
health action — which left the UI effectively read-only.

Both sides are now normalised to (host, port) with the scheme's default port
filled in, lowercased, IPv6 literals handled. An allowed Origin paired with a
foreign Host is still refused, which is the case the check exists for: the tests
cover evil.example, evil.example:443 and a suffix-match attempt alongside the
proxy shapes that must pass.

Reverting the comparison fails the proxy test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139vHnNpS2sLNELjrAHX5Cu
2026-09-13 16:22:45 +00:00
MarkusandClaude Opus 5 272346d8ad fix(index): remember former titles so a rename does not orphan inbound links
Link resolution re-reads the link text on every reindex: `[[Old Title]]` is
looked up again and the result overwrites the stored edge. So renaming a note
did not break anything at the time — it armed a trap. The next time each inbound
source was reindexed, for any unrelated reason, its link silently became
`unresolved:Old Title`, and `reindex --full` sprang every one of them at once.
Nothing in the codebase handled a title change.

A new `note_titles` table records every title a note has carried, written in the
same transaction as its links. `_resolve` consults it only after the live title
and slug lookups miss, so a *new* note claiming a freed title still wins — the
link text should name whatever holds that title now, not whatever held it first.
Where a title has had several holders, the most recent wins: deterministic, and
the least surprising reading.

`open()` backfills the table from the titles already in `notes`. Without that an
upgraded index only learns a title when the note is next reindexed, and a note
that never changes never is — so the first rename after the upgrade would still
break its links, which is the whole case this exists to prevent. `remove()`
drops a note's titles, so a deleted ULID cannot keep winning resolution for a
title nothing holds any more.

Reverting the lookup fails the rename and backfill tests; reverting only the
backfill fails the backfill test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139vHnNpS2sLNELjrAHX5Cu
2026-09-13 16:09:56 +00:00
MarkusandClaude Opus 5 79f88fdd51 fix(ui): mobile layout for the inbox, note, search and browse pages
The health pass only covered the page it was looking at. The rest had the same
class of problem:

- Card headings put a title and its pills in one inline run, and `.pill` was
  `white-space: nowrap`. An inbox item labelled `Revision of "<title>"` carries a
  whole note title inside that pill, so it pushed the card — and the page —
  sideways on a phone. Those headings are now flex containers that wrap, and a
  pill may break its own text at the small breakpoint.
- The reject form pairs a reason input with a button. Inside a flex `.actions`
  the input collapsed toward its intrinsic size; it now takes the slack.
- The search filter row is eight controls. Stacked full width it was eight rows
  tall before a single result; it now lays out in two columns with the query and
  the submit button spanning.
- `white-space: nowrap` on table cells (added so the scroll container would do
  its job) made the browse table absurdly wide, since note titles are long and
  every one of them claimed a full line. Title, tags and the call-log ids now
  wrap within a bounded column; the short columns stay nowrap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139vHnNpS2sLNELjrAHX5Cu
2026-09-13 14:42:18 +00:00
MarkusandClaude Opus 5 527799cbc9 fix(ui): align the content column and rebuild health as a real dashboard
The shell had a genuine bug: `main` carried both `max-width: var(--shell)` and a
centering inline padding, and with `box-sizing: border-box` the max-width
*includes* that padding. On a wide display the content column collapsed to a
fraction of its intended width while the header still spanned the full shell, so
the page sat narrow and misaligned with its own header. Both now use the one
gutter formula and no max-width, against `100%` rather than `100vw` so a
scrollbar cannot turn the gutter into overflow.

Health mixed three kinds of thing into one tile grid — bare counts, a status
pill, a latency with a unit, a model name — which is why it read as a ragged
list. Tiles are now uniformly one label and one number, muted at zero and amber
when a non-zero count wants attention (pending vectors, unresolved links,
orphans). Everything with a unit or a status moved into an Embeddings card
beside a Maintenance card, and a lexical-only backend now says so in a banner
instead of hiding inside a pill.

Mobile: tiles hold two columns down to ~340px, the nav scrolls sideways instead
of wrapping onto a second line, the user's name gives way to the sign-out link,
action buttons and filter controls go full width rather than forming a ragged
two-and-a-half-button row, and table cells stop wrapping so the scroll container
does its job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139vHnNpS2sLNELjrAHX5Cu
2026-09-13 14:31:54 +00:00
MarkusandClaude Opus 5 e18f42a4d0 feat(ui): translate the interface to English and modernise the design
The UI shipped in German (commit 5853b7b), which PLAN.md never asked for — the
plan's German context is about *content*: NFC/umlaut handling, German trigram
coverage, a bilingual eval set. Chrome is now English. German test fixtures and
eval/corpus notes stay German on purpose, since they are what exercises that
handling.

The stylesheet is rebuilt around design tokens rather than ad-hoc values:
surface/line/accent scales with real light and dark palettes, a type scale with
tabular numerals for the metric-heavy views, consistent radii and elevation,
visible focus rings on every control, and reduced-motion support. Notable
component work: the nav marks the current page with aria-current, health counts
become stat tiles instead of a bullet list, tables get a sticky header inside a
scroll container so a wide row cannot push the page sideways, and the diff view
tints whole added and removed lines rather than colouring the text alone.

Two functional touches that came out of the markup pass: browse and the MCP call
log render an explicit empty state, and the search state filter now offers
"Inbox only", which the route already supported after the M-18 fix but nothing
exposed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139vHnNpS2sLNELjrAHX5Cu
2026-09-13 12:58:49 +00:00
MarkusandClaude Opus 5 357b0635f5 chore(deploy): set the PocketID client id
The redirect URI registered against this client must be
https://memory.cloud.traunseenet.com/ui/auth/callback — OidcClient derives it
from UI_BASE_URL, so the two move together.

secret.yaml (SOPS) is now the only thing standing between this overlay and a
clean render. The digest and client-id placeholder spellings stay on the test's
allowlist: the digest is bumped by hand on every build (§13a.2), so it comes
back each release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139vHnNpS2sLNELjrAHX5Cu
2026-09-13 10:37:29 +00:00
17 changed files with 1083 additions and 210 deletions

No files matched your search

@@ -46,7 +46,7 @@ configMapGenerator:
- UI_ALLOWED_ORIGINS=https://memory.cloud.traunseenet.com
- OIDC_ISSUER=https://pocketid.cloud.traunseenet.com
- OIDC_SCOPE=openid email profile groups
- OIDC_CLIENT_ID=REPLACE-WITH-THE-POCKETID-CLIENT-UUID
- OIDC_CLIENT_ID=dc2aa40e-4386-46cc-bae6-0259a2e7490e
- OIDC_GROUPS_CLAIM=groups
- OIDC_REQUIRED_GROUP=mdmem_admins
- SESSION_TTL_HOURS=12
+33
View File
@@ -51,6 +51,15 @@ CREATE TABLE IF NOT EXISTS links(
PRIMARY KEY(src, dst, rel));
CREATE INDEX IF NOT EXISTS links_dst ON links(dst);
-- Every title a note has ever carried, so a rename does not silently break the
-- inbound links that still spell the old one (§5). Resolution consults this only
-- after the live title and slug miss, so a new note claiming a freed title wins.
CREATE TABLE IF NOT EXISTS note_titles(
note_id TEXT NOT NULL, title TEXT NOT NULL, slug TEXT NOT NULL, seen TEXT NOT NULL,
PRIMARY KEY(note_id, title));
CREATE INDEX IF NOT EXISTS note_titles_title ON note_titles(title);
CREATE INDEX IF NOT EXISTS note_titles_slug ON note_titles(slug);
CREATE TABLE IF NOT EXISTS flags(
id INTEGER PRIMARY KEY, note_id TEXT NOT NULL, reason TEXT, source TEXT,
created TEXT, resolved INTEGER NOT NULL DEFAULT 0);
@@ -140,6 +149,7 @@ class Database:
chunk_columns = {r[1] for r in self._write.execute("PRAGMA table_info(chunks)")}
if "tri_pending" not in chunk_columns:
self._write.execute("ALTER TABLE chunks ADD COLUMN tri_pending INTEGER NOT NULL DEFAULT 0")
self._backfill_note_titles()
self._ensure_trigram_table()
self._write.commit()
rebuilt = self._ensure_vec_table()
@@ -149,6 +159,29 @@ class Database:
self._write.commit()
return rebuilt
def _backfill_note_titles(self) -> None:
"""Seed the alias table from the titles already in the index.
Without this, an upgraded index only learns a note's title the next time
that note is reindexed — and a note that never changes is never
reindexed, so the *first* rename after the upgrade would still break its
inbound links, which is exactly the case the table exists to prevent.
"""
from mdmem.ids import slugify
assert self._write is not None
rows = self._write.execute(
"SELECT n.id, n.title FROM notes n "
"LEFT JOIN note_titles t ON t.note_id = n.id AND t.title = n.title "
"WHERE n.title IS NOT NULL AND n.title <> '' AND t.note_id IS NULL"
).fetchall()
for row in rows:
self._write.execute(
"INSERT OR IGNORE INTO note_titles(note_id,title,slug,seen) "
"VALUES(?,?,?,strftime('%Y-%m-%dT%H:%M:%SZ','now'))",
(row["id"], row["title"], slugify(row["title"])),
)
def _ensure_trigram_table(self) -> None:
"""Create/drop `chunks_tri` to match `self.trigram`.
+35 -1
View File
@@ -128,6 +128,7 @@ class Indexer:
self._write_links(w, note)
self._mirror_flags(w, note)
self._record_title(w, note)
self._resolve_links_for(note)
return pending
@@ -178,6 +179,7 @@ class Indexer:
w.execute("DELETE FROM links WHERE src=?", (note_id,))
w.execute("DELETE FROM links WHERE dst=?", (note_id,))
w.execute("DELETE FROM flags WHERE note_id=?", (note_id,))
w.execute("DELETE FROM note_titles WHERE note_id=?", (note_id,))
w.execute("DELETE FROM notes WHERE id=?", (note_id,))
def inbound_sources(self, note_id: str) -> list[str]:
@@ -210,9 +212,32 @@ class Indexer:
seen.add(key)
w.execute("INSERT OR REPLACE INTO links(src,dst,rel,origin) VALUES(?,?,?,?)", r)
@staticmethod
def _record_title(w, note: Note) -> None:
"""Remember this title for the note, so a later rename does not orphan
the inbound links that still spell it (§5)."""
if not note.title:
return
w.execute(
"INSERT INTO note_titles(note_id,title,slug,seen) VALUES(?,?,?,?) "
"ON CONFLICT(note_id, title) DO UPDATE SET seen=excluded.seen",
(note.id, note.title, slugify(note.title), utcnow()),
)
@staticmethod
def _resolve(w, target: str) -> str:
"""ULID, then exact title, then slug; otherwise 'unresolved:<text>' (§5)."""
"""ULID, then exact title, then slug, then a former title; otherwise
'unresolved:<text>' (§5).
The former-title lookup runs last on purpose. Resolution re-reads the
link text on every reindex, so renaming a note used to turn every
inbound `[[Old Title]]` into an unresolved link the next time its source
was indexed — silently, and long after the rename. Consulting past
titles fixes that without rewriting anyone else's body. Keeping it below
the live lookups means a *new* note that claims the freed title still
wins, which is the behaviour you want: the link text names whatever
holds that title now.
"""
target = target.strip()
if not target:
return f"{UNRESOLVED}"
@@ -224,6 +249,15 @@ class Indexer:
return row[0]
slug = slugify(target)
row = w.execute("SELECT id, title FROM notes WHERE path LIKE ? LIMIT 1", (f"%/{slug}--%",)).fetchone()
if row:
return row[0]
# A title may have been held by several notes over time; the most recent
# holder is the least surprising answer, and it is deterministic.
row = w.execute(
"SELECT t.note_id FROM note_titles t JOIN notes n ON n.id = t.note_id "
"WHERE t.title=? OR t.slug=? ORDER BY t.seen DESC, t.note_id DESC LIMIT 1",
(target, slug),
).fetchone()
if row:
return row[0]
return f"{UNRESOLVED}{target}"
+75 -7
View File
@@ -142,6 +142,26 @@ def build_ui_app(svc: MemoryService) -> FastAPI: # noqa: C901 - one place, many
# itself degrade to "no Origin, so allow".
candidate = request.headers.get("origin") or _origin_from_referer(request)
if candidate is None or not _origin_allowed(candidate, settings, request):
# A silent 403 is only diagnosable from the browser: uvicorn's
# access log is off, so a refused write left no trace at all on
# the server. Log what was compared — origin, host and the
# configured allow-list are all non-secret, and without them a
# proxy that spells the authority differently looks like a bug
# in the UI rather than a header mismatch.
log.warning(
"CSRF check refused a write",
extra={
"fields": {
"path": path,
"method": request.method,
"reason": _refusal_reason(candidate),
"origin": candidate,
"host": request.headers.get("host"),
"x_forwarded_host": request.headers.get("x-forwarded-host"),
"allowed": sorted(_allowed_origins(settings)),
}
},
)
return JSONResponse({"error": "bad origin"}, status_code=403)
if not is_open:
@@ -158,7 +178,14 @@ def build_ui_app(svc: MemoryService) -> FastAPI: # noqa: C901 - one place, many
response = await call_next(request)
response.headers["Content-Security-Policy"] = CSP
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "no-referrer"
# NOT "no-referrer", however tempting: per the Fetch standard a referrer
# policy of `no-referrer` makes the browser send `Origin: null` on HTML
# form submissions — and suppress the Referer that the CSRF guard falls
# back to. Every write in this UI is a form POST, so that header refused
# every one of them with "bad origin". `same-origin` nulls the Origin
# only on cross-origin requests, which is precisely the case the guard
# is meant to refuse, and still sends no referrer information off-site.
response.headers["Referrer-Policy"] = "same-origin"
response.headers["X-Frame-Options"] = "DENY"
return response
@@ -307,7 +334,7 @@ def build_ui_app(svc: MemoryService) -> FastAPI: # noqa: C901 - one place, many
@app.post("/ui/n/{note_id}/approve")
async def approve(request: Request, note_id: str) -> Response:
# The plain "Übernehmen" form submits no `body` field at all — approve
# as-is. The "Bearbeiten & übernehmen" form always submits one, even
# as-is. The "Edit & approve" form always submits one, even
# when the textarea was cleared, and an intentionally emptied body
# must be saved as empty rather than silently falling back to the old
# body (lower-severity plan drift, §10). `Form(default="")` can't tell
@@ -637,22 +664,63 @@ def _origin_allowed(origin: str, settings: Any, request: Request) -> bool:
parsed_origin = urlparse(origin)
if not parsed_origin.scheme or not parsed_origin.netloc:
return False
host = _request_host(request)
if host != parsed_origin.netloc:
if not _same_authority(_request_host(request), parsed_origin):
return False
allowed = set(settings.ui_allowed_origins)
if settings.ui_base_url:
allowed.add(settings.ui_base_url)
allowed = _allowed_origins(settings)
if not allowed: # not configured: fall back to same-host
return True
return origin.rstrip("/") in {a.rstrip("/") for a in allowed}
def _refusal_reason(candidate: str | None) -> str:
if candidate is None:
return "no Origin or Referer"
if candidate.strip().lower() == "null":
# Worth calling out by name: an opaque origin means something upstream
# stripped it — a referrer policy of no-referrer, a sandboxed iframe, or
# a privacy extension — not that the caller is on the wrong host.
return "opaque origin (Origin: null) — check Referrer-Policy"
return "origin not allowed"
def _allowed_origins(settings: Any) -> set[str]:
allowed = set(settings.ui_allowed_origins)
if settings.ui_base_url:
allowed.add(settings.ui_base_url)
return allowed
def _request_host(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-host", "").split(",", 1)[0].strip()
return forwarded or request.headers.get("host", "")
def _split_authority(value: str, scheme: str) -> tuple[str, str]:
"""(host, port) with the scheme's default port filled in, lowercased."""
v = value.strip().lower()
if v.startswith("["): # IPv6 literal: [::1]:8081
host, _, rest = v.partition("]")
host, port = host + "]", rest[1:] if rest.startswith(":") else ""
else:
host, _, port = v.partition(":")
return host, port or ("443" if scheme == "https" else "80")
def _same_authority(host_header: str, origin: Any) -> bool:
"""Does the Host header name the same authority as this Origin?
A plain string comparison does not survive a reverse proxy: browsers omit the
default port from Origin while Traefik forwards Host with it, so
`memory.example.com` and `memory.example.com:443` are the same authority
spelled two ways — comparing them raw refused every state-changing POST in
the deployed UI. Normalising the default port and case still refuses the case
this check exists for, an allowed Origin paired with a foreign Host (M-10).
"""
if not host_header:
return False
return _split_authority(host_header, origin.scheme) == _split_authority(origin.netloc, origin.scheme)
def _diff(old: str, new: str, label: str) -> str:
return "\n".join(
difflib.unified_diff(
+484 -59
View File
@@ -1,72 +1,497 @@
/* mdmem UI — a dense review tool, so: calm surfaces, strong hierarchy, no chrome
that competes with note text. Tokens first, components after. */
:root {
--bg: #fbfaf7; --fg: #1d1c1a; --muted: #6b675f; --line: #e2ded5;
--card: #ffffff; --accent: #2f5d50; --warn: #8a5a00; --danger: #8c2f2f;
--add: #1f6f43; --del: #8c2f2f;
color-scheme: light dark;
--bg: #f7f7f8;
--bg-sunken: #efeff1;
--surface: #ffffff;
--surface-2: #fafafb;
--fg: #17181c;
--fg-soft: #4a4d57;
--muted: #70747f;
--line: #e3e4e8;
--line-strong: #cfd1d7;
--accent: #10715c;
--accent-fg: #ffffff;
--accent-soft: #e6f2ee;
--ok: #15704a;
--warn: #8a5a00;
--danger: #a32f2f;
--add: #15704a;
--del: #a32f2f;
--radius-sm: 6px;
--radius: 10px;
--radius-lg: 14px;
--radius-pill: 999px;
--shadow-sm: 0 1px 2px rgb(16 18 24 / 6%);
--shadow: 0 1px 2px rgb(16 18 24 / 5%), 0 4px 12px rgb(16 18 24 / 6%);
--shadow-lg: 0 2px 4px rgb(16 18 24 / 6%), 0 12px 28px rgb(16 18 24 / 10%);
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
--mono: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace;
--step: 0.25rem;
--shell: 68rem;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #16171a; --fg: #e8e6e1; --muted: #9b968c; --line: #2c2e33;
--card: #1d1f23; --accent: #7fbfab; --warn: #d0a24c; --danger: #e08585;
--add: #7fc7a0; --del: #e08585;
--bg: #0f1113;
--bg-sunken: #0a0c0d;
--surface: #17191c;
--surface-2: #1c1f23;
--fg: #e9eaec;
--fg-soft: #b9bcc2;
--muted: #8b8f98;
--line: #282b31;
--line-strong: #3a3e46;
--accent: #4fd1ae;
--accent-fg: #06231c;
--accent-soft: #142e28;
--ok: #52c48c;
--warn: #e0ad5a;
--danger: #f08585;
--add: #52c48c;
--del: #f08585;
--shadow-sm: 0 1px 2px rgb(0 0 0 / 40%);
--shadow: 0 1px 2px rgb(0 0 0 / 35%), 0 4px 12px rgb(0 0 0 / 35%);
--shadow-lg: 0 2px 4px rgb(0 0 0 / 35%), 0 12px 28px rgb(0 0 0 / 45%);
}
}
* { box-sizing: border-box; }
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--fg);
font: 16px/1.55 -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--sans);
font-size: 15px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
:where(a) { color: var(--accent); text-decoration-thickness: 1px; text-underline-offset: 2px; }
:where(a):hover { text-decoration-thickness: 2px; }
:where(a, button, input, select, textarea, summary):focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* -- shell ---------------------------------------------------------------- */
header {
display: flex; justify-content: space-between; align-items: center; gap: 1rem;
padding: .6rem 1.2rem; border-bottom: 1px solid var(--line); position: sticky; top: 0;
background: var(--bg); z-index: 5;
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding-block: 0.55rem;
padding-inline: max(1rem, calc((100% - var(--shell)) / 2));
border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--bg) 82%, transparent);
backdrop-filter: saturate(1.4) blur(10px);
}
nav { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
nav a { color: var(--fg); text-decoration: none; }
nav a:hover { text-decoration: underline; }
.brand { font-weight: 700; letter-spacing: .02em; color: var(--accent); }
main { max-width: 62rem; margin: 0 auto; padding: 1.5rem 1.2rem 5rem; }
h1 { font-size: 1.5rem; } h2 { font-size: 1.15rem; }
h3 { font-size: .95rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
h1 small { color: var(--muted); font-weight: 400; font-size: .8rem; margin-left: .5rem; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 1rem 1.1rem; margin: 0 0 1.2rem; }
.meta, .hint { color: var(--muted); font-size: .82rem; }
.summary { font-style: italic; color: var(--muted); }
.snippet { white-space: pre-wrap; }
.pill { font-size: .72rem; padding: .1rem .45rem; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); vertical-align: middle; }
.pill.ok { color: var(--add); border-color: var(--add); }
.pill.warn { color: var(--warn); border-color: var(--warn); }
.badge { background: var(--line); border-radius: 999px; padding: 0 .4rem; font-size: .75rem; }
.tag { display: inline-block; font-size: .78rem; background: var(--line); border-radius: 4px; padding: .05rem .4rem; margin: 0 .25rem .25rem 0; color: var(--fg); text-decoration: none; }
.unresolved { color: var(--warn); text-decoration: underline dotted; }
.rel { color: var(--muted); font-size: .75rem; }
.empty { color: var(--muted); }
.cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 1rem; }
.cols ul, .history { list-style: none; padding: 0; margin: .2rem 0; }
form { display: inline; }
.filters, .editor { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin-bottom: 1rem; }
.editor { flex-direction: column; align-items: stretch; }
.editor label { display: flex; flex-direction: column; gap: .3rem; font-size: .85rem; color: var(--muted); }
nav {
display: flex;
align-items: center;
gap: 0.2rem;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
}
nav::-webkit-scrollbar { display: none; }
nav a { flex: 0 0 auto; }
nav a {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.34rem 0.66rem;
border-radius: var(--radius-sm);
color: var(--fg-soft);
font-weight: 500;
text-decoration: none;
transition: background-color 0.12s ease, color 0.12s ease;
}
nav a:hover { background: var(--bg-sunken); color: var(--fg); }
nav a[aria-current="page"] { background: var(--accent-soft); color: var(--accent); }
.brand {
margin-right: 0.5rem;
padding-left: 0;
font-weight: 700;
font-size: 1.02rem;
letter-spacing: -0.01em;
color: var(--fg);
}
.brand:hover { background: none; }
.who { display: flex; align-items: center; gap: 0.7rem; font-size: 0.84rem; color: var(--muted); }
.who a { color: var(--muted); text-decoration: none; }
.who a:hover { color: var(--fg); text-decoration: underline; }
main {
/* Same gutter formula as the header so both content columns align. No
max-width here: with border-box it would include the gutter and squeeze
the column to a fraction of the header's width. */
padding-block: 1.75rem 6rem;
padding-inline: max(1rem, calc((100% - var(--shell)) / 2));
}
/* -- type ----------------------------------------------------------------- */
h1 {
margin: 0 0 1.25rem;
font-size: 1.55rem;
line-height: 1.25;
font-weight: 650;
letter-spacing: -0.02em;
}
h2 { margin: 0 0 0.5rem; font-size: 1.08rem; font-weight: 600; letter-spacing: -0.01em; }
h3 {
margin: 1.5rem 0 0.5rem;
font-size: 0.74rem;
font-weight: 650;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
h1 small {
margin-left: 0.55rem;
font-size: 0.8rem;
font-weight: 400;
color: var(--muted);
letter-spacing: 0;
}
.meta, .hint { color: var(--muted); font-size: 0.8rem; }
.meta { margin: 0.3rem 0; font-variant-numeric: tabular-nums; }
.summary { margin: 0.4rem 0; color: var(--fg-soft); font-style: italic; }
.snippet { margin: 0.5rem 0; white-space: pre-wrap; color: var(--fg-soft); }
.empty { color: var(--muted); font-style: italic; }
/* -- card ----------------------------------------------------------------- */
.card {
margin: 0 0 1rem;
padding: 1.1rem 1.25rem;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: var(--surface);
box-shadow: var(--shadow-sm);
transition: box-shadow 0.15s ease, border-color 0.15s ease;
}
.card:hover { box-shadow: var(--shadow); border-color: var(--line-strong); }
.card h2, .card > h1 { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; }
.card h2 .pill, .card > h1 .pill { margin-left: 0; max-width: 100%; }
.card h2 a { color: inherit; text-decoration: none; }
.card h2 a:hover { color: var(--accent); }
/* -- labels --------------------------------------------------------------- */
.pill {
display: inline-flex;
align-items: center;
margin-left: 0.4rem;
padding: 0.08rem 0.5rem;
border: 1px solid var(--line-strong);
border-radius: var(--radius-pill);
font-size: 0.7rem;
font-weight: 500;
letter-spacing: 0.01em;
color: var(--muted);
vertical-align: middle;
white-space: nowrap;
}
.pill.ok { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 40%, transparent); background: color-mix(in srgb, var(--ok) 8%, transparent); }
.pill.warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 40%, transparent); background: color-mix(in srgb, var(--warn) 8%, transparent); }
.badge {
display: inline-block;
min-width: 1.3rem;
padding: 0 0.38rem;
border-radius: var(--radius-pill);
background: var(--bg-sunken);
color: var(--muted);
font-size: 0.72rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
text-align: center;
}
nav a[aria-current="page"] .badge { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent); }
.tag {
display: inline-block;
margin: 0 0.3rem 0.3rem 0;
padding: 0.1rem 0.45rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--fg-soft);
font-size: 0.76rem;
text-decoration: none;
}
a.tag:hover { border-color: var(--accent); color: var(--accent); }
.kw { margin: 0.6rem 0 0; }
.unresolved { color: var(--warn); text-decoration: underline dotted; text-underline-offset: 3px; }
.rel { color: var(--muted); font-size: 0.74rem; }
/* -- controls ------------------------------------------------------------- */
input, select, textarea, button, .button {
font: inherit; padding: .4rem .6rem; border-radius: 6px; border: 1px solid var(--line);
background: var(--card); color: var(--fg);
font: inherit;
padding: 0.42rem 0.7rem;
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--fg);
transition: border-color 0.12s ease, background-color 0.12s ease, box-shadow 0.12s ease;
}
textarea { width: 100%; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .88rem; }
button, .button { cursor: pointer; text-decoration: none; }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.danger, summary.danger { color: var(--danger); }
.actions { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin-top: 1rem; }
.actions details { width: 100%; }
table { width: 100%; border-collapse: collapse; font-size: .9rem; display: block; overflow-x: auto; }
th, td { text-align: left; padding: .35rem .5rem; border-bottom: 1px solid var(--line); }
th a { color: var(--muted); text-decoration: none; }
tr.superseded td { opacity: .55; text-decoration: line-through; }
pre.diff { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: .6rem; overflow-x: auto; font-size: .82rem; }
pre.diff .add { color: var(--add); } pre.diff .del { color: var(--del); } pre.diff .hunk { color: var(--muted); }
input::placeholder, textarea::placeholder { color: var(--muted); }
input:hover, select:hover, textarea:hover { border-color: var(--muted); }
textarea {
width: 100%;
font-family: var(--mono);
font-size: 0.85rem;
line-height: 1.55;
resize: vertical;
}
button, .button {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-weight: 550;
text-decoration: none;
cursor: pointer;
white-space: nowrap;
}
button:hover, .button:hover { background: var(--bg-sunken); border-color: var(--muted); }
button:active, .button:active { transform: translateY(0.5px); }
button.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-fg);
box-shadow: var(--shadow-sm);
}
button.primary:hover { background: color-mix(in srgb, var(--accent) 88%, black); border-color: transparent; }
button.danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 35%, var(--line-strong)); }
button.danger:hover { background: color-mix(in srgb, var(--danger) 10%, transparent); border-color: var(--danger); }
summary.danger { color: var(--danger); }
form { display: inline; }
.filters {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-bottom: 1.25rem;
padding: 0.75rem 0.85rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.filters label { display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.84rem; color: var(--fg-soft); }
.filters input[type="search"] { flex: 1 1 16rem; }
.editor { display: flex; flex-direction: column; gap: 0.85rem; align-items: stretch; margin-bottom: 1.25rem; }
.editor label { display: flex; flex-direction: column; gap: 0.35rem; font-size: 0.78rem; font-weight: 600; letter-spacing: 0.02em; text-transform: uppercase; color: var(--muted); }
.editor button, .editor .button { align-self: flex-start; }
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-top: 1.1rem;
padding-top: 1rem;
border-top: 1px solid var(--line);
}
.actions.bare { margin-top: 0; padding-top: 0; border-top: none; }
.actions form { display: inline-flex; gap: 0.4rem; align-items: center; }
.actions form input[type="text"] { flex: 1 1 10rem; min-width: 0; }
.actions details { width: 100%; margin-top: 0.3rem; }
.actions details summary { cursor: pointer; font-size: 0.86rem; padding: 0.2rem 0; }
.actions details[open] summary { margin-bottom: 0.5rem; }
.actions details form { display: flex; flex-direction: column; gap: 0.5rem; align-items: flex-start; }
.actions details form input[type="text"] { width: min(100%, 24rem); }
.history-compare { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: flex-end; margin: 0.5rem 0 1rem; }
.history-compare label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
/* -- layout helpers ------------------------------------------------------- */
.cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 1.25rem; }
.cols ul, .history { list-style: none; padding: 0; margin: 0.2rem 0; }
.cols li, .history li { padding: 0.18rem 0; }
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
gap: 0.6rem;
margin-bottom: 1.5rem;
}
.stat {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 0.3rem;
min-height: 4.6rem;
padding: 0.7rem 0.85rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
box-shadow: var(--shadow-sm);
}
.stat dt { margin: 0; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
.stat dd { margin: 0; font-size: 1.65rem; line-height: 1.1; font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: -0.03em; }
.stat.attention dd { color: var(--warn); }
.stat.zero dd { color: var(--muted); }
/* key/value rows for mixed-type facts that do not belong in a number tile */
.kv { display: grid; grid-template-columns: auto 1fr; gap: 0.3rem 1rem; margin: 0; font-size: 0.88rem; }
.kv dt { color: var(--muted); white-space: nowrap; }
.kv dd { margin: 0; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
/* -- notices -------------------------------------------------------------- */
.notice {
display: flex;
gap: 0.6rem;
margin: 0 0 1.25rem;
padding: 0.8rem 1rem;
border: 1px solid var(--line-strong);
border-left-width: 3px;
border-radius: var(--radius);
background: var(--surface);
font-size: 0.9rem;
}
.notice p { margin: 0; }
.notice h3 { margin-top: 0; }
.notice.ok { border-left-color: var(--ok); }
.notice.warn { border-left-color: var(--warn); }
.notice.danger { border-left-color: var(--danger); }
.conflict {
margin: 0 0 1.25rem;
padding: 0.9rem 1.1rem;
border: 1px solid color-mix(in srgb, var(--warn) 40%, var(--line));
border-left: 3px solid var(--warn);
border-radius: var(--radius);
background: color-mix(in srgb, var(--warn) 5%, var(--surface));
}
.conflict h2, .conflict h3 { margin-top: 0; }
/* -- table ---------------------------------------------------------------- */
.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--line); white-space: nowrap; }
td.wrap, th.wrap { white-space: normal; min-width: 11rem; max-width: 26rem; }
thead th {
position: sticky;
top: 0;
background: var(--surface-2);
font-size: 0.72rem;
font-weight: 650;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
white-space: nowrap;
}
th a { color: inherit; text-decoration: none; }
th a:hover { color: var(--accent); }
tbody tr:last-child td { border-bottom: none; }
tbody tr { transition: background-color 0.1s ease; }
tbody tr:hover { background: var(--surface-2); }
td { font-variant-numeric: tabular-nums; }
tr.superseded td { opacity: 0.5; text-decoration: line-through; }
/* -- markdown body -------------------------------------------------------- */
.body { overflow-wrap: anywhere; }
.body pre, .body code { background: var(--line); border-radius: 4px; padding: .05rem .25rem; }
.body pre { padding: .6rem; overflow-x: auto; }
.body.small { font-size: .85rem; }
.dupes { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: .8rem; }
.dupe { border: 1px dashed var(--line); border-radius: 8px; padding: .6rem; }
.conflict { border: 1px solid var(--warn); border-radius: 8px; padding: .8rem; margin-bottom: 1rem; }
.cloud { line-height: 2; }
.body > :first-child { margin-top: 0; }
.body > :last-child { margin-bottom: 0; }
.body pre, .body code { background: var(--bg-sunken); border-radius: var(--radius-sm); font-family: var(--mono); font-size: 0.86em; }
.body code { padding: 0.1rem 0.3rem; }
.body pre { padding: 0.7rem 0.85rem; overflow-x: auto; border: 1px solid var(--line); }
.body pre code { background: none; padding: 0; }
.body blockquote { margin: 0.8rem 0; padding-left: 0.9rem; border-left: 3px solid var(--line-strong); color: var(--fg-soft); }
.body.small { font-size: 0.86rem; }
/* -- diff ----------------------------------------------------------------- */
pre.diff {
margin: 0.5rem 0 1rem;
padding: 0.75rem 0.9rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
overflow-x: auto;
font-family: var(--mono);
font-size: 0.8rem;
line-height: 1.5;
}
pre.diff .add { color: var(--add); background: color-mix(in srgb, var(--add) 10%, transparent); display: inline-block; width: 100%; }
pre.diff .del { color: var(--del); background: color-mix(in srgb, var(--del) 10%, transparent); display: inline-block; width: 100%; }
pre.diff .hunk { color: var(--muted); font-weight: 600; }
/* -- duplicates ----------------------------------------------------------- */
.dupes { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 0.75rem; }
.dupe {
padding: 0.75rem 0.85rem;
border: 1px dashed var(--line-strong);
border-radius: var(--radius);
background: var(--surface-2);
}
.dupe > a { font-weight: 550; }
.cloud { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 1.25rem; }
.cloud .tag { margin: 0; display: inline-flex; align-items: center; gap: 0.35rem; }
@media (max-width: 40rem) {
body { font-size: 14.5px; }
header { gap: 0.5rem; }
.brand { margin-right: 0.2rem; }
.who span { display: none; } /* the name costs a line; the link is the point */
main { padding-block: 1.25rem 4rem; }
h1 { font-size: 1.28rem; }
h1 small { display: block; margin: 0.2rem 0 0; }
.card { padding: 0.9rem 1rem; }
/* two tiles per row stays readable down to ~340px */
.stats { grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr)); gap: 0.5rem; }
.stat { min-height: 4rem; padding: 0.6rem 0.7rem; }
.stat dd { font-size: 1.35rem; }
/* full-width controls beat a ragged two-and-a-half-button row */
.actions form { display: flex; width: 100%; }
.actions > form > button, .actions > .button { flex: 1 1 auto; justify-content: center; }
.actions form input[type="text"] { flex: 1 1 6rem; }
/* a long note title in a pill must wrap rather than widen the card */
.pill { white-space: normal; }
/* the search filter row is eight controls: two columns halve its height,
with the query and the submit button spanning the full width */
.filters { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; padding: 0.6rem; }
.filters > * { width: 100%; min-width: 0; }
.filters input[type="search"], .filters button, .filters label { grid-column: 1 / -1; }
.history-compare { width: 100%; }
.history-compare label, .history-compare select { flex: 1 1 100%; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
}
+7 -6
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="de">
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -7,17 +7,18 @@
<link rel="stylesheet" href="/ui/static/style.css">
<script src="/ui/static/htmx.min.js" defer></script>
</head>
{% set path = request.url.path if request else '' %}
<body>
<header>
<nav>
<a class="brand" href="/ui/inbox">mdmem</a>
<a href="/ui/inbox">Posteingang <span class="badge">{{ counts.get('inbox', 0) }}</span></a>
<a href="/ui/search">Suche</a>
<a href="/ui/browse">Alle <span class="badge">{{ counts.get('active', 0) }}</span></a>
<a href="/ui/health">Health</a>
<a href="/ui/inbox" {% if path.startswith('/ui/inbox') %}aria-current="page"{% endif %}>Inbox <span class="badge">{{ counts.get('inbox', 0) }}</span></a>
<a href="/ui/search" {% if path.startswith('/ui/search') %}aria-current="page"{% endif %}>Search</a>
<a href="/ui/browse" {% if path.startswith('/ui/browse') %}aria-current="page"{% endif %}>All <span class="badge">{{ counts.get('active', 0) }}</span></a>
<a href="/ui/health" {% if path.startswith('/ui/health') %}aria-current="page"{% endif %}>Health</a>
</nav>
<div class="who">
{% if user %}<span>{{ user.name }}</span> <a href="/ui/auth/logout">abmelden</a>{% endif %}
{% if user %}<span>{{ user.name }}</span> <a href="/ui/auth/logout">Sign out</a>{% endif %}
</div>
</header>
<main>
+18 -12
View File
@@ -1,34 +1,40 @@
{% extends "base.html" %}
{% block title %}Alle Notizen — mdmem{% endblock %}
{% block title %}All notes — mdmem{% endblock %}
{% block content %}
<h1>Alle Notizen <small>{{ rows|length }}</small></h1>
<h1>All notes <small>{{ rows|length }}</small></h1>
{% if cloud %}
<p class="cloud">{% for t, n in cloud %}<a class="tag" href="/ui/browse?tag={{ t }}">#{{ t }} <span class="badge">{{ n }}</span></a>{% endfor %}</p>
{% endif %}
<form method="get" class="filters">
<select name="state">
<option value="">alle Zustände</option>
<select name="state" aria-label="Lifecycle state">
<option value="">All states</option>
{% for s in ['inbox','active','archive'] %}<option value="{{ s }}" {% if state == s %}selected{% endif %}>{{ s }}</option>{% endfor %}
</select>
<input type="text" name="tag" value="{{ tag }}" placeholder="Tag">
<button type="submit">Filtern</button>
<input type="text" name="tag" value="{{ tag }}" placeholder="Tag" aria-label="Tag">
<button type="submit">Filter</button>
</form>
<div class="table-wrap">
<table>
<thead><tr>
<th><a href="/ui/browse?order=title&state={{ state }}&tag={{ tag }}">Titel</a></th>
<th><a href="/ui/browse?order=type&state={{ state }}&tag={{ tag }}">Typ</a></th>
<th><a href="/ui/browse?order=state&state={{ state }}&tag={{ tag }}">Zustand</a></th>
<th class="wrap"><a href="/ui/browse?order=title&state={{ state }}&tag={{ tag }}">Title</a></th>
<th><a href="/ui/browse?order=type&state={{ state }}&tag={{ tag }}">Type</a></th>
<th><a href="/ui/browse?order=state&state={{ state }}&tag={{ tag }}">State</a></th>
<th><a href="/ui/browse?order=tags&state={{ state }}&tag={{ tag }}">Tags</a></th>
<th><a href="/ui/browse?order=updated&state={{ state }}&tag={{ tag }}">Aktualisiert</a></th>
<th><a href="/ui/browse?order=updated&state={{ state }}&tag={{ tag }}">Updated</a></th>
</tr></thead>
<tbody>
{% for r in rows %}
<tr class="{{ 'superseded' if r.superseded_by else '' }}">
<td><a href="/ui/n/{{ r.id }}">{{ r.title }}</a>{% if r.revises %} <span class="pill warn">Revision</span>{% endif %}</td>
<td class="wrap"><a href="/ui/n/{{ r.id }}">{{ r.title }}</a>{% if r.revises %} <span class="pill warn">Revision</span>{% endif %}</td>
<td>{{ r.type }}</td>
<td>{{ r.state }}</td>
<td>{% for t in r.tags %}<span class="tag">#{{ t }}</span>{% endfor %}</td>
<td class="wrap">{% for t in r.tags %}<span class="tag">#{{ t }}</span>{% endfor %}</td>
<td>{{ r.updated }}</td>
</tr>
{% else %}
<tr><td colspan="5" class="empty">No notes match this filter.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+10 -8
View File
@@ -1,14 +1,14 @@
{% extends "base.html" %}
{% block title %}{{ note.title }} bearbeiten — mdmem{% endblock %}
{% block title %}Edit {{ note.title }} — mdmem{% endblock %}
{% block content %}
<h1>{{ note.title }} bearbeiten</h1>
<h1>Edit <small>{{ note.title }}</small></h1>
{% if conflict %}
<div class="conflict">
<h2>Konflikt</h2>
<h2>Conflict</h2>
<p>{{ conflict.message }}</p>
{% set diff = conflict.diff %}
{% include "partial_diff.html" with context %}
<p>Speichere erneut, um deine Fassung über die aktuelle zu legen — das Formular trägt jetzt die neue Version.</p>
<p class="meta">Save again to put your version on top of the current one — the form now carries the new version.</p>
</div>
{% endif %}
<form method="post" action="/ui/n/{{ note.id }}/edit" class="editor">
@@ -16,14 +16,16 @@
<label>Frontmatter (YAML)
<textarea name="frontmatter" rows="10" spellcheck="false">{{ frontmatter }}</textarea>
</label>
<label>Text
<label>Body
<textarea name="body" rows="18" spellcheck="false"
hx-post="/ui/preview" hx-trigger="keyup changed delay:400ms"
hx-target="#preview">{% if conflict %}{{ conflict.yours }}{% else %}{{ note.body }}{% endif %}</textarea>
</label>
<button class="primary" type="submit">Speichern</button>
<a class="button" href="/ui/n/{{ note.id }}">Abbrechen</a>
<div class="actions">
<button class="primary" type="submit">Save</button>
<a class="button" href="/ui/n/{{ note.id }}">Cancel</a>
</div>
</form>
<h3>Vorschau</h3>
<h3>Preview</h3>
<div id="preview" class="body">{{ render_markdown(note.body, {}) | safe }}</div>
{% endblock %}
+9 -5
View File
@@ -1,8 +1,12 @@
{% extends "base.html" %}
{% block title %}Fehler — mdmem{% endblock %}
{% block title %}Error — mdmem{% endblock %}
{% block content %}
<h1>{{ error.error }}</h1>
<p>{{ error.message }}</p>
{% if error.details %}<pre>{{ error.details }}</pre>{% endif %}
<p><a href="/ui/inbox">zurück zum Posteingang</a></p>
<div class="notice danger">
<div>
<h2>{{ error.error }}</h2>
<p>{{ error.message }}</p>
</div>
</div>
{% if error.details %}<pre class="diff">{{ error.details }}</pre>{% endif %}
<p><a href="/ui/inbox">Back to the inbox</a></p>
{% endblock %}
+55 -44
View File
@@ -2,66 +2,77 @@
{% block title %}Health — mdmem{% endblock %}
{% block content %}
<h1>Health</h1>
{% if not health.embed_ok %}
<div class="notice warn">
<p><strong>Lexical only.</strong> The embedding backend did not answer, so search is running
without the dense channel. Notes are still written and indexed; vectors fill in once it recovers.</p>
</div>
{% endif %}
{# Uniform number tiles: one label, one number, nothing else. Anything that needs
a unit, a status or a model name goes in the cards below instead — mixing the
two is what made this read as a ragged list rather than a dashboard. #}
<div class="stats">
{% for s in ['inbox', 'active', 'archive'] %}
<dl class="stat {{ 'zero' if not health.counts.get(s) }}"><dt>{{ s }}</dt><dd>{{ health.counts.get(s, 0) }}</dd></dl>
{% endfor %}
<dl class="stat {{ 'zero' if not health.chunks }}"><dt>Chunks</dt><dd>{{ health.chunks }}</dd></dl>
<dl class="stat {{ 'attention' if health.vec_pending else 'zero' }}"><dt>No vector</dt><dd>{{ health.vec_pending }}</dd></dl>
<dl class="stat {{ 'attention' if health.unresolved_links else 'zero' }}"><dt>Unresolved</dt><dd>{{ health.unresolved_links }}</dd></dl>
<dl class="stat {{ 'attention' if health.orphans else 'zero' }}"><dt>Orphans</dt><dd>{{ health.orphans }}</dd></dl>
</div>
<div class="cols">
<section>
<h3>Bestand</h3>
<ul>
{% for s, n in health.counts.items() %}<li>{{ s }}: {{ n }}</li>{% endfor %}
<li>Chunks: {{ health.chunks }}</li>
<li>ohne Vektor: {{ health.vec_pending }}</li>
</ul>
<section class="card">
<h2>Embeddings</h2>
<dl class="kv">
<dt>Status</dt>
<dd><span class="pill {{ 'ok' if health.embed_ok else 'warn' }}">{{ 'ok' if health.embed_ok else 'lexical only' }}</span></dd>
<dt>Model</dt><dd>{{ health.embed_model }}</dd>
<dt>Dimension</dt><dd>{{ health.embed_dim }}</dd>
<dt>Last call</dt><dd>{{ health.embed_last_ms or '–' }} ms</dd>
<dt>Errors</dt><dd>{{ health.embed_errors }}</dd>
</dl>
</section>
<section>
<h3>Embeddings</h3>
<ul>
<li>Modell: {{ health.embed_model }}</li>
<li>Dimension: {{ health.embed_dim }}</li>
<li>Status: <span class="pill {{ 'ok' if health.embed_ok else 'warn' }}">{{ 'ok' if health.embed_ok else 'nur lexikalisch' }}</span></li>
<li>letzte Latenz: {{ health.embed_last_ms or '–' }} ms</li>
<li>Fehler: {{ health.embed_errors }}</li>
</ul>
</section>
<section>
<h3>Verlinkung</h3>
<ul>
<li>unaufgelöste Links: {{ health.unresolved_links }}</li>
<li>verwaiste Notizen: {{ health.orphans }}</li>
</ul>
<section class="card">
<h2>Maintenance</h2>
<div class="actions bare">
<form method="post" action="/ui/health/reindex"><button type="submit">Refresh index</button></form>
<form method="post" action="/ui/health/reindex"><input type="hidden" name="full" value="1"><button type="submit">Rebuild index</button></form>
<form method="post" action="/ui/health/reembed"><button type="submit">Re-embed everything</button></form>
<form method="post" action="/ui/health/eval"><button type="submit">Run eval</button></form>
</div>
</section>
</div>
{% if eval_status.startswith("started:") %}
<div class="notice ok"><p>Eval started — report: {{ eval_status[8:] }}</p></div>
{% elif eval_status.startswith("missing:") %}
<div class="notice warn"><p>No query set at {{ eval_status[8:] }} — label 50–100 real queries first (§11).</p></div>
{% endif %}
{% if health.secrets_in_vault %}
<div class="conflict">
<h3>Mögliche Zugangsdaten im Vault</h3>
<h3>Possible credentials in the vault</h3>
<ul>{% for f in health.secrets_in_vault %}<li>{{ f }}</li>{% endfor %}</ul>
</div>
{% endif %}
<div class="actions">
<form method="post" action="/ui/health/reindex"><button type="submit">Index auffrischen</button></form>
<form method="post" action="/ui/health/reindex"><input type="hidden" name="full" value="1"><button type="submit">Index neu bauen</button></form>
<form method="post" action="/ui/health/reembed"><button type="submit">Alles neu einbetten</button></form>
<form method="post" action="/ui/health/eval"><button type="submit">Eval starten</button></form>
</div>
<div>
{% if eval_status.startswith("started:") %}
<p class="ok">Eval gestartet — Bericht: {{ eval_status[8:] }}</p>
{% elif eval_status.startswith("missing:") %}
<p class="warn">Kein Query-Set unter {{ eval_status[8:] }} — erst 50–100 Queries labeln (§11).</p>
{% endif %}
</div>
<h3>Unresolved links</h3>
<ul>{% for u in unresolved %}<li><a href="/ui/n/{{ u.src }}">{{ u.title }}</a> → <span class="unresolved">{{ u.target }}</span> ({{ u.rel }})</li>{% else %}<li class="empty">none</li>{% endfor %}</ul>
<h3>Unaufgelöste Links</h3>
<ul>{% for u in unresolved %}<li><a href="/ui/n/{{ u.src }}">{{ u.title }}</a> → <span class="unresolved">{{ u.target }}</span> ({{ u.rel }})</li>{% else %}<li class="empty">keine</li>{% endfor %}</ul>
<h3>Letzte MCP-Aufrufe</h3>
<h3>Recent MCP calls</h3>
<div class="table-wrap">
<table>
<thead><tr><th>Zeit</th><th>Tool</th><th>Quelle</th><th>ok</th><th>ms</th><th>ids</th></tr></thead>
<thead><tr><th>Time</th><th>Tool</th><th>Source</th><th>ok</th><th>ms</th><th class="wrap">ids</th></tr></thead>
<tbody>
{% for c in calls %}
<tr><td>{{ c.ts }}</td><td>{{ c.tool }}</td><td>{{ c.get('source', '') }}</td><td>{{ c.get('ok', '') }}</td><td>{{ c.get('ms', '') }}</td>
<td>{% for i in c.get('ids', []) %}<a href="/ui/n/{{ i }}">{{ i[:8] }}</a> {% endfor %}</td></tr>
{% else %}<tr><td colspan="6" class="empty">keine</td></tr>{% endfor %}
<td class="wrap">{% for i in c.get('ids', []) %}<a href="/ui/n/{{ i }}">{{ i[:8] }}</a> {% endfor %}</td></tr>
{% else %}<tr><td colspan="6" class="empty">none</td></tr>{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+18 -18
View File
@@ -1,33 +1,33 @@
{% extends "base.html" %}
{% block title %}Posteingang — mdmem{% endblock %}
{% block title %}Inbox — mdmem{% endblock %}
{% block content %}
<h1>Posteingang <small>{{ items|length }} unbestätigt</small></h1>
{% if not items %}<p class="empty">Nichts zu prüfen.</p>{% endif %}
<h1>Inbox <small>{{ items|length }} awaiting review</small></h1>
{% if not items %}<p class="empty">Nothing to review.</p>{% endif %}
{% for item in items %}
{% set n = item.note %}
<article class="card" id="n-{{ n.id }}">
<h2><a href="/ui/n/{{ n.id }}">{{ n.title }}</a>
<span class="pill">{{ n.type }}</span>
{% if item.target %}<span class="pill warn">Revision von „{{ item.target.title }}“</span>{% endif %}
{% if n.supersede_chain %}<span class="pill warn">ersetzt {{ n.supersede_chain|length }}</span>{% endif %}
{% if item.target %}<span class="pill warn">Revision of “{{ item.target.title }}”</span>{% endif %}
{% if n.supersede_chain %}<span class="pill warn">replaces {{ n.supersede_chain|length }}</span>{% endif %}
</h2>
<p class="meta">
beobachtet {{ n.observed_at or '–' }} ·
Quelle {{ n.source.get('harness', 'unbekannt') if n.source else 'unbekannt' }}
<span class="hint" title="selbstberichtet, nicht verifiziert">(selbstberichtet)</span>
observed {{ n.observed_at or '–' }} ·
source {{ n.source.get('harness', 'unknown') if n.source else 'unknown' }}
<span class="hint" title="self-reported by the agent, not verified">(self-reported)</span>
</p>
{% if n.summary %}<p class="summary">{{ n.summary }}</p>{% endif %}
<div class="body">{{ render_markdown(n.body, {}) | safe }}</div>
{% if n.keywords %}<p class="kw">{% for k in n.keywords %}<span class="tag">{{ k }}</span>{% endfor %}</p>{% endif %}
{% if item.diff %}
<h3>Änderung gegenüber der aktiven Notiz</h3>
<h3>Change against the active note</h3>
{% set diff = item.diff %}
{% include "partial_diff.html" with context %}
{% endif %}
{% if item.duplicates %}
<h3>Mögliche Duplikate</h3>
<h3>Possible duplicates</h3>
<div class="dupes">
{% for d in item.duplicates %}
<div class="dupe">
@@ -35,7 +35,7 @@
<div class="body small">{{ render_markdown(d.body, {}) | safe }}</div>
<form method="post" action="/ui/n/{{ n.id }}/merge">
<input type="hidden" name="target" value="{{ d.id }}">
<button type="submit">in diese Notiz zusammenführen</button>
<button type="submit">Merge into this note</button>
</form>
</div>
{% endfor %}
@@ -44,19 +44,19 @@
<div class="actions">
<form method="post" action="/ui/n/{{ n.id }}/approve">
<button class="primary" type="submit">Übernehmen</button>
<button class="primary" type="submit">Approve</button>
</form>
<form method="post" action="/ui/n/{{ n.id }}/reject">
<input type="text" name="reason" placeholder="Reason (optional)">
<button class="danger" type="submit">Reject</button>
</form>
<details>
<summary>Bearbeiten &amp; übernehmen</summary>
<summary>Edit &amp; approve</summary>
<form method="post" action="/ui/n/{{ n.id }}/approve">
<textarea name="body" rows="8">{{ n.body }}</textarea>
<button class="primary" type="submit">Bearbeitet übernehmen</button>
<button class="primary" type="submit">Approve edited</button>
</form>
</details>
<form method="post" action="/ui/n/{{ n.id }}/reject">
<input type="text" name="reason" placeholder="Grund (optional)">
<button class="danger" type="submit">Ablehnen</button>
</form>
</div>
</article>
{% endfor %}
+31 -19
View File
@@ -6,7 +6,7 @@
<span class="pill">{{ note.type }}</span>
<span class="pill {% if note.state == 'active' %}ok{% elif note.state == 'inbox' %}warn{% endif %}">{{ note.state }}</span>
</h1>
<p class="meta">id {{ note.id }} · Version {{ note.version[:12] }} · aktualisiert {{ note.updated }}</p>
<p class="meta">id {{ note.id }} · version {{ note.version[:12] }} · updated {{ note.updated }}</p>
{% if note.summary %}<p class="summary">{{ note.summary }}</p>{% endif %}
<div class="body">{{ render_markdown(note.body, resolve) | safe }}</div>
{% if note.keywords %}<p class="kw">{% for k in note.keywords %}<span class="tag">{{ k }}</span>{% endfor %}</p>{% endif %}
@@ -14,64 +14,76 @@
<div class="cols">
<section>
<h3>Ausgehende Links</h3>
<h3>Outgoing links</h3>
<ul>{% for l in note.links %}
<li>{% if l.resolved %}<a href="/ui/n/{{ l.id }}">{{ l.title }}</a>{% else %}<span class="unresolved">{{ l.title }}</span>{% endif %} <span class="rel">{{ l.rel }}</span></li>
{% else %}<li class="empty">keine</li>{% endfor %}</ul>
{% else %}<li class="empty">none</li>{% endfor %}</ul>
</section>
<section>
<h3>Backlinks</h3>
<ul>{% for l in note.backlinks %}
<li><a href="/ui/n/{{ l.id }}">{{ l.title }}</a> <span class="rel">{{ l.rel }}</span></li>
{% else %}<li class="empty">keine</li>{% endfor %}</ul>
{% else %}<li class="empty">none</li>{% endfor %}</ul>
</section>
<section>
<h3>Ersetzungskette</h3>
<h3>Supersede chain</h3>
<ul>{% for l in note.supersede_chain %}
<li><a href="/ui/n/{{ l.id }}">{{ l.title }}</a> <span class="rel">{{ l.rel }}</span></li>
{% else %}<li class="empty">keine</li>{% endfor %}</ul>
{% else %}<li class="empty">none</li>{% endfor %}</ul>
</section>
</div>
{% if note.flags %}
<h3>Meldungen</h3>
<h3>Flags</h3>
<ul>{% for f in note.flags %}<li>{{ f.created }} · {{ f.source }} · {{ f.reason }}</li>{% endfor %}</ul>
{% endif %}
<h3>Verlauf</h3>
<h3>History</h3>
<ul class="history">
{% for h in history|reverse %}
<li><button hx-get="/ui/n/{{ note.id }}/history/{{ h }}" hx-target="#diff-{{ loop.index }}">{{ h }}</button>
<div id="diff-{{ loop.index }}"></div></li>
{% else %}<li class="empty">keine früheren Fassungen</li>{% endfor %}
{% else %}<li class="empty">no earlier versions</li>{% endfor %}
</ul>
{% if history %}
<form class="history-compare" hx-get="/ui/n/{{ note.id }}/history-diff" hx-target="#history-compare-diff">
<label>von <select name="a">
<label>From <select name="a">
{% for h in history %}<option value="{{ h }}" {% if loop.first %}selected{% endif %}>{{ h }}</option>{% endfor %}
</select></label>
<label>bis <select name="b">
<option value="current" selected>aktuell</option>
<label>To <select name="b">
<option value="current" selected>current</option>
{% for h in history|reverse %}<option value="{{ h }}">{{ h }}</option>{% endfor %}
</select></label>
<button type="submit">Vergleichen</button>
<button type="submit">Compare</button>
</form>
<div id="history-compare-diff"></div>
{% endif %}
<div class="actions">
<a class="button" href="/ui/n/{{ note.id }}/edit">Bearbeiten</a>
{% if note.state == 'inbox' %}
{# Review without a round trip: having come here from the inbox to fix the
frontmatter, the decision belongs on this page too. Same route the inbox
posts to, so revision merges and supersede chains behave identically. #}
<form method="post" action="/ui/n/{{ note.id }}/approve">
<button class="primary" type="submit">Approve</button>
</form>
<form method="post" action="/ui/n/{{ note.id }}/reject">
<input type="text" name="reason" placeholder="Reason (optional)">
<button class="danger" type="submit">Reject</button>
</form>
{% endif %}
<a class="button" href="/ui/n/{{ note.id }}/edit">Edit</a>
{% if note.state != 'archive' %}
<form method="post" action="/ui/n/{{ note.id }}/state"><input type="hidden" name="state" value="archive"><button type="submit">Archivieren</button></form>
<form method="post" action="/ui/n/{{ note.id }}/state"><input type="hidden" name="state" value="archive"><button type="submit">Archive</button></form>
{% else %}
<form method="post" action="/ui/n/{{ note.id }}/state"><input type="hidden" name="state" value="active"><button type="submit">Wiederherstellen</button></form>
<form method="post" action="/ui/n/{{ note.id }}/state"><input type="hidden" name="state" value="active"><button type="submit">Restore</button></form>
{% endif %}
<details>
<summary class="danger">Endgültig löschen</summary>
<summary class="danger">Delete permanently</summary>
<form method="post" action="/ui/n/{{ note.id }}/delete">
<p>Datei, Verlauf und Index werden entfernt. Zum Bestätigen die id eingeben:</p>
<p class="meta">The file, its history and its index rows are removed. Type the id to confirm:</p>
<input type="text" name="confirm" placeholder="{{ note.id }}">
<button class="danger" type="submit">Löschen</button>
<button class="danger" type="submit">Delete</button>
</form>
</details>
</div>
+18 -17
View File
@@ -1,34 +1,35 @@
{% extends "base.html" %}
{% block title %}Suche — mdmem{% endblock %}
{% block title %}Search — mdmem{% endblock %}
{% block content %}
<h1>Suche</h1>
<h1>Search</h1>
<form method="get" action="/ui/search" class="filters">
<input type="search" name="q" value="{{ q }}" placeholder="Suchbegriff" autofocus>
<select name="type">
<option value="">alle Typen</option>
<input type="search" name="q" value="{{ q }}" placeholder="Search notes…" aria-label="Query" autofocus>
<select name="type" aria-label="Type">
<option value="">All types</option>
{% for t in ['fact','preference','decision','procedure','entity','event','note'] %}
<option value="{{ t }}" {% if type == t %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
<input type="text" name="tag" value="{{ tag }}" placeholder="Tag">
<select name="state">
<option value="">inbox + aktiv</option>
<option value="active" {% if state == 'active' %}selected{% endif %}>nur aktiv</option>
<option value="archive" {% if state == 'archive' %}selected{% endif %}>inkl. Archiv</option>
<input type="text" name="tag" value="{{ tag }}" placeholder="Tag" aria-label="Tag">
<select name="state" aria-label="Lifecycle state">
<option value="">Inbox + active</option>
<option value="inbox" {% if state == 'inbox' %}selected{% endif %}>Inbox only</option>
<option value="active" {% if state == 'active' %}selected{% endif %}>Active only</option>
<option value="archive" {% if state == 'archive' %}selected{% endif %}>Archive only</option>
</select>
<input type="text" name="since" value="{{ since }}" placeholder="seit (ISO)">
<input type="text" name="until" value="{{ until }}" placeholder="bis (ISO)">
<label><input type="checkbox" name="superseded" value="1" {% if superseded %}checked{% endif %}> ersetzte zeigen</label>
<button type="submit">Suchen</button>
<input type="text" name="since" value="{{ since }}" placeholder="Since (ISO)" aria-label="Since">
<input type="text" name="until" value="{{ until }}" placeholder="Until (ISO)" aria-label="Until">
<label><input type="checkbox" name="superseded" value="1" {% if superseded %}checked{% endif %}> Show superseded</label>
<button class="primary" type="submit">Search</button>
</form>
{% if resp is not none %}
<p class="meta">{{ resp.results|length }} Treffer · Fusion {{ resp.fusion }}{% if resp.truncated %} · gekürzt{% endif %}</p>
<p class="meta">{{ resp.results|length }} hits · fusion {{ resp.fusion }}{% if resp.truncated %} · truncated{% endif %}</p>
{% for r in resp.results %}
<article class="card">
<h2><a href="/ui/n/{{ r.id }}">{{ r.title }}</a>
<span class="pill">{{ r.type }}</span>
<span class="pill {% if r.verified %}ok{% else %}warn{% endif %}">{{ 'geprüft' if r.verified else 'ungeprüft' }}</span>
<span class="pill {% if r.verified %}ok{% else %}warn{% endif %}">{{ 'reviewed' if r.verified else 'unreviewed' }}</span>
</h2>
<p class="snippet">{{ r.snippet }}</p>
<p class="meta" title="lex_rank/sem_rank/cosine/fused">
@@ -43,6 +44,6 @@
</p>
</article>
{% endfor %}
{% if not resp.results %}<p class="empty">Keine Treffer.</p>{% endif %}
{% if not resp.results %}<p class="empty">No hits.</p>{% endif %}
{% endif %}
{% endblock %}
+9 -6
View File
@@ -1,11 +1,14 @@
"""Smoke tests for the committed Kubernetes overlay (PLAN §13a).
The overlay is not directly buildable — `secret.yaml` is SOPS-encrypted and kept out
of the repo, and the image digest plus the PocketID client id are release-time
placeholders. That is deliberate, but until now nothing checked that the rest of it
stays coherent: a renamed environment variable, a probe that points at a port the
container does not expose, or a resource dropped from the kustomization would only
surface on a cluster.
The overlay is not directly buildable: `secret.yaml` is SOPS-encrypted and kept out
of the repo. The image digest and the PocketID client id are filled in, but both are
release-time values — the digest is bumped by hand on every build (§13a.2) — so their
placeholder spellings stay on the allowlist below rather than being treated as a
defect if they reappear.
Nothing else checked that the overlay stayed coherent: a renamed environment
variable, a probe pointing at a port the container does not expose, or a resource
dropped from the kustomization would only surface on a cluster.
"""
from pathlib import Path
+109
View File
@@ -387,3 +387,112 @@ def test_store_vectors_requires_chunk_hash_match(idx):
assert stored == 0
assert db.read().execute("SELECT count(*) FROM chunks_vec").fetchone()[0] == 0
assert db.read().execute("SELECT vec_pending FROM chunks").fetchone()[0] == 1
# -- renames must not orphan the links that still spell the old title --------
def _link_dst(db, src_id):
row = db.read().execute("SELECT dst FROM links WHERE src=? AND rel='links'", (src_id,)).fetchone()
return row["dst"] if row else None
def test_renaming_a_note_keeps_inbound_links_resolved(idx):
"""Resolution re-reads the link text on every reindex, so before the alias
table a rename turned every inbound `[[Old Title]]` into an unresolved link
the next time its source happened to be indexed — silently, and long after
the rename itself."""
indexer, vault, db = idx
target = Note.new(title="Old Title", body="the target", type="fact")
vault.write(target)
indexer.index_note(vault.read(vault.find(target.id)))
source = Note.new(title="Source", body="see [[Old Title]] for detail", type="note")
vault.write(source)
indexer.index_note(vault.read(vault.find(source.id)))
assert _link_dst(db, source.id) == target.id
renamed = vault.read(vault.find(target.id))
renamed.set("title", "New Title")
vault.write(renamed)
indexer.index_note(vault.read(vault.find(target.id)))
# the inbound source is reindexed for some unrelated reason later on
indexer.index_note(vault.read(vault.find(source.id)))
assert _link_dst(db, source.id) == target.id, "the rename orphaned the inbound link"
# and a full rebuild does not detonate them all at once either
indexer.scan(full=True)
assert _link_dst(db, source.id) == target.id
def test_a_new_note_claiming_a_freed_title_beats_the_alias(idx):
"""The alias lookup runs last: link text names whatever holds that title
now, not whatever held it first."""
indexer, vault, db = idx
first = Note.new(title="Shared Title", body="the original", type="fact")
vault.write(first)
indexer.index_note(vault.read(vault.find(first.id)))
renamed = vault.read(vault.find(first.id))
renamed.set("title", "Renamed Away")
vault.write(renamed)
indexer.index_note(vault.read(vault.find(first.id)))
second = Note.new(title="Shared Title", body="the newcomer", type="fact")
vault.write(second)
indexer.index_note(vault.read(vault.find(second.id)))
source = Note.new(title="Source", body="points at [[Shared Title]]", type="note")
vault.write(source)
indexer.index_note(vault.read(vault.find(source.id)))
assert _link_dst(db, source.id) == second.id
def test_deleting_a_note_drops_its_former_titles(idx):
"""Otherwise a dead ULID keeps winning resolution for a title nothing holds."""
indexer, vault, db = idx
target = Note.new(title="Doomed", body="x", type="fact")
vault.write(target)
indexer.index_note(vault.read(vault.find(target.id)))
indexer.remove(target.id)
assert db.read().execute(
"SELECT count(*) FROM note_titles WHERE note_id=?", (target.id,)
).fetchone()[0] == 0
source = Note.new(title="Source", body="points at [[Doomed]]", type="note")
vault.write(source)
indexer.index_note(vault.read(vault.find(source.id)))
assert _link_dst(db, source.id) == "unresolved:Doomed"
def test_existing_indexes_are_backfilled_so_the_first_rename_is_safe(tmp_path):
"""An upgraded index only learns a title when the note is next reindexed, and
an unchanged note never is — so without a backfill the first rename after the
upgrade would still break its inbound links."""
vault = Vault(tmp_path / "vault", tmp_path / ".history")
vault.ensure_dirs()
db = Database(tmp_path / "index.db", dim=8, trigram=True)
db.open()
indexer = Indexer(db, vault)
target = Note.new(title="Before Upgrade", body="x", type="fact")
vault.write(target)
indexer.index_note(vault.read(vault.find(target.id)))
source = Note.new(title="Source", body="see [[Before Upgrade]]", type="note")
vault.write(source)
indexer.index_note(vault.read(vault.find(source.id)))
# simulate an index that predates the alias table
with db.write() as w:
w.execute("DELETE FROM note_titles")
db.close()
db2 = Database(tmp_path / "index.db", dim=8, trigram=True)
db2.open() # the migration backfills from the notes table
try:
indexer2 = Indexer(db2, vault)
renamed = vault.read(vault.find(target.id))
renamed.set("title", "After Upgrade")
vault.write(renamed)
indexer2.index_note(vault.read(vault.find(target.id)))
indexer2.index_note(vault.read(vault.find(source.id)))
assert _link_dst(db2, source.id) == target.id
finally:
db2.close()
+124 -1
View File
@@ -260,7 +260,7 @@ async def test_a_valid_session_gets_in(app_and_svc):
cookie = codec.issue(Principal(sub="u1", name="Markus", email=""))
async with _client(app, cookie) as c:
r = await c.get("/ui/inbox")
assert r.status_code == 200 and "Posteingang" in r.text
assert r.status_code == 200 and "Inbox" in r.text
async def test_probes_and_metrics_stay_open(app_and_svc):
@@ -295,6 +295,17 @@ async def test_security_headers_are_set(app_and_svc):
r = await c.get("/ui/inbox")
assert r.headers["content-security-policy"].startswith("default-src 'self'")
assert r.headers["x-frame-options"] == "DENY"
# Per the Fetch standard these policies make a browser send `Origin: null`
# on an HTML form submission. Every write in this UI is a form POST and the
# CSRF guard fails closed, so shipping one of them silently refuses every
# write in a real browser while every test that sets Origin by hand passes.
assert r.headers["referrer-policy"] not in {
"no-referrer",
"no-referrer-when-downgrade",
"strict-origin",
"strict-origin-when-cross-origin",
}
assert r.headers["referrer-policy"] == "same-origin"
async def test_post_from_a_foreign_origin_is_refused(app_and_svc):
@@ -416,3 +427,115 @@ async def test_auth_disabled_is_loudly_logged_and_lets_requests_through(settings
assert (await c.get("/ui/inbox")).status_code == 200
finally:
await svc.stop()
async def test_post_survives_a_proxy_that_forwards_the_default_port(app_and_svc):
"""Browsers omit the default port from Origin; Traefik forwards Host with it.
Comparing the two raw refused every state-changing POST in the deployed UI."""
app, svc = app_and_svc
cookie = SessionCodec(svc.settings.session_secret, 12).issue(Principal("u1", "M", ""))
w = await svc.write_note(title="T", body="v1", type="fact")
for host in (
"memory.cloud.traunseenet.com:443", # proxy keeps the default port
"MEMORY.Cloud.Traunseenet.com", # case differs
"memory.cloud.traunseenet.com", # the plain form still works
):
async with _client(app, cookie) as c:
r = await c.post(
f"/ui/n/{w['id']}/state",
data={"state": "archive"},
headers={"origin": "https://memory.cloud.traunseenet.com", "host": host},
)
assert r.status_code != 403, f"host {host!r} was refused"
async def test_a_foreign_host_is_still_refused_with_an_allowed_origin(app_and_svc):
"""The normalisation must not weaken what the check exists for (M-10)."""
app, svc = app_and_svc
cookie = SessionCodec(svc.settings.session_secret, 12).issue(Principal("u1", "M", ""))
w = await svc.write_note(title="T2", body="v1", type="fact")
for host in ("evil.example", "evil.example:443", "memory.cloud.traunseenet.com.evil.example"):
async with _client(app, cookie) as c:
r = await c.post(
f"/ui/n/{w['id']}/state",
data={"state": "archive"},
headers={"origin": "https://memory.cloud.traunseenet.com", "host": host},
)
assert r.status_code == 403, f"host {host!r} was accepted"
async def test_a_refused_write_is_logged_with_what_was_compared(app_and_svc, caplog):
"""uvicorn's access log is off, so a silent 403 was only visible in the
browser — a proxy spelling the authority differently looked like a UI bug."""
import logging as _logging
app, svc = app_and_svc
cookie = SessionCodec(svc.settings.session_secret, 12).issue(Principal("u1", "M", ""))
with caplog.at_level(_logging.WARNING, logger="mdmem.ui"):
async with _client(app, cookie) as c:
r = await c.post(
"/ui/n/whatever/state",
data={"state": "archive"},
headers={"origin": "https://evil.example", "host": "evil.example"},
)
assert r.status_code == 403
record = next(r for r in caplog.records if "CSRF" in r.message)
fields = record.fields
assert fields["origin"] == "https://evil.example"
assert fields["host"] == "evil.example"
assert fields["reason"] == "origin not allowed"
assert "https://memory.cloud.traunseenet.com" in fields["allowed"]
assert fields["path"] == "/ui/n/whatever/state"
async def test_a_write_with_no_origin_header_says_so_in_the_log(app_and_svc, caplog):
import logging as _logging
app, svc = app_and_svc
cookie = SessionCodec(svc.settings.session_secret, 12).issue(Principal("u1", "M", ""))
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="https://memory.cloud.traunseenet.com",
cookies={SESSION_COOKIE: cookie},
) as c:
with caplog.at_level(_logging.WARNING, logger="mdmem.ui"):
r = await c.post("/ui/n/whatever/state", data={"state": "archive"})
assert r.status_code == 403
record = next(r for r in caplog.records if "CSRF" in r.message)
assert record.fields["reason"] == "no Origin or Referer"
async def test_an_opaque_origin_is_refused_and_named_in_the_log(app_and_svc, caplog):
"""`Origin: null` is what a browser sends for a form POST under
Referrer-Policy: no-referrer. Refusing it is right, but the log has to say
*why* — it means something stripped the origin, not that the caller is on
the wrong host."""
import logging as _logging
app, svc = app_and_svc
cookie = SessionCodec(svc.settings.session_secret, 12).issue(Principal("u1", "M", ""))
with caplog.at_level(_logging.WARNING, logger="mdmem.ui"):
async with _client(app, cookie) as c:
r = await c.post(
"/ui/n/whatever/state", data={"state": "archive"}, headers={"origin": "null"}
)
assert r.status_code == 403
record = next(r for r in caplog.records if "CSRF" in r.message)
assert "opaque origin" in record.fields["reason"]
async def test_a_real_browser_form_post_is_accepted(app_and_svc):
"""The end-to-end shape a browser actually sends for a same-origin form POST
under the policy this app ships: a real Origin, no Referer."""
app, svc = app_and_svc
cookie = SessionCodec(svc.settings.session_secret, 12).issue(Principal("u1", "M", ""))
w = await svc.write_note(title="Form post", body="v1", type="fact")
async with _client(app, cookie) as c:
r = await c.post(
f"/ui/n/{w['id']}/approve",
data={},
headers={"origin": "https://memory.cloud.traunseenet.com"},
)
assert r.status_code == 303
assert svc.get_note(w["id"]).state == "active"
+47 -6
View File
@@ -80,7 +80,7 @@ async def test_revision_shows_a_diff_and_merges_into_the_target(ui):
proposal = await svc.update_note(base["id"], version, body="Der Wert ist 12")
page = await client.get("/ui/inbox")
assert "Revision von" in page.text
assert "Revision of" in page.text
assert "-Der Wert ist 10" in page.text and "+Der Wert ist 12" in page.text
r = await client.post(f"/ui/n/{proposal['id']}/approve", data={})
@@ -101,7 +101,7 @@ async def test_duplicates_are_offered_for_merging(ui):
assert second["possible_duplicates"], "an identical note should be flagged as a duplicate"
page = await client.get("/ui/inbox")
assert "Mögliche Duplikate" in page.text
assert "Possible duplicates" in page.text
await client.post(f"/ui/n/{second['id']}/merge", data={"target": first["id"]})
assert svc.get_note(second["id"]).state == "archive"
@@ -118,7 +118,7 @@ async def test_note_page_shows_links_backlinks_and_history(ui):
page = await client.get(f"/ui/n/{src['id']}")
assert "evo-x2" in page.text and "Backlinks" in page.text
assert "Verlauf" in page.text and "keine früheren Fassungen" not in page.text
assert "History" in page.text and "no earlier versions" not in page.text
back = await client.get(f"/ui/n/{target['id']}")
assert "llama-swap" in back.text
@@ -153,7 +153,7 @@ async def test_edit_conflict_shows_a_diff_and_keeps_both_versions(ui):
data={"body": "meine Fassung", "frontmatter": "", "expected_version": stale},
)
assert r.status_code == 409
assert "Konflikt" in r.text
assert "Conflict" in r.text
assert "meine Fassung" in r.text and "von woanders geändert" in r.text
assert svc.get_note(res["id"]).body.strip() == "von woanders geändert"
@@ -396,7 +396,7 @@ async def test_health_page_shows_the_audit_source_and_eval_action(ui):
svc.audit.record(tool="memory_write", args="deadbeef", source="mcp", ok=True, ids=[], ms=3.0)
r = await client.get("/ui/health")
assert "mcp" in r.text
assert "Eval starten" in r.text
assert "Run eval" in r.text
async def test_run_eval_is_auth_and_csrf_protected_like_the_other_health_actions(ui):
@@ -460,7 +460,7 @@ async def test_run_eval_keeps_its_files_on_the_data_volume_and_reports_status(ui
# and the health page renders the status rather than swallowing it
page = await client.get("/ui/health", params={"eval": f"started:{data_dir.name}.json"})
assert page.status_code == 200 and "Eval gestartet" in page.text
assert page.status_code == 200 and "Eval started" in page.text
async def test_search_state_filter_still_finds_matches_below_the_first_page(ui):
@@ -476,3 +476,44 @@ async def test_search_state_filter_still_finds_matches_below_the_first_page(ui):
r = await client.get("/ui/search", params={"q": "Bifrost routet Modelle", "state": "inbox"})
assert r.status_code == 200
assert needle["id"] in r.text
async def test_an_inbox_note_can_be_reviewed_from_its_own_page(ui):
"""Fixing an LLM's frontmatter happens on the note page; without approve here
the owner has to navigate back to the inbox to act on what they just fixed."""
client, svc = ui
w = await svc.write_note(title="Needs a fix", body="v1", type="fact")
page = await client.get(f"/ui/n/{w['id']}")
assert "Approve" in page.text and "Reject" in page.text
r = await client.post(f"/ui/n/{w['id']}/approve", data={})
assert r.status_code == 303
assert svc.get_note(w["id"]).state == "active"
# and an active note offers no review buttons, only archive/edit/delete
page = await client.get(f"/ui/n/{w['id']}")
assert "/approve" not in page.text and "/reject" not in page.text
async def test_frontmatter_fixed_on_the_note_page_survives_approval(ui):
"""The whole point of the button: correct what the agent got wrong, then
accept it, without the fix being lost on the way."""
client, svc = ui
w = await svc.write_note(title="Wrong type", body="v1", type="note", tags=["bogus"])
note = svc.get_note(w["id"])
fm = note.to_text().split("---\n")[1].replace("type: note", "type: fact")
fm = fm.replace("- bogus", "- homelab")
saved = await client.post(
f"/ui/n/{w['id']}/edit",
data={"body": "v1 corrected", "frontmatter": fm, "expected_version": note.file_hash},
)
assert saved.status_code in (200, 303)
assert (await client.post(f"/ui/n/{w['id']}/approve", data={})).status_code == 303
approved = svc.get_note(w["id"])
assert approved.state == "active"
assert approved.type == "fact"
assert approved.tags == ["homelab"]
assert approved.body.strip() == "v1 corrected"