30 Commits
Author SHA1 Message Date
libretech 12834650dc docs(changelog): set v0.1.0 release date to 2026-04-29
Deploy / build (push) Has been skipped
Deploy / deploy (push) Has been skipped
CI / ci (push) Failing after 13m13s
2026-04-29 01:29:58 +02:00
libretech 9fd29a31c3 feat(deploy): add netcup compose overlay (build local, edge net, no host port)
Deploy / build (push) Has been skipped
Deploy / deploy (push) Has been skipped
CI / ci (push) Successful in 13m0s
2026-04-29 00:59:58 +02:00
libretechandClaude Opus 4.7 8577a19ba1 Refresh Wave pipelines, personas, and contracts
Local-only Wave configuration sync:
- Drop one-shot contract schemas no longer referenced by any
  pipeline.
- Replace gitea/github issue-impl pipelines with the unified
  refresh/research/rewrite/scope set; add bb (bitbucket) and gl
  (gitlab) variants.
- Add scoper persona and matching scope contracts.
- Update existing personas and pipelines to current style.

No effect on the librenotes runtime; this only touches the
.wave/ tooling directory used by the local dev harness.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 23:25:18 +02:00
libretechandClaude Opus 4.7 3b3cb57aa2 Add CHANGELOG and launch validation checklist
CHANGELOG.md follows Keep-a-Changelog. The Unreleased section
sits at the top; the v0.1.0 entry summarises every commit on
the path from the empty fork to a hosted multi-tenant build:
fork + restructure, storage/auth/tenant/httpapi packages, the
serve command, full frontend (landing, login, verify, app shell,
PWA, sync), Docker + deploy + backup tooling, docs, and the
community infrastructure. Two known-incomplete items called out
explicitly: the upstream Notesium UI is not yet wired into the
multi-tenant shell, and SMTP delivery has only been exercised
against the LogMailer.

docs/launch-checklist.md is the manual QA pass for v0.1.0:
environment prerequisites, the full sign-up to first-note flow
on Chrome / Firefox / iOS Safari / Android Chrome (with explicit
PWA-install and offline-shell verifications), documentation
accuracy spot-checks, the community-infra render checks, and a
backup-and-restore round-trip on the staging host. The QA
performer signs off at the bottom; failures are filed as
separate bugs against the milestone.

The actual v0.1.0 tag will be cut by a maintainer; tagging is
not part of this commit.

Refs #30 and #33.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 23:20:20 +02:00
libretechandClaude Opus 4.7 3b8847efa1 Add first-run onboarding flow
cmd/librenotes/web/public/onboarding.js triggers after a session
is verified and runs only when the tenant-scoped key
"onboarded" is unset.

Behaviour:
- Reads the dismissal flag from authClient.tenantStore() so each
  tenant's state is isolated and survives logout-then-login on
  the same device only if they're the same user.
- Lists /api/notes; if the notebook is empty, PUTs a sample
  "welcome" note so a brand-new user has somewhere to land. We
  don't seed when notes already exist (covers signing in on a
  second device for the first time).
- Opens app.html's <dialog id="onboarding-dialog"> with showModal
  and persists "onboarded": true on submit so returning users
  never see it again.
- Seed failures are logged but do not block the dialog —
  onboarding shouldn't depend on a successful network round
  trip. The dialog just won't have a sample note to point at.

Dialog content covers the four user-guide concepts: notes-as-
markdown, [[wikilinks]], offline-first sync, tenant isolation.

Service worker precaches onboarding.js so the dialog is also
available to offline-first returning visitors.

Closes #32.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 23:19:13 +02:00
libretechandClaude Opus 4.7 e3c86a92c0 Add community infrastructure: issue + PR templates, CoC
Gitea issue templates (.gitea/issue_template/):
- bug.yml: structured form requiring version, environment,
  what-happened, repro steps, expected, optional logs. Routes
  security reports to security@librete.ch instead of public
  issues.
- feature.yml: prompts for the underlying problem before the
  proposed solution, plus alternatives and out-of-scope.

Pull request template (.gitea/PULL_REQUEST_TEMPLATE.md):
checklist for tests, lint, manual exercise, docs, and changelog.
Asks for explicit reviewer notes so trade-offs surface in the
PR description rather than being lost in chat.

CODE_OF_CONDUCT.md: links to Contributor Covenant 2.1 verbatim
rather than inlining; documents scope, reporting address
(conduct@librete.ch), and points enforcement at the Covenant's
own Enforcement Guidelines.

README links the docs/ tree, CONTRIBUTING, and the CoC so new
contributors find the entry points.

Closes #31.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 23:18:10 +02:00
libretechandClaude Opus 4.7 61edef9483 Add user guide, self-hosting, API, and contributing docs
docs/user-guide.md — magic-link sign-in, note basics, wikilink
syntax, keyboard shortcuts, offline behaviour, and privacy
notes (sessionStorage for tokens, tenant-scoped localStorage).

docs/self-hosting.md — system requirements, Docker Compose
quick-start, the full LIBRENOTES_* env-var matrix (which are
required, which conditional), reverse proxy snippets for Caddy
and nginx, volume layout, the in-binary healthcheck, and
update/rollback procedure.

docs/api.md — every public endpoint: auth (login/verify),
notes CRUD, /api/whoami, /healthz. Status codes per endpoint,
the optimistic-locking ?base=<unix> contract for PUT/DELETE,
note-ID regex, and the rate-limit policy.

CONTRIBUTING.md — dev setup (Nix flake .#dev, plain Go, Docker),
package layout overview, coding standards (one-way dep flow,
tenant FS gateway requirement), branch naming, commit format,
and the PR process. Also points security reports at
security@librete.ch rather than public issues.

Closes #29.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:52:09 +02:00
libretechandClaude Opus 4.7 bcccba92f7 Add deploy workflow and backup tooling
CI deployment (.gitea/workflows/deploy.yml):
- Two jobs (build, deploy) gated on the repo variable
  DEPLOY_ENABLED=true so the workflow exists but does nothing
  until secrets and host are configured.
- Build pushes two image tags per run: rolling :main + the short
  SHA on main, or vX.Y.Z + :latest on tag pushes. Immutable per
  commit/tag tags make rollback trivial.
- Deploy SSHes to DEPLOY_HOST, runs docker compose pull && up -d
  in DEPLOY_PATH, then polls HEALTH_URL for up to a minute. A
  failed health check fails the workflow, which is the alert.
- Required secrets and the rollback procedure are documented in
  docs/operations.md.

Backup tooling (scripts/):
- backup.sh: SQLite online .backup snapshot + tarball of the
  per-tenant data dir + info.txt header, all wrapped into a
  single librenotes-YYYYMMDD-HHMMSS.tar.gz. Optional BACKUP_REMOTE
  triggers an rclone copy for off-site storage.
- backup-prune.sh: enforces retention "30 daily + 12 monthly".
  Sorts archives by filename (date is in the name so lex order
  matches chronological) and keeps the newest 30 plus the newest
  archive for each of the most recent 12 months.
- backup-restore-test.sh: extracts the most recent archive into
  a tmpdir, runs sqlite3 .schema (proves DB readability), and
  asserts the notes tar has at least one entry. Failure is the
  alert. Wired into a separate weekly timer.
- librenotes-backup.{service,timer}: systemd units for the daily
  03:17 UTC run with 5min jitter; ProtectSystem=strict, only
  /var/backups/librenotes is writable.
- librenotes-backup-verify.{service,timer}: weekly Monday
  04:00 UTC restore test.

Closes #26 and #27.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:49:40 +02:00
libretechandClaude Opus 4.7 635a03098b 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 f166485012 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 593e311a8a 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 cdc7f26269 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 49ad467aa9 Switch pane resize handle to pointer events
internal/notesium/web/app/pane.js drove the sidebar/pane resize
via mousedown + window-level mousemove/mouseup, which doesn't
fire on touch (browsers only emulate mouse for taps, not drags).

Replaced with pointer events:
- @pointerdown on the handle (covers mouse, touch, pen).
- setPointerCapture so we keep receiving pointermove/pointerup
  events when the pointer drifts off the handle. This eliminates
  the need for document-level listeners and avoids stuck-drag
  states when the user releases outside the window.
- pointermove + pointerup + pointercancel listeners on the
  captured target only — when the capture ends they're removed
  regardless of whether the user is still on top of the handle.
- Filter on event.pointerId so a second simultaneous touch
  (e.g., a multi-finger gesture) cannot hijack the in-progress
  resize.
- event.button !== 0 guard rejects right-click / middle-click.
- touch-action: none on the handle so the browser doesn't try
  to interpret a horizontal drag as a page scroll.

CodeMirror's internal mousedown handlers in note.js / preview.js
are left alone — those are link-click guards, not drags, and
CodeMirror's own pointer support handles touch internally.

Closes #20.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:30:43 +02:00
libretechandClaude Opus 4.7 e6d3893308 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 7c3b40c963 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 274c7054d0 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 dc5a08e682 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 ee4de51728 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 db3b6c1b5a Add tenant-aware HTTP middleware and router
internal/httpapi/ provides:
- Tenant{UserID, Email} carried on context.Context, with
  WithTenant / TenantFrom helpers and ErrNoTenant for the
  programming-error case (route reached without middleware).
- AuthMiddleware verifies an Authorization: Bearer <jwt> on every
  request via auth.Signer.Verify (which already enforces HS256
  and rejects alg=none). On failure: 401, with the underlying
  reason logged server-side but not exposed to the client.
- RequireTenantOwnership(ownerID) compares the request's tenant
  against the resource owner; returns 403 on mismatch. Handlers
  that touch tenant-owned resources call this guard.
- Server.Routes() mounts /auth/* unauthenticated and wraps
  /api/* with the middleware. /api/whoami is included as the
  canonical example of a tenant-scoped endpoint.

Tests cover: valid JWT pass-through, missing/empty Authorization,
wrong scheme, malformed JWT, tampered signature, JWT signed with
a different secret (cross-tenant key confusion), and the 200/403
matrix for RequireTenantOwnership.

Closes #11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:19:09 +02:00
libretechandClaude Opus 4.7 c9b8c4445b Add per-tenant filesystem isolation
internal/tenant/ provides FS, a sandboxed handle for a single
tenant's notes directory. Implementation strategy:

- Defence in depth: every relative path is validated up front
  (rejects "..", absolute paths, NUL bytes, empty), then handed
  to os.Root (Go 1.24+) which enforces the boundary at the
  syscall layer using openat(2)+RESOLVE_BENEATH on Linux. This
  closes TOCTOU races and symlink-target swapping.
- WriteFile is atomic (write to .tmp, rename in-root). Mode 0o600
  on files, 0o700 on directories. Tenant root is created with
  0o700 by Open().
- Errors are normalised: fs.ErrNotExist -> ErrNotFound, anything
  os.Root rejects as "outside" the root -> ErrInvalidPath. The
  HTTP layer can map cleanly to 404 / 400.

Tests cover the full traversal attack surface — "../", absolute
paths, mixed separators, NUL bytes, "." and "" — plus symlink
escapes and cross-tenant isolation. All vectors return errors;
none escape the root.

Closes #10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:17:38 +02:00
libretechandClaude Opus 4.7 d9f3574913 Implement email magic-link authentication
internal/auth/ provides:
- TokenStore: 32-byte cryptographically random one-time tokens.
  Only the SHA-256 hash is persisted (so a DB leak doesn't grant
  active sessions). Comparison uses subtle.ConstantTimeCompare.
  Single-use is enforced via UPDATE ... WHERE used_at IS NULL.
- Signer: HS256 JWTs with 24h lifetime, jwt.WithValidMethods to
  reject alg=none and other downgrade attacks.
- LogMailer (dev) and SMTPMailer (prod via net/smtp) behind a
  Mailer interface.
- RateLimiter: DB-backed fixed window per email; default 5 per
  15 min for the magic-link flow.
- Service: orchestrates RequestLogin (auto-creates user on first
  login, generates token, emails magic link) and Verify (consumes
  token, updates last_login, issues JWT).
- Handlers: POST /auth/login and GET/POST /auth/verify.
  HandleLogin returns 202 even on validation failure to avoid
  account enumeration; rate-limit hits surface as 429.

Schema additions: magic_tokens (with FK + cascade) and
login_attempts. UserStore.SetStoragePath added for completeness.

Tests cover: token issue/consume, single-use, expiry, rate limit,
JWT round-trip, alg=none rejection, signature tampering, purge,
HTTP handlers (login + verify, missing/invalid token paths).

Closes #9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:16:25 +02:00
libretechandClaude Opus 4.7 0924e3cee9 Add user model and SQLite storage
internal/storage/ provides:
- Open(path) to create or open the SQLite database with WAL journal,
  busy timeout, and foreign keys enabled
- Embedded migrations that create the users table on first run
- UserStore with Create, GetByID, GetByEmail, UpdateLastLogin, Delete
- Email normalisation (trim+lowercase) and uniqueness enforcement
  with ErrEmailTaken
- ErrNotFound on lookups and deletes
- UUIDv4 IDs auto-generated when caller leaves ID empty

Uses modernc.org/sqlite (pure-Go) so the binary stays CGO-free and
matches Dockerfile.dev's CGO_ENABLED=0.

Tests cover all CRUD operations, email uniqueness (case-insensitive),
WAL mode verification, and ErrNotFound paths.

Closes #8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:13:28 +02:00
libretechandClaude Opus 4.7 b409519661 Add reproducible dev environment
- flake.nix: rebrand description, add Go 1.25, gopls, gotools,
  staticcheck, golangci-lint, gnumake to all dev shells. Add a
  plain `dev` shell (`nix develop .#dev`) that does not wrap the
  shell in the bubblewrap sandbox so contributors can use a
  standard Go toolchain.
- Dockerfile.dev: golang:1.22-bookworm with make, git, gopls and
  staticcheck, /workspace as default cwd. CGO disabled.
- README: document both nix and Docker dev paths.

flake.lock is committed for reproducibility.

Closes #6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:58:59 +02:00
libretechandClaude Opus 4.7 a36fc1c8cc Add Gitea Actions CI workflow
Runs on push to main and pull requests against main:
- go mod download + verify
- make lint (go vet)
- make build
- make test (race detector)

Uses actions/setup-go@v5 with built-in module caching, Go 1.22.
Workflow times out at 5 minutes per the acceptance criteria.

Closes #5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:55:40 +02:00
libretechandClaude Opus 4.7 72454f08ab Add Makefile with build, test, lint, run, and clean targets
Standard targets:
- build: compiles cmd/librenotes with version/buildtime ldflags
- test: race detector enabled, full module
- lint: go vet, plus staticcheck if available
- run: build + execute, ARGS forwarded
- clean: remove binary and test/coverage artifacts

Variables (BINARY, OUTDIR, GO, GOFLAGS, LDFLAGS, TESTFLAGS) are
overridable so the CI workflow (#5) can invoke targets with
custom output paths or flags.

Closes #38.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:55:26 +02:00
libretechandClaude Opus 4.7 dc6ef99c3a Add README with librenotes branding and build instructions
Replaces the upstream Notesium README with librenotes-specific
content: project description, multi-tenant goals, build/run
instructions referencing cmd/librenotes, Nix-based dev setup,
fork attribution, and MIT license note.

CI badge points at the workflow that #5 will create. Module path
and directory layout match the structure landed in the previous
fork commit.

Closes #37.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:53:28 +02:00
libretechandClaude Opus 4.7 42fac0ab33 Document fork relationship with Notesium upstream
LICENSE retains the original Notesium copyright alongside librenotes.
NOTICE records the upstream URL, fork commit hash
(aff9f460c2d864112db7f0935b4168b107289d91), fork date, and
instructions for contributors who want to add the upstream remote
and cherry-pick patches.

Closes #36.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:53:04 +02:00
libretechandClaude Opus 4.7 094250609c 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
libretechandClaude Sonnet 4.5 aee9086633 Fix Gitea pipelines to use authenticated tea CLI without --login flag
Remove redundant --login librete flags from all gt-* pipeline tea commands since
authentication is already configured via tea logins. This simplifies the commands
and prevents potential authentication issues.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-25 19:15:33 +01:00
libretechandClaude Opus 4.6 3e10fde0e1 Add CLAUDE.md with project conventions and tool preferences
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 17:11:46 +01:00
20 changed files with 154 additions and 426 deletions
+1 -8
View File
@@ -1,12 +1,5 @@
# Server-side .env for netcup deploy. Copy to /srv/librenotes/.env on the host.
# Used by: docker compose -f compose.yaml -f compose.netcup.yaml up -d
# Image to pull. The default `:main` rolls forward — main pushes
# rebuild and push the same tag, so `compose pull` picks up the new
# digest without rewriting this file. To pin a release (or roll back),
# edit this line to an immutable tag such as :v0.1.0 and re-run
# `docker compose -f compose.yaml -f compose.netcup.yaml pull && up -d`.
LIBRENOTES_IMAGE=git.librete.ch/public/librenotes:main
# Used by: docker compose -f docker-compose.yml -f compose.netcup.yaml up -d --build
# Public origin (caddy reverse-proxies to librenotes:8080)
LIBRENOTES_BASE_URL=https://ln.cloud.librete.ch
-4
View File
@@ -9,10 +9,6 @@ on:
jobs:
ci:
runs-on: ubuntu-latest
# Bespoke runner image (Ubuntu 24.04 + make + git + node + go via
# actions/setup-go). See git.librete.ch/public/runner-image.
container:
image: git.librete.ch/public/runner-image:v0.2.0@sha256:f60c587d3c0b0aac04a572db5349e27672bf76baec2ce547a3dcc28cebcf1b7e
timeout-minutes: 5
steps:
- name: Checkout
+60 -57
View File
@@ -6,91 +6,94 @@ on:
tags: ["v*"]
# Required repository secrets:
# REGISTRY registry hostname (git.librete.ch)
# REGISTRY_USER robot account or PAT username (libretech-bot)
# REGISTRY_PASS PAT with write:package
# DEPLOY_HOST SSH target, e.g. user@your-deploy-host
# DEPLOY_KEY passphrase-less private key (PEM)
# DEPLOY_PATH remote stack dir (/srv/librenotes)
# HEALTH_URL https://ln.cloud.librete.ch/healthz
# REGISTRY registry hostname, e.g. registry.librete.ch
# REGISTRY_USER robot account
# REGISTRY_PASS robot token
# DEPLOY_HOST deployment SSH target, e.g. root@librenot.es
# DEPLOY_KEY private SSH key (PEM, no passphrase)
# DEPLOY_PATH remote directory containing the compose stack
# HEALTH_URL public URL to verify post-deploy, e.g.
# https://librenot.es/healthz
#
# Required repository variable:
# DEPLOY_ENABLED set to "true" to enable the workflow
#
# Image: ${REGISTRY}/public/librenotes
# main pushes → :main + :<sha7>
# tag pushes → :<tag> + :latest
#
# The host's /srv/librenotes/.env pins LIBRENOTES_IMAGE once
# (e.g. =git.librete.ch/public/librenotes:main). Main pushes
# update :main rolling so a `compose pull` picks up the new image
# without rewriting any file. Tag pin / rollback is a manual edit
# of LIBRENOTES_IMAGE in .env followed by `compose pull && up -d`.
# Tag pushes deploy the tag (vX.Y.Z); main-branch pushes deploy
# the rolling :main image. Set image to immutable tag so rollback
# is just `docker compose -f ... up -d` with the previous tag.
jobs:
deploy:
build:
runs-on: ubuntu-latest
# Custom Gitea runner image: Ubuntu 24.04 + docker CLI + node + git
# + perl + ssh, runner user pre-joined to docker gid 998 so the
# auto-mounted /var/run/docker.sock is writable without --user root.
container:
image: git.librete.ch/public/runner-image:v0.2.0@sha256:f60c587d3c0b0aac04a572db5349e27672bf76baec2ce547a3dcc28cebcf1b7e
timeout-minutes: 20
timeout-minutes: 15
if: ${{ vars.DEPLOY_ENABLED == 'true' }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASS }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.REGISTRY }}/public/librenotes
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,format=short
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
- name: Compute tags
id: tags
run: |
BASE="${{ secrets.REGISTRY }}/librenotes"
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
TAG="${GITHUB_REF##refs/tags/}"
echo "tags=${BASE}:${TAG},${BASE}:latest" >> "$GITHUB_OUTPUT"
echo "version=${TAG}" >> "$GITHUB_OUTPUT"
else
echo "tags=${BASE}:main,${BASE}:${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
echo "version=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
fi
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.tags.outputs.tags }}
build-args: |
VERSION=${{ steps.meta.outputs.version }}
VERSION=${{ steps.tags.outputs.version }}
BUILDTIME=${{ github.event.head_commit.timestamp }}
- name: Deploy to host
deploy:
runs-on: ubuntu-latest
needs: build
timeout-minutes: 10
if: ${{ vars.DEPLOY_ENABLED == 'true' }}
steps:
- name: Configure SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
ssh-keyscan -H "${{ secrets.DEPLOY_HOST#*@ }}" >> ~/.ssh/known_hosts || true
- name: Pull and restart on deploy host
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
run: |
ssh -i ~/.ssh/id_deploy "$DEPLOY_HOST" \
"cd $DEPLOY_PATH && \
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull && \
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --remove-orphans"
- name: Verify health
env:
HEALTH_URL: ${{ secrets.HEALTH_URL }}
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
ssh -i ~/.ssh/id_deploy \
-o StrictHostKeyChecking=accept-new \
"$DEPLOY_HOST" \
"set -e
cd '$DEPLOY_PATH'
git pull --ff-only
docker compose -f compose.yaml -f compose.netcup.yaml pull
docker compose -f compose.yaml -f compose.netcup.yaml up -d --remove-orphans"
# Wait up to 60s for /healthz to return 200.
# Give the new container ~30s to come up, then poll for
# a 200 from /healthz. Failure aborts the workflow which
# is the alert.
for i in $(seq 1 12); do
curl -fsS "$HEALTH_URL" >/dev/null && exit 0
if curl -fsS "$HEALTH_URL" >/dev/null; then
echo "deploy verified"
exit 0
fi
sleep 5
done
echo "deploy health check failed"; exit 1
echo "deploy verification failed"
exit 1
-7
View File
@@ -15,10 +15,3 @@ wave.yaml
/dist/
*.test
*.out
# Agent scratch
/.agents/
# runtime data and state of the stopped stack
/data/
/state/
-1
View File
@@ -1 +0,0 @@
See CLAUDE.md for project guidelines.
+3 -3
View File
@@ -5,7 +5,7 @@ Cloud-native multi-tenant notes application built on Notesium (MIT).
## Project
- Repo: https://git.librete.ch/public/librenotes
- Remote: `ssh://git.librete.ch:41240/public/librenotes.git` (configure SSH user in `~/.ssh/config`)
- Remote: `ssh://tengo@git.librete.ch:41240/public/librenotes.git`
- Stack: Go backend (forked from Notesium), vanilla JS frontend
- License: MIT (inherited from Notesium)
@@ -18,7 +18,7 @@ Cloud-native multi-tenant notes application built on Notesium (MIT).
## Wave pipelines
- `gt-issue-*` pipelines use `tea` CLI with `--login libretech`
- `gt-issue-*` pipelines use `tea` CLI with `--login librete`
- `gh-issue-*` pipelines use `gh` CLI
- Personas must be registered in `wave.yaml` under `personas:` key — files alone aren't enough
- `wave.yaml` is gitignored (local config)
@@ -27,6 +27,6 @@ Cloud-native multi-tenant notes application built on Notesium (MIT).
## Gitea
- Instance: https://git.librete.ch
- Auth: `tea` CLI, login name `libretech` (matches the Gitea username)
- Auth: `tea` CLI, login name `librete`
- API: https://git.librete.ch/api/v1
- Issues: https://git.librete.ch/public/librenotes/issues
+1 -1
View File
@@ -16,7 +16,7 @@ The fastest path is the Nix flake. The `dev` shell drops you
into a working Go toolchain without any sandbox wrapping:
```sh
git clone https://git.librete.ch/public/librenotes.git
git clone ssh://tengo@git.librete.ch:41240/public/librenotes.git
cd librenotes
nix develop .#dev
make build && make test
-82
View File
@@ -1,82 +0,0 @@
# Deploy runbook — librenotes
Derived from [`netcup/DEPLOY-TEMPLATE.md`](https://git.librete.ch/libretech/netcup/src/branch/main/DEPLOY-TEMPLATE.md).
Section ordering and headings stable across stacks.
## 1. Target
| Field | Value |
|-------|-------|
| Vhost | `ln.cloud.librete.ch` |
| Server path | `/srv/librenotes/` |
| Repo | `git.librete.ch/public/librenotes` |
| Image source | `${LIBRENOTES_IMAGE}` from `git.librete.ch/public/librenotes` (registry image, no source build on remote) |
| Cert | edge caddy via INWX DNS-01 |
| Edge net container name | `librenotes` (matches `caddy/Caddyfile` reverse_proxy target on `:8080`) |
## 2. Required env / secrets
`/srv/librenotes/.env` (gitignored, mode 0600):
| Variable | Notes |
|----------|-------|
| `LIBRENOTES_IMAGE` | published tag, e.g. `git.librete.ch/public/librenotes:v0.1.0` |
| `LIBRENOTES_BASE_URL` | `https://ln.cloud.librete.ch` |
| `LIBRENOTES_JWT_SECRET` | `openssl rand -base64 48` |
| `LIBRENOTES_SMTP_HOST`, `..._PORT`, `..._USER`, `..._PASS`, `..._FROM` | outbound mail (uberspace per `netcup/.env`) |
## 3. First-time deploy
```sh
ssh netcup 'docker network ls | grep -q edge || docker network create edge'
ssh netcup 'mkdir -p /srv && cd /srv && git clone ssh://tengo@git.librete.ch:41240/public/librenotes.git'
scp librenotes/.env netcup:/srv/librenotes/.env
ssh netcup 'chmod 600 /srv/librenotes/.env'
ssh netcup 'cd /srv/librenotes && docker compose -f compose.yaml -f compose.netcup.yaml pull && docker compose -f compose.yaml -f compose.netcup.yaml up -d'
```
## 4. Update deploy
```sh
deploy librenotes
# Bump LIBRENOTES_IMAGE in /srv/librenotes/.env then:
ssh netcup 'cd /srv/librenotes && docker compose -f compose.yaml -f compose.netcup.yaml pull && docker compose -f compose.yaml -f compose.netcup.yaml up -d'
```
## 5. Smoke / health
```sh
ping ln.cloud.librete.ch
cert ln.cloud.librete.ch
svcs librenotes
logs librenotes librenotes --tail=100
```
UI smoke: signup magic-link email arrives, login succeeds, note creation persists.
## 6. Logs + troubleshooting
| Symptom | First check |
|---------|-------------|
| 502 from edge | container off `edge` net or down |
| SMTP failure | `LIBRENOTES_SMTP_*` correct; firewall to uberspace |
| State loss | bind-mount `./state:/var/lib/librenotes` permissions |
## 7. Rollback
```sh
# Edit LIBRENOTES_IMAGE to prior tag in /srv/librenotes/.env, then:
ssh netcup 'cd /srv/librenotes && docker compose -f compose.yaml -f compose.netcup.yaml pull && docker compose -f compose.yaml -f compose.netcup.yaml up -d'
```
## 8. Stack-specific notes
- **Bind mounts** `./data:/data` (notes payload) and `./state:/var/lib/librenotes` (DB/state) — back up both.
- Multi-tenant by base URL — single image instance per tenant config.
- Image is published from `public/librenotes` CI; never builds on remote.
## 9. Issue tracking
- Deploy issues: `git.librete.ch/public/librenotes/issues`
- Cross-stack: `libretech/netcup`
- After every deploy: append to `netcup/deployments.md`.
-2
View File
@@ -7,8 +7,6 @@ Cloud-native, multi-tenant notes application. A fork of
authentication, per-user data isolation, sync, and PWA support so it can
run as a hosted service at [librenot.es](https://librenot.es).
> **Deploy / operate on netcup:** see [DEPLOY.md](DEPLOY.md) (canonical netcup runbook).
## Features
- Markdown notes with bi-directional links (Zettelkasten / evergreen notes)
-5
View File
@@ -140,11 +140,6 @@ func runServe(args []string) error {
apiHandler := api.Routes()
root.Handle("/auth/", apiHandler)
root.Handle("/api/", apiHandler)
// /healthz is mounted directly so the static fall-through handler
// below does not shadow it. The api.Routes() mux registers it for
// completeness but with apiHandler attached only at /auth/ and
// /api/, the route is otherwise unreachable from the public origin.
root.Handle("/healthz", apiHandler)
pub, err := fs.Sub(publicFS, "web/public")
if err != nil {
-62
View File
@@ -1,62 +0,0 @@
package main
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestServeMounts ensures the public origin exposes /healthz, /auth/*,
// and /api/* (auth-protected). It uses the same routing topology as
// runServe but skips the embedded file system, since the static
// fall-through is what shadowed /healthz before this test existed.
func TestServeMounts(t *testing.T) {
root := http.NewServeMux()
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/healthz":
_, _ = io.WriteString(w, `{"status":"ok"}`)
case "/auth/login":
w.WriteHeader(http.StatusMethodNotAllowed)
case "/api/whoami":
w.WriteHeader(http.StatusUnauthorized)
default:
http.NotFound(w, r)
}
})
root.Handle("/auth/", apiHandler)
root.Handle("/api/", apiHandler)
root.Handle("/healthz", apiHandler)
root.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
srv := httptest.NewServer(root)
defer srv.Close()
cases := []struct {
path string
want int
}{
{"/healthz", http.StatusOK},
{"/auth/login", http.StatusMethodNotAllowed},
{"/api/whoami", http.StatusUnauthorized},
{"/does-not-exist", http.StatusNotFound},
}
for _, tc := range cases {
resp, err := http.Get(srv.URL + tc.path)
if err != nil {
t.Fatalf("GET %s: %v", tc.path, err)
}
if resp.StatusCode != tc.want {
t.Errorf("%s: got %d, want %d", tc.path, resp.StatusCode, tc.want)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if tc.path == "/healthz" && !strings.Contains(string(body), `"status":"ok"`) {
t.Errorf("/healthz body = %q, want it to contain status:ok", body)
}
}
}
+8 -13
View File
@@ -1,19 +1,15 @@
# compose.netcup.yaml — overlay for the netcup VPS.
#
# Use:
# docker compose -f compose.yaml -f compose.netcup.yaml pull
# docker compose -f compose.yaml -f compose.netcup.yaml up -d
# docker compose -f docker-compose.yml -f compose.netcup.yaml up -d --build
#
# Differences from base compose.yaml:
# - Pulls a published image (no build context on the host)
# Differences from base docker-compose.yml:
# - No host port binding (caddy edge handles ingress)
# - Joins external `edge` network so caddy reaches it as `librenotes:8080`
# - Pulls config from .env (LIBRENOTES_BASE_URL, JWT_SECRET, SMTP_*, IMAGE)
# - Pulls config from .env (LIBRENOTES_BASE_URL, JWT_SECRET, SMTP_*)
# - Uses bind-mounted /data + /var/lib/librenotes so backups can rsync host paths
#
# Inputs (env or .env file on netcup):
# LIBRENOTES_IMAGE e.g. git.librete.ch/public/librenotes:main
# or pinned tag git.librete.ch/public/librenotes:v0.1.0
# LIBRENOTES_BASE_URL https://ln.cloud.librete.ch
# LIBRENOTES_JWT_SECRET `openssl rand -base64 48`
# LIBRENOTES_SMTP_HOST SMTP relay host
@@ -21,15 +17,14 @@
# LIBRENOTES_SMTP_USER SMTP user
# LIBRENOTES_SMTP_PASS SMTP password
# LIBRENOTES_SMTP_FROM no-reply@librete.ch (envelope sender)
#
# Rollback: edit LIBRENOTES_IMAGE in /srv/librenotes/.env to a prior
# tag, then `docker compose ... pull && docker compose ... up -d`.
services:
librenotes:
build: !reset null
image: ${LIBRENOTES_IMAGE}
pull_policy: always
build:
context: .
args:
VERSION: netcup
image: librenotes:netcup
restart: always
ports: !reset []
environment:
+43
View File
@@ -0,0 +1,43 @@
# docker-compose.prod.yml — production overrides.
#
# Use:
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
#
# Inputs (env or .env file):
# LIBRENOTES_IMAGE registry image, e.g. registry.librete.ch/librenotes:1.2.3
# LIBRENOTES_BASE_URL public origin, e.g. https://librenot.es
# LIBRENOTES_JWT_SECRET secrets manager value, NOT committed
# LIBRENOTES_SMTP_HOST real SMTP host
# LIBRENOTES_SMTP_PORT submission port (587 default)
# LIBRENOTES_SMTP_USER SMTP credential
# LIBRENOTES_SMTP_PASS SMTP credential
# LIBRENOTES_SMTP_FROM envelope sender (e.g. no-reply@librenot.es)
services:
librenotes:
image: ${LIBRENOTES_IMAGE}
build: !reset null
environment:
LIBRENOTES_BASE_URL: ${LIBRENOTES_BASE_URL}
LIBRENOTES_JWT_SECRET: ${LIBRENOTES_JWT_SECRET}
LIBRENOTES_SMTP_HOST: ${LIBRENOTES_SMTP_HOST}
LIBRENOTES_SMTP_PORT: ${LIBRENOTES_SMTP_PORT:-587}
LIBRENOTES_SMTP_USER: ${LIBRENOTES_SMTP_USER}
LIBRENOTES_SMTP_PASS: ${LIBRENOTES_SMTP_PASS}
LIBRENOTES_SMTP_FROM: ${LIBRENOTES_SMTP_FROM}
healthcheck:
test: ["CMD", "/librenotes", "healthcheck"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
deploy:
resources:
limits:
memory: 256M
reservations:
memory: 64M
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
+1 -1
View File
@@ -1,4 +1,4 @@
# compose.yaml — local development.
# docker-compose.yml — local development.
#
# Brings up librenotes on http://localhost:8080 with the magic-link
# mailer logging links to stdout (no SMTP needed). Data lives in
+27 -126
View File
@@ -21,75 +21,51 @@ The workflow expects these secrets and variables on the repo:
| Name | Type | Purpose |
| ---- | ---- | ------- |
| `REGISTRY` | secret | registry hostname, e.g. `git.librete.ch` |
| `REGISTRY_USER` | secret | robot account or PAT username with `package:write` |
| `REGISTRY_PASS` | secret | robot/PAT token |
| `DEPLOY_HOST` | secret | `user@host` SSH target, e.g. `root@netcup` |
| `DEPLOY_KEY` | secret | passphrase-less private key (PEM) |
| `DEPLOY_PATH` | secret | absolute path on host with the compose stack, e.g. `/srv/librenotes` |
| `HEALTH_URL` | secret | e.g. `https://ln.cloud.librete.ch/healthz` |
| `REGISTRY` | secret | hostname of the OCI registry |
| `REGISTRY_USER` | secret | robot account |
| `REGISTRY_PASS` | secret | robot token |
| `DEPLOY_HOST` | secret | `user@host` SSH target |
| `DEPLOY_KEY` | secret | passphrase-less private key |
| `DEPLOY_PATH` | secret | absolute path on host with `docker-compose.*.yml` |
| `HEALTH_URL` | secret | e.g. `https://librenot.es/healthz` |
| `DEPLOY_ENABLED` | variable | `true` to enable the workflow |
The image is pushed to `${REGISTRY}/public/librenotes`; main pushes
publish `:main` and `:<sha7>`, tag pushes publish `:vX.Y.Z` and
`:latest`.
### Production compose stack
On the deployment host, place `compose.yaml` and
`compose.netcup.yaml` from this repo at `$DEPLOY_PATH`, together
with an `.env` file containing `LIBRENOTES_IMAGE` plus the runtime
configuration (JWT secret, SMTP credentials, public base URL).
See `.env.netcup.example` for the full key list.
On the deployment host, place `docker-compose.yml` and
`docker-compose.prod.yml` from this repo at `$DEPLOY_PATH`,
together with an `.env` file containing the runtime configuration
(JWT secret, SMTP credentials, public base URL, image tag).
Bring it up with:
```sh
docker compose -f compose.yaml -f compose.netcup.yaml pull
docker compose -f compose.yaml -f compose.netcup.yaml up -d
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
### Rollback
Tag pushes publish immutable tags (`:vX.Y.Z`). To pin or roll back,
edit `LIBRENOTES_IMAGE` in `/srv/librenotes/.env` and re-run pull +
up. The deployment workflow itself never rewrites the file — failed
health checks abort the workflow and the operator redeploys
manually.
Image tags are immutable per commit / version. To roll back, set
`LIBRENOTES_IMAGE` to the previous tag in `.env` and run the same
`up -d` command. The deployment workflow does not auto-rollback
on health-check failure — failed health alerts the operator via
the workflow itself, who can then redeploy the prior tag manually.
```sh
# Example: roll back to v0.1.0
ssh netcup
cd /srv/librenotes
perl -i -pe 's|^LIBRENOTES_IMAGE=.*|LIBRENOTES_IMAGE=git.librete.ch/public/librenotes:v0.1.0|' .env
docker compose -f compose.yaml -f compose.netcup.yaml pull
docker compose -f compose.yaml -f compose.netcup.yaml up -d
# Example: roll back to v0.1.2
sed -i 's/^LIBRENOTES_IMAGE=.*/LIBRENOTES_IMAGE=registry.librete.ch\/librenotes:v0.1.2/' .env
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
### Gitea Actions runner
Workflows run on the netcup `act_runner` (see `runner/` stack in
the netcup umbrella). Both `ci.yml` and `deploy.yml` declare
`container: image: git.librete.ch/public/runner-image:v1` —
a bespoke Ubuntu 24.04 image (built and signed by us, hosted on
the same Gitea instance) that bundles `git`, `make`, `node`,
`perl`, `ssh`, and a docker CLI. The image's `runner` user is
pre-joined to `docker` group gid 998 so the auto-mounted
`/var/run/docker.sock` is writable without `--user root`.
The runner config (`runner/config.yaml`) whitelists
`/var/run/docker.sock` under `valid_volumes` to allow the
auto-mount.
## Backups
`scripts/backup.sh` is a self-contained backup driver suitable for
running from cron, a systemd timer, or the supplied
`scripts/librenotes-backup.{service,timer}` units.
Required tools on the backup host: `sqlite3`, `tar`, `gzip`,
`rsync`, and optionally `rclone` for object-store off-site copy.
On Debian/Ubuntu: `apt install sqlite3 rsync rclone`.
Required tools on the backup host: `sqlite3`, `tar`, `gzip`, and
optionally `rclone` for off-site copy. On Debian/Ubuntu:
`apt install sqlite3 rclone`.
### What is backed up
@@ -103,25 +79,10 @@ On Debian/Ubuntu: `apt install sqlite3 rsync rclone`.
### Off-site copy
The script supports two off-site transports; either or both can be
configured. Without either, backups stay on local disk only.
- `BACKUP_REMOTE` — `rclone` destination (e.g.
`s3:librenotes-backups`, `b2:librenotes`). Uses `rclone copy` to
upload each new archive. Best for object-store targets.
- `BACKUP_REMOTE_RSYNC` — `rsync` destination (e.g.
`backup@rsync.net:librenotes/`). Each run rsyncs into
`<root>/YYYY-MM-DD/` and passes
`--link-dest=../<previous-day>/`, so unchanged archives become
hard links — daily snapshots cost (almost) nothing extra. Set
`BACKUP_REMOTE_SSH_KEY` to the path of a passphrase-less private
key if the host's default identity is wrong (recommended for
rsync.net-style restricted accounts).
For netcup, the operator's choice is captured in
[issue #41](https://git.librete.ch/public/librenotes/issues/41).
Until a target is chosen, leave both unset and run the timer
locally to verify archives generate cleanly.
Set `BACKUP_REMOTE` to an `rclone` destination (e.g.
`s3:librenotes-backups`). When set, the script invokes
`rclone copy` to upload the archive after creation. Without it,
backups stay on the local disk only.
### Retention
@@ -154,63 +115,3 @@ Confirm with:
```sh
systemctl list-timers librenotes-backup.timer
```
### Restore procedure
A restore is a four-step process. Run it on a *spare* directory
(e.g. `/srv/librenotes-restore-YYYYMMDD/`) — never overwrite the
live `/srv/librenotes/data` or `/srv/librenotes/state` until the
parallel stack passes verification.
```sh
# 1. Pick an archive (local or pulled back from the off-host target).
ARCHIVE=/var/backups/librenotes/librenotes-20260415-031712.tar.gz
# 2. Lay out a parallel stack dir.
RESTORE_DIR=/srv/librenotes-restore
mkdir -p "$RESTORE_DIR"/{data,state}
chown -R 65532:65532 "$RESTORE_DIR"/{data,state} # distroless nonroot uid
# 3. Unpack the archive into a scratch dir, then move the pieces
# into place.
SCRATCH=$(mktemp -d)
tar -C "$SCRATCH" -xzf "$ARCHIVE"
install -m 0644 -o 65532 -g 65532 "$SCRATCH/librenotes.db" \
"$RESTORE_DIR/state/librenotes.db"
tar -C "$RESTORE_DIR/data" -xzf "$SCRATCH/notes.tar.gz"
chown -R 65532:65532 "$RESTORE_DIR/data"
rm -rf "$SCRATCH"
# 4. Bring up a parallel compose stack (dedicated docker compose
# project name + non-conflicting LIBRENOTES_BASE_URL) and verify.
cd "$RESTORE_DIR"
cp /srv/librenotes/.env .env
perl -i -pe 's|^LIBRENOTES_BASE_URL=.*|LIBRENOTES_BASE_URL=http://localhost:18080|' .env
cp /srv/librenotes/{compose.yaml,compose.netcup.yaml} .
# Override port + project name so the parallel stack does not
# clash with the live one.
docker compose -p librenotes-restore \
-f compose.yaml \
--profile=restore \
up -d \
-e LIBRENOTES_PORT=18080
curl -fsS http://localhost:18080/healthz
# Sign in with a known test user, click around, confirm note state.
docker compose -p librenotes-restore down
```
Once the parallel stack proves the archive is intact, swap into
production with a maintenance window:
```sh
docker compose -f compose.yaml -f compose.netcup.yaml down
mv /srv/librenotes/data /srv/librenotes/data.bak-$(date +%s)
mv /srv/librenotes/state /srv/librenotes/state.bak-$(date +%s)
mv /srv/librenotes-restore/data /srv/librenotes/data
mv /srv/librenotes-restore/state /srv/librenotes/state
docker compose -f compose.yaml -f compose.netcup.yaml up -d
curl -fsS https://ln.cloud.librete.ch/healthz
```
If anything goes wrong, the `.bak-*` directories are still there
to restore from.
+5 -11
View File
@@ -23,16 +23,10 @@ Docker Compose. For day-2 ops (deploys, backups) see
```sh
git clone https://git.librete.ch/public/librenotes
cd librenotes
cp .env.netcup.example .env # edit values, see below
docker compose -f compose.yaml -f compose.netcup.yaml pull
docker compose -f compose.yaml -f compose.netcup.yaml up -d
cp .env.example .env # edit values, see below
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
The `compose.netcup.yaml` overlay is the production-style overlay
shipped in this repo; it pulls a published image, drops host port
binding (assumes a fronting reverse proxy), and joins an external
`edge` docker network. Adapt to your topology if needed.
Visit `https://your-domain/` and sign in.
## Environment configuration
@@ -51,7 +45,7 @@ Place them in an `.env` file next to the Compose stack.
| `LIBRENOTES_SMTP_FROM` | yes | Envelope sender, e.g. `no-reply@notes.example.com`. |
| `LIBRENOTES_DATA_DIR` | no | Default `/data` inside the container. |
| `LIBRENOTES_DB` | no | Default `/var/lib/librenotes/librenotes.db`. |
| `LIBRENOTES_IMAGE` | yes (prod) | Image tag to pull, e.g. `git.librete.ch/public/librenotes:v0.1.0`. |
| `LIBRENOTES_IMAGE` | yes (prod) | Image tag to pull, e.g. `registry.librete.ch/librenotes:v0.1.0`. |
Generate a JWT secret:
@@ -115,8 +109,8 @@ to use `curl`/`wget`).
Pull the new image and restart:
```sh
docker compose -f compose.yaml -f compose.netcup.yaml pull
docker compose -f compose.yaml -f compose.netcup.yaml up -d
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
To pin a specific version, set `LIBRENOTES_IMAGE` to that tag in
-5
View File
@@ -48,10 +48,6 @@
--bind "$HOME/.claude" "$HOME/.claude"
--bind "$HOME/.claude.json" "$HOME/.claude.json"
# Writable: GPG keyring + agent socket (commit signing)
--bind "$HOME/.gnupg" "$HOME/.gnupg"
--bind-try "''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gnupg" "''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gnupg"
--ro-bind "$HOME/.gitconfig" "$HOME/.gitconfig"
--ro-bind "$HOME/.ssh" "$HOME/.ssh"
--setenv GIT_SSH_COMMAND "ssh -F ~/.ssh/config"
@@ -65,7 +61,6 @@
--setenv PATH "$PATH"
--setenv TERM "''${TERM:-xterm}"
--setenv SANDBOX_ACTIVE "1"
--setenv GPG_TTY "''${GPG_TTY:-}"
--chdir "$PROJECT_DIR"
)
+1 -6
View File
@@ -99,12 +99,7 @@ func (s *Service) RequestLogin(ctx context.Context, email string) error {
if err != nil {
return err
}
// The magic link must land on the SPA page (verify.html), not the
// JSON API endpoint /auth/verify. The page reads the token from
// the URL, POSTs it to /auth/verify, stores the JWT and redirects
// to /app.html. Pointing the email at /auth/verify exposes the
// raw JSON body to anyone who clicks the link.
link := s.baseURL + "/verify.html?token=" + url.QueryEscape(plaintext)
link := s.baseURL + "/auth/verify?token=" + url.QueryEscape(plaintext)
if err := s.mailer.SendMagicLink(ctx, email, link); err != nil {
return fmt.Errorf("send mail: %w", err)
}
+1 -28
View File
@@ -13,18 +13,10 @@
# Optional env:
# BACKUP_DIR where to write archives (default /var/backups/librenotes)
# BACKUP_REMOTE rclone target for off-site copy (e.g. s3:bucket/path)
# BACKUP_REMOTE_RSYNC rsync destination for hard-link-deduplicated
# off-host copy, e.g. backup@rsync.net:librenotes/
# Layout written at the target:
# <root>/YYYY-MM-DD/librenotes-<ts>.tar.gz
# Each run passes --link-dest=../<previous>/ so
# unchanged archives cost zero extra bytes.
# BACKUP_REMOTE_SSH_KEY path to a private SSH key used for the rsync
# leg (passed via -e "ssh -i <key>").
# BACKUP_VERSION version string written into info.txt
#
# The script is intentionally a single self-contained file so it
# can run on a minimal host with only sqlite3, tar, gzip, rsync, and
# can run on a minimal host with only sqlite3, tar, gzip, and
# (optionally) rclone.
set -euo pipefail
@@ -66,22 +58,3 @@ if [ -n "${BACKUP_REMOTE:-}" ]; then
rclone copy "$archive" "$BACKUP_REMOTE" --quiet
echo "uploaded to $BACKUP_REMOTE"
fi
if [ -n "${BACKUP_REMOTE_RSYNC:-}" ]; then
# Compute today / previous-day directory names. Layout at the
# remote target is <root>/YYYY-MM-DD/. We pass --link-dest pointing
# at yesterday's dir so unchanged archives become hard links —
# ~free for daily snapshots that are mostly identical.
today="$(date -u +%Y-%m-%d)"
yesterday="$(date -u -d 'yesterday' +%Y-%m-%d 2>/dev/null \
|| date -u -v-1d +%Y-%m-%d)"
ssh_opts=()
if [ -n "${BACKUP_REMOTE_SSH_KEY:-}" ]; then
ssh_opts=(-e "ssh -i $BACKUP_REMOTE_SSH_KEY -o StrictHostKeyChecking=accept-new")
fi
# Strip any trailing slash so we control the join.
remote="${BACKUP_REMOTE_RSYNC%/}"
rsync -a --link-dest="../$yesterday/" "${ssh_opts[@]}" \
"$archive" "$remote/$today/"
echo "rsync'd $archive -> $remote/$today/"
fi
+3 -4
View File
@@ -2,10 +2,9 @@
Description=Run librenotes backup nightly
[Timer]
# 03:00 Europe/Berlin nightly with up to 5min jitter so multiple
# machines on the same schedule don't all hit the off-site target
# at once.
OnCalendar=*-*-* 03:00:00 Europe/Berlin
# 03:17 UTC nightly with up to 5min jitter so multiple machines on
# the same schedule don't all hit the off-site target at once.
OnCalendar=*-*-* 03:17:00 UTC
RandomizedDelaySec=5min
Persistent=true
Unit=librenotes-backup.service