Commit Graph
10 Commits
Author SHA1 Message Date
libretechandClaude Opus 4.7 db3b99edcc Add Dockerfile, Compose stacks, and /healthz endpoint
Dockerfile is multi-stage:
- build: golang:1.25-bookworm, CGO_ENABLED=0 (modernc.org/sqlite
  is pure-Go) + -trimpath + -ldflags "-s -w" so the resulting
  binary is small and reproducible-ish.
- runtime: gcr.io/distroless/static:nonroot, ~2 MB. Runs as uid
  65532. /data and /var/lib/librenotes are declared volumes so
  per-tenant notes and the SQLite database survive container
  restarts.

healthcheck subcommand: distroless static has no shell or
wget/curl, so /healthz is reachable but no client to call it. A
new "librenotes healthcheck" subcommand uses net/http to GET
$LIBRENOTES_HEALTHCHECK_URL (default 127.0.0.1:8080/healthz) and
exits non-zero on failure. Both compose files invoke it from the
HEALTHCHECK directive.

httpapi adds a tiny GET /healthz that returns {"status":"ok"}
(no DB ping yet — added when readiness probes need it).

docker-compose.yml: dev stack on :8080 with named volumes and a
LogMailer; everything via env vars, JWT secret defaulted to a
dev value.
docker-compose.prod.yml: layered overrides — pulls a registry
image, expects LIBRENOTES_* env, sets memory limits and JSON-
file log rotation.

Closes #25.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:47:42 +02:00
libretechandClaude Opus 4.7 d71db09d45 Add background sync controller with conflict detection
cmd/librenotes/web/public/sync.js drives the offline-online
reconciliation flow against the notes REST API:

- start(): registers online/offline window listeners, runs an
  initial syncOnce() if currently online.
- syncOnce(): push() then pull(); emits "librenotes:sync-state"
  events with state in {online, offline, syncing, synced, error}.
- push(): walks notesCache.pending() (rows with dirty=1, including
  tombstones). PUTs use ?base=<synced_at> for optimistic locking
  and DELETEs use the same. The notes API returns 409 with the
  current server body on conflict; sync.js stashes the pair in
  conflictsById and dispatches "librenotes:sync-conflict" so the
  app shell can render a resolution dialog.
- pull(): GETs the summary list, refetches any row whose server
  updated_at exceeds the local synced_at (or that is missing
  locally), and stamps it as cleanly synced. Skips locally-dirty
  rows so push's conflict path stays authoritative.
- resolveConflict(id, "local"|"remote"|"merge", merged): replays
  the user's choice. "local" and "merge" PUT with the latest
  server base so the second attempt accepts; "remote" overwrites
  the local cache with the server copy.

app.html now includes a sync-state badge in the header and a
<dialog> for conflict resolution wired to the events. app.js
calls notesSync.start() on load and routes dialog clicks back to
resolveConflict. The dialog uses native <dialog>.showModal(),
which all current target browsers support.

style.css adds badge colour states (syncing/synced/offline/error)
and a two-column conflict layout that collapses on narrow widths.

Closes #23.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:45:25 +02:00
libretechandClaude Opus 4.7 3208b3c92a Add IndexedDB-backed offline notes cache
cmd/librenotes/web/public/notes-cache.js exposes window.notesCache
with the offline-first read/write API the rest of the app uses:

Schema (object store "notes", key path "id"):
  { id, title, content, updated_at, synced_at, dirty, deleted }
plus a denormalised dirty_idx:0|1 column because IndexedDB cannot
index booleans directly. Two indexes — by_updated_at for sorted
listing, by_dirty for the sync controller's pending-queue scan.

Per-tenant database name "librenotes-notes-{user_id}" so two
users on the same browser have fully separate offline caches and
clearAll() (called from authClient.clearSession on logout) drops
only the leaving user's data.

Public surface:
- list/get/put/remove: straight CRUD.
- markDirty(id, patch): stage an offline edit. Bumps updated_at
  to now() but preserves synced_at so the sync controller can
  detect server-side concurrent edits via the ?base=<unix> 409.
- markDeleted(id): tombstone (deleted:true, dirty:true) so the
  sync controller can replay the delete on reconnect.
- markSynced(id, serverUpdatedAt): clear dirty + record
  serverUpdatedAt as synced_at; if tombstone, drop entirely.
- pending(): returns dirty rows for the sync queue.
- estimateUsage(): wraps navigator.storage.estimate so the UI
  can warn before quota.

Quota errors are remapped to a typed err.code === "QUOTA" so the
UI can show "out of space" instead of a generic failure.

The service worker precache list now includes notes-cache.js and
sync.js so the offline shell has the cache layer too.

Closes #22.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:43:44 +02:00
libretechandClaude Opus 4.7 f9c941f723 Add tenant-scoped notes REST API
internal/httpapi/notes.go exposes:
- GET    /api/notes            list summaries {id, title, updated_at}
- GET    /api/notes/{id}       full {id, title, content, updated_at}
- PUT    /api/notes/{id}       create/update; ?base=<unix> for
                                optimistic-locking conflict detection
- DELETE /api/notes/{id}       remove; ?base=<unix> guards against
                                deleting a row modified after the
                                client last saw it

Backed by tenant.FS so all reads/writes go through the per-user
sandbox — path traversal is rejected at parse time (regex slug)
and again by os.Root inside the FS layer.

On-disk format is plain Markdown: first line `# Title`, rest is
content. grep / cat / vim still produce a usable view of raw
files. Title round-trips through composeNote/splitTitle.

Conflict semantics: when the client supplies ?base=<unix>, the
server compares against the file's mtime. If the file is newer,
respond 409 with the current note body so the client can present
a merge UI. Same logic on DELETE returns 409 alone.

cmd/librenotes/serve.go grows a tenantPool that memoises FS
handles per user id; defer-closes them on shutdown.

Tests cover: full CRUD round-trip, cross-tenant isolation,
unauthenticated 401s, invalid IDs (regex rejection), and the
conflict path with a real mtime advance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:42:43 +02:00
libretechandClaude Opus 4.7 07b28133c8 Overhaul CSS for 320px–2560px viewports
style.css now has explicit breakpoints and primitives covering
the full target range:

- Global: overflow-x: hidden on body, max-width:100% on media,
  fluid typography via clamp() so headings shrink on 320px.
- .wrap: 64rem cap at desktop, 72rem at 1440px, 96rem at 2560px;
  generous side padding at large widths so text doesn't hug the
  edge on huge monitors.
- .app-shell layout primitive (grid: sidebar + content [+ aside
  on ultrawide]) ready for the eventual notes UI:
    * mobile: single column, sidebar hidden behind a toggle
      ([data-sidebar="open"] reveals it).
    * 768px+: 2-column with 16rem sidebar.
    * 1024px+: 18rem sidebar.
    * 1440px+: 20rem sidebar, content max-width 56rem so reading
      lines don't grow unbounded.
    * 2560px+: 3-column (sidebar | content | aside) so the
      editor stays at reading width while the extra real estate
      hosts backlinks/preview.
- .app-resize-handle with touch-action:none so pointer-event
  drag handlers won't conflict with browser scrolling.
- Auth card tightens on viewports under 360px.

Result: no horizontal scroll at any width; content uses ultrawide
space effectively without sacrificing legibility.

Closes #18.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:28:19 +02:00
libretechandClaude Opus 4.7 d4e47f5761 Add PWA manifest, service worker, and install prompt
- manifest.webmanifest: standalone display mode, theme #2563eb,
  start_url=/app.html (so users who install land in the app
  shell, not the marketing page), scope=/. Three icons: 192px
  any-purpose, 512px any-purpose, 512px maskable for adaptive
  icons on Android.
- icons/: PNGs generated from favicon.svg.
- sw.js: cache-first for the precached app shell, network-first
  for /api/* and /auth/* (we never serve stale auth or notes).
  Versioned cache name (librenotes-shell-v1) so a SW update
  evicts old assets. skipWaiting + clients.claim so a new SW
  takes over without a manual reload.
- pwa.js: registers the SW on every page and handles
  beforeinstallprompt by showing #install-btn. Hides the button
  again on appinstalled. Defer loaded so it never blocks render.
- All HTML pages link the manifest, set the theme-color meta,
  and load pwa.js. Landing page exposes the install button next
  to the existing CTAs.

Closes #19.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:27:32 +02:00
libretechandClaude Opus 4.7 a42b7fa345 Add JWT session client and tenant-scoped storage
cmd/librenotes/web/public/auth-client.js exposes window.authClient
with the full session API used by the rest of the frontend:

Session storage (#14):
- saveSession / loadSession / clearSession / isAuthenticated
- Backed by sessionStorage, not localStorage: tokens are isolated
  per tab and cleared on tab close. localStorage would survive
  tab close on a shared device, which we want to avoid.
- loadSession returns null when expires_at has passed, so callers
  treat expired sessions as logged-out without a network round
  trip.

API wrapper (#14):
- apiFetch(url, init) attaches Authorization: Bearer <jwt> to
  every call. On 401 it clears the session and redirects to
  /login.html?next=<current-path> so the user returns where they
  started. Throws after the redirect so the caller's .then does
  not run with stale data.

Tenant-scoped localStorage (#15):
- tenantStore() returns a get/set/remove wrapper whose keys are
  prefixed "librenotes:{user_id}:". Two users on the same browser
  therefore have fully independent UI state. JSON serialisation
  with try/catch fallbacks for corrupted or quota-exceeded
  storage so a bad blob never crashes the app.
- clearTenantStore(userID) removes every key with that prefix.
  Called from clearSession() so logout wipes both the JWT and
  the user's preferences.

verify.html + verify.js complete the magic-link flow: read
?token=, POST /auth/verify, hand the response to saveSession(),
strip the token from the URL via history.replaceState. Errors
route the user back to /login.html.

app.html + app.js are a minimal authenticated landing demonstrating
the full stack end-to-end: apiFetch hits /api/whoami, tenantStore
persists a theme preference, logout clears both. The full notes
UI is left to a later phase — this is the seam.

Closes #14 and #15.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:25:07 +02:00
libretechandClaude Opus 4.7 fab1896056 Add magic-link login UI with client-side validation
cmd/librenotes/web/public/login.{html,js}:
- Email input with required + autocomplete + autofocus, ARIA
  attributes for screen readers (aria-describedby, aria-invalid,
  role="alert" on the error container, role="status" on success).
- Client-side regex validation runs before POST to /auth/login
  to avoid a network round-trip for obvious typos. Server is
  still the source of truth.
- Loading state disables the button and changes its label.
- Success state replaces the form with "Check your email"
  including the address, plus the 15-minute / single-use note.
- Error states map server statuses to user-friendly messages:
  429 -> "too many requests", 400 -> "invalid email", anything
  else -> generic server error. Network errors get their own
  message so users can distinguish offline from server problems.
- No external CSS or JS dependencies; works with keyboard and
  on small viewports.

Closes #13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:24:48 +02:00
libretechandClaude Opus 4.7 f63c7a7e63 Add librenotes serve command and public landing page
cmd/librenotes/serve.go wires the multi-tenant HTTP server:
storage + auth + httpapi packages, configurable via flags or
LIBRENOTES_* env vars. Embeds web/public/ for unauthenticated
static content. Generates an ephemeral JWT secret with a warning
when none is supplied. Adds security headers (CSP, nosniff,
DENY-frame, no-referrer) on every response. Background goroutine
purges expired magic-link tokens every 10 minutes.

cmd/librenotes/web/public/ provides the unauthenticated frontend:
- index.html: hero, features grid, fork attribution, footer.
  Mobile-first, responsive from 320px up via clamp() and
  auto-fit grid. SEO + Open Graph tags. No JS dependency.
- privacy.html: placeholder privacy policy (full text TBD).
- style.css: shared design tokens (light/dark via [data-theme]),
  used by landing, auth pages, and the post-login app shell.
- favicon.svg: minimal mark.

The "serve" command sits alongside the original notesium CLI
verbs; main.go dispatches "serve" to the new code path and
forwards everything else to notesium.Run().

Closes #16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:24:37 +02:00
libretechandClaude Opus 4.7 df9407dd8d Fork Notesium source and restructure into Go package layout
Initial fork of github.com/alonswartz/notesium into librenotes:
- Source moved to internal/notesium/ (package notesium)
- Thin entry point at cmd/librenotes/main.go
- Module renamed to git.librete.ch/public/librenotes
- main() exposed as notesium.Run()
- LICENSE preserved (MIT), NOTICE added with attribution
- Web assets and completion.bash co-located with embedding code
  to satisfy go:embed path constraints

Closes #3, #34, #35.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:52:25 +02:00