Hosted, multi-user note-taking app for work meetings. Type fast/shorthand notes, get
real-time autocomplete corrections and a post-meeting "sanitize" polish, and export to
Markdown/PDF. Email+password login; each user's notes are isolated under notes/<userId>/.
Small team, admin-created accounts (no public signup). LLM still via claude -p under one
shared server login.
_Last updated: 2026-06-28 — autocomplete model switch: autocomplete now runs on sonnet with --effort low (new effort option on runClaude → claude -p --effort <level>), keeping thinking disabled (MAX_THINKING_TOKENS=0). Prior same-day: security hardening pass (post code-review): PDF export now runs
Chromium with --host-resolver-rules=MAP * 0.0.0.0 (blocks SSRF from attacker-controlled note
markdown) + a 2-render concurrency cap; login always runs scrypt (dummy credential for unknown
emails, kills the user-enumeration timing channel) and is rate-limited (10/IP/15min); session
cookie secure now derives from req.secure (X-Forwarded-Proto) instead of NODE_ENV; prod CORS
defaults to no cross-origin (was reflect-any true); ensureUserDir memoized (was a blocking
mkdirSync every request); userRootFor moved to auth.ts as the single per-user-root source.
Earlier 2026-06-28 — went multi-user/hosted: added email+password auth (scrypt +
opaque httpOnly session cookie, users/sessions in SQLite notes/.noter.db), per-user note
isolation under notes/<userId>/ (resolveInNotes now takes a user root; all handlers + trash
threaded), a requireAuth gate on every data/AI route, an admin add-user CLI, a login screen
- logout, and note export (Markdown via client Blob download; PDF server-rendered with
md-to-pdf+github-markdown-css).claude.tsunchanged (shared login). Prod: Express serves the built client; CORS tightened +trust proxy. Verified end-to-end via curl (gating, login, isolation, CRUD, real%PDF). Prior: 2026-06-22 — sorting now uses last edit (mtime) for both notes and folders (sort modes renamedcreated-*→edited-*; folders ordered by their latest-edited note, alphabetical only innamemode); gave the confirm-dialog backdrop az-indexso it sits above the editor/popovers. Prior: 2026-06-20 (v1.1.2) — added an app logo (favicon + sidebar), a last-edited timestamp in the toolbar header, and Esc-to-preview in edit mode. Also cut autocomplete latency ~2-4x by disabling thinking (MAX_THINKING_TOKENS=0) on the haiku call. Details in Current state below._
Working and verified end-to-end:
- Multi-user auth — email+password login gates the app. Passwords hashed with built-in
crypto.scrypt; sessions are opaquerandomBytestokens in an httpOnly, SameSite=Lax cookie (secureset fromreq.secure/X-Forwarded-Proto, so it's on behind the TLS proxy), stored server-side in SQLite (notes/.noter.db, override viaNOTER_DB).auth.tsowns the DB, hashing, sessions, and therequireAuthmiddleware;index.tsmountsPOST /api/login,POST /api/logout,GET /api/me, a publicGET /api/ping, thenapp.use("/api", requireAuth)so every data/AI route below requires a session. Accounts are created by an admin vianpm run add-user -- <email> <pw> "Name" [--admin](no signup UI). Client gates on mount viaGET /api/me(loading→login→app); a 401 from any call bounces to login (App stays mounted so unsaved edits survive a re-login). Logout + current user sit in the sidebar footer. curl-verified: ping public, data routes 401 without a cookie, login sets the cookie, wrong password 401, logout invalidates. - Per-user storage — each user's notes live under
notes/<userId>/(own unfiled notes, folder subdirs, and.trash/+trash.json).resolveInNotes(userRoot, …)re-anchors the traversal guard per-user; every fs handler derivesuserRoot = notes/<req.user.id>first.userIdis a randombase64urlid (not the email). Verified: two users see only their own notes on disk and via/api/tree. - Export — toolbar .md downloads the raw markdown client-side (Blob, no server). PDF
POSTs the current markdown to
POST /api/export/pdf, rendered server-side bymd-to-pdfstyled withgithub-markdown-css(light theme,.markdown-body), returned as aBuffer(wrap the Uint8Array or Express JSON-serializes it). Both disabled on an empty note; PDF shows a busy state. Verified: returns a real%PDF-1.4document. - Real-time autocomplete — ghost-text-on-pause (~900ms debounce). The suggestion shows in
a floating card anchored just below the line the caret is on (flips above the line near
the editor's bottom edge, follows textarea scroll), so it appears where you're typing rather
than pinned to the bottom of the pane. Tab accepts, Esc dismisses. Caret pixel position is
measured via a hidden mirror-div (
caretCoordsinEditor.tsx); the card is absolutely positioned inside theposition: relative.editorbox (.suggestion-pop). Minimal corrections only (typos, abbreviations) — never invents content. Undo an accept: right after accepting a suggestion,⌘Z/Ctrl+Zreverts just that replacement (React value changes don't enter the textarea's native undo stack, soEditor.tsxsnapshots the pre-accept state inlastAcceptRefand restores it; any manual keystroke clears the snapshot so native undo resumes). Model + speed tuning: the autocompleteclaude -pcall runs on sonnet with--effort low(runClaude'seffortoption) and thinking disabled viaMAX_THINKING_TOKENS=0(runClaude'sdisableThinkingoption, set in the child env). The thinking-off tuning originated on haiku, which was emitting ~130 hidden thinking tokens for a ~5-token answer; disabling it cut per-call API time from ~1.5-4s to ~0.9s with identical output. Sanitize/summarize keep thinking on. - Rendered (read) mode — formatted-Markdown view (
react-markdown+remark-gfm) shown alongside the raw textarea. A toolbar Preview/Edit toggle switches between them. Notes open in rendered mode and auto-switch to rendered after a sanitize; new blank notes start in edit mode. Clicking the rendered view drops into edit mode with the caret at the clicked spot (arehypeSourceOffsetplugin stampsdata-posoffsets; the click maps back viacaretFromPoint- a block-anchored text search). In edit mode, Esc returns to rendered/preview (a first Esc dismisses an active autocomplete suggestion before exiting to preview).
- Post-meeting sanitize — stronger model polishes the whole note into Markdown without
changing meaning; asks clarifying questions via a
QUESTIONS:block (multi-turn resume loop) rather than guessing. - Folder summarize — ✨ on a folder row rolls every note in that folder up into one weekly
digest (Opus).
SummaryPanelpreviews the result; "Save to folder" writes a canonical<folder> Summary.mdinside that folder (overwritten on re-run) and opens it. The summarizer skips the folder's own summary note so it never feeds its own output back in. Grounded-only prompt (SUMMARIZE_SYSTEM) — sections for overview / decisions / action items / open questions / themes; never invents. Steerable regenerate: a text box in the preview lets you give an instruction (e.g. "focus on decisions", "make it shorter"); it resumes the summary session viaPOST /api/folder/summarize/replyto revise the existing digest. An empty box re-summarizes from scratch (new session). - New notes are unfiled — new top-level notes (⌘N and the top "+ Note") are created as
unfiled (folder
""); the user files them manually via drag-and-drop or move. Explicit "+ Note in folder" still creates inside the chosen folder. (Removed the prior weekly auto-foldering /client/src/week.ts.) - VSCode-style inline create — "+ Note" shows a naming input as a row in the Unfiled list;
"+ Folder" shows one as a new folder row at the top of the Folders area; "+ Note in folder"
shows one inside that folder. All use
InlineInput(Enter saves, Esc/blur cancels); an empty folder name just cancels. The inputs render in their tree position, not under the buttons. - Note & folder management — create / rename / move / delete notes; create / rename / delete
single-level folders (rename errors on name collision, no merge); recycle bin with restore /
permanent-delete / empty-bin (manual, nothing auto-purges). Rename is inline (the sidebar
name becomes an editable
InlineInput, prefilled + selected; Enter saves, Esc/blur cancels). Delete is a confirm modal (ConfirmDialoginclient/src/Dialog.tsx) with a red Delete button; same dialog backs the recycle bin's Delete-forever / Empty-bin. No more nativewindow.prompt/window.confirm. - Sorting — both notes and folders sort by last edit (mtime), most recent first by
default. A small button in the sidebar actions cycles the order: Last edited (
↓) → Least recently edited (↑) → Name A–Z (A). Server-side (GET /api/tree?sort=edited-desc|edited-asc|name, defaultedited-desc, uses file mtime); the choice persists inlocalStorage(sortMode). Sort applies to notes within Unfiled and within each folder. Folders follow the toggle too: innamemode alphabetical, otherwise ordered by the latest edit (mtime) of any note in the folder (empty folders sort oldest). - Drag-and-drop — drag a note onto a folder (or "Unfiled") to move it. Menu move still works.
- Resizable sidebar — drag the sidebar/main boundary to resize (160–520px); width persists
in
localStorage(sidebarWidth). Handled inApp.tsx(.sidebar-resizeroverlay). - Search — sidebar search box (debounced ~250ms) hits
GET /api/search?q=, which walks all notes (skips.trash) and matches filename + content, returning one-line snippets. Results replace the tree while a query is active; clicking opens the note.⌘K/⌘Ffocus the box. - Autosave + save indicator — edits auto-write ~1.5s after a typing pause; toolbar shows
Unsaved changes / Saving… / Saved / Save failed. Manual
⌘S(and the Save button) still work;beforeunloadwarns on unsaved work. SinglesaveNow()inApp.tsxis the only save path. - In-place rename — editing the title and saving now renames the open file (via
/api/note/rename) instead of creating a duplicate. Resolves the old title-vs-rename foot-gun. - Last-edited timestamp — the toolbar shows "Edited <date, time>" under the note title,
in the viewer's local timezone (
GET /api/notereturns the filemtime; refreshed on save). - Branding — inline-SVG app logo (note page + blinking green caret) as the browser favicon
and beside the "Noter" sidebar title (
client/public/noter-icon.svg). - Keyboard shortcuts —
⌘Ssave,⌘Nnew (blank, unsaved) note,⌘K/⌘Ffocus search,Tabaccept autocomplete,⌘Zundo a just-accepted autocomplete (in the editor). - Persistence — notes saved under
notes/as.md; folders are subdirectories; trash is a hiddennotes/.trash/+trash.jsonmanifest.
Both client (tsc -b && vite build) and server (tsc --noEmit) typecheck clean. All backend
routes curl-verified, including the path-traversal guard. Drag-and-drop itself has only been
build-verified, not yet click-tested in the browser.
- Multi-user model = small team, admin-created accounts (no public signup) → a CLI
(
add-user) over a signup endpoint/UI. Auth = email+password withcrypto.scrypt(no bcrypt dep) + opaque server-side session tokens in SQLite (revocable; chosen over stateless JWT). Cookie parsed by hand (nocookie-parserdep). Storage = keep notes on the filesystem, namespacednotes/<userId>/(reuses all existing fs code via a per-userresolveInNotesroot) rather than moving notes into a DB — only users/sessions are in SQLite. - LLM under multi-user = keep
claude -punder one shared server login (zero change toclaude.ts); all users' AI calls flow through it. Tradeoff accepted: shared billing/limits, and--resumesessionIds aren't namespaced per-user (low risk for a trusted team — ids are opaque and returned only to the originating client; documented, not enforced). - Export = Markdown client-side (Blob, zero deps); PDF server-side via
md-to-pdf+github-markdown-css(per user request) — accepts the Puppeteer/Chromium dependency weight in exchange for consistent GitHub-style output, and keeps the client free of print CSS. - LLM backend =
claude -p(Claude Code CLI, headless), NOT the Anthropic API. Uses the existing Claude Code login — no API key/billing. Swappable later without touching the UI.- Autocomplete →
--model sonnet --effort low; Sanitize →--model claude-opus-4-8. (runClaudenow has aneffortoption that maps toclaude -p --effort <level>:low|medium|high|xhigh|max.) - Autocomplete latency fix = disable thinking via
MAX_THINKING_TOKENS=0in the child env (the--no-extended-thinkingCLI flag does NOT exist in this version — errors as an unknown option). This was the dominant latency lever; the CLI process startup and the ~6.6k-token default system prompt were not (system-prompt replacement showed no clear win). - DO NOT use
--bare— on this enterprise/managed (BCG) account, auth comes through Claude Code settings (apiKeyHelper);--bareskips settings loading → "Not logged in". Confirmed working:claude -p --model haiku --tools "" --output-format json.
- Autocomplete →
- Autocomplete UX = ghost-text on pause (chosen over inline/always-on).
- Rendered mode =
react-markdown+remark-gfm(React nodes, nodangerouslySetInnerHTML, so no separate XSS sanitization). Mode state lives inApp.tsxand is passed toEditor. Default edit on new note; render on open + after sanitize; click rendered view → edit with the caret mapped to the clicked source position (best-effort, falls back to the clicked block start). - Folders = single level only (no nesting). Move UX =
⋯menu + drag-and-drop. Recycle bin = manual restore/purge (no auto-purge by age). - Weekly folders = named by the week's Monday date (
YYYY-MM-DD), chosen over ISO-week (2026-W25) and human labels ("Week of Jun 16") because it sorts chronologically in the alphabetical sidebar and matches theYYYY-MM-DD-*note titles. Auto-filing applies only to new top-level notes (not "+ Note in folder", not existing notes — no retroactive backfill). - Folder summary = ✨ icon on the folder row (chosen over a
⋯menu for one-click discoverability of a headline feature). Output = preview then save as a note (vs. copy-only or a new note per run); a single living<folder> Summary.mdoverwritten on re-run. One-shot (noQUESTIONS:loop) — a digest doesn't need sanitize's meaning-preserving Q&A. - Saving = full autosave (debounced) + indicator, chosen over manual-only. Title =
renames the open file in place (not a new file). Search = server-side content + filename
(not client filename-only).
⌘K= focus sidebar search (no separate command palette). - Stack — Vite 6 + React 18 + TypeScript (client, :12345); Node + Express 4 +
tsx watch(server, :23456). Vite dev-proxies/api→:23456.concurrentlyruns both vianpm run dev. - Path safety — every fs path built from user input goes through
resolveInNotes()(throws on escape);safeFileName/safeFolderNamesanitize names;.trash/dot-folders rejected. - Collision safety —
save/renameno longer silently overwrite. New, unsaved notes save via/api/savewithcreateNew:true→ server dedupes throughuniqueName()(so two same-day…-meetingnotes get-1,-2rather than clobbering)./api/note/renamealso dedupes. Existing notes always autosave to their own backing file (current.name), never a title-derived name that could collide with a sibling. The client adopts any deduped name the server returns (updates the title) to avoid rename churn.
index.ts— Express app. Routes:GET /api/health;POST /api/autocomplete;POST /api/sanitize+POST /api/sanitize/reply;POST /api/folder/summarize(reads every.mdin the folder except its own<folder> Summary.md, builds one prompt, calls Opus with a longerSUMMARIZE_TIMEOUT_MS) +POST /api/folder/summarize/reply(resumes the session to steer/revise the digest from a user instruction);GET /api/search; file mgmt:GET /api/tree,POST /api/save,GET /api/note,POST /api/note/{create,rename,move,delete},POST /api/folder/{create,rename,delete},GET /api/trash,POST /api/trash/{restore,delete,empty}. Holds path/trash helpers (safeFileName,safeFolderName,resolveInNotes,readManifest/writeManifest,makeId,uniqueName,listMd). Now also: publicGET /api/ping+POST /api/login/POST /api/logout/GET /api/me, thenapp.use("/api", requireAuth)gating all routes below;POST /api/export/pdf(md-to-pdf); per-user threading — every fs handler starts withuserRoot = userRootFor(req.user!.id)andresolveInNotes/readManifest/writeManifesttake explicit paths under it; CORS restricted +trust proxy; in prod servesclient/dist+ SPA fallback.NOTES_DIR(imported fromauth.ts) =<root>/notes(the parent of per-user dirs);PORT= 23456.claude.ts— the ONLY module that talks to the LLM.runClaude(opts)spawnsclaude -pviachild_process.spawn. Has an explicit "do NOT use--bare" comment. Unchanged by the multi-user work (runs under the shared server login, user-agnostic).auth.ts— auth + storage roots. Inits SQLite (users,sessions) atnotes/.noter.db(orNOTER_DB);hashPassword/verifyPassword(scrypt +timingSafeEqual);createUser,getUserByEmail;createSession/getSessionUser/deleteSession/pruneExpiredSessions;requireAuth(cookie →req.useror 401, +ensureUserDir);readCookie; exportsNOTES_DIR,SESSION_COOKIE,publicUser, and augmentsExpress.Requestwithuser.add-user.ts— admin CLI (npm run add-user -- <email> <pw> "Name" [--admin]) →createUseragainst the same DB. No signup endpoint/UI.prompts.ts—AUTOCOMPLETE_SYSTEM,SANITIZE_SYSTEM,SUMMARIZE_SYSTEMguardrail prompts + thebuildAutocompletePrompt/buildSanitizePrompt/buildSanitizeReplyPrompt/buildFolderSummaryPromptbuilders.
App.tsx— top-level state: open note{folder, name}, title, content, tree, modals,saveState, search query/results.refreshTree,saveNow(the single save path: rename-in- place + write), debounced autosave +beforeunloadguard, debounced search,newNote, global keydown shortcuts,handleOpen,handleCurrentChanged,handleAcceptSanitized. Now also hosts auth state (loading/out/in): bootstraps viame()on mount, registers the 401 handler, gates the render (spinner →<Login>→ app), guards the tree load onauth==="in", and addshandleLogout+ the toolbar .md/PDF export buttons (pdfBusy).Sidebar.tsx—+ Note/+ Folder, collapsible folders, "Unfiled" group, per-note⋯menu (Rename / Delete / Move to…), per-folder summarize(✨)+add(+)+rename(✎)+delete, recycle-bin row with count. Adding and renaming use inline input rows (InlineInputcomponent; Enter saves, Esc/blur cancels), notwindow.prompt. Create =creatingstate, rendered VSCode-style in tree position:+ Note→ a row in Unfiled,+ Folder→ a new folder row atop Folders, a folder's+→ a row inside that (auto-expanded) folder. Rename =renamingstate; the note link / folder name becomes anInlineInput(prefilled + selected). Delete usesConfirmDialog(dialogstate). Hosts drag-and-drop (note rows draggable; folders + Unfiled are drop targets) and the search box + results list (controlled byApp; results replace the tree when a query is active). Footer row shows the signed-in user (userprop) + a sign-out button (onLogout).Editor.tsx— in edit mode: textarea + suggestion bar (debounced autocomplete with AbortController); in rendered mode: scrollablereact-markdown/remark-gfmview that switches to edit on click — the click maps to a source caret offset (rehypeSourceOffset/data-pos+caretFromPoint+sourceOffsetFromClick), applied when the textarea mounts. Takesmode+onModeChangeprops; exportsEditorMode.SanitizePanel.tsx— modal: loading / questions / preview / error phases; resume loop. Preview renders the polished result as Markdown (react-markdown+remark-gfm, shared.editor-rendered/.modal-renderedstyling — same asSummaryPanel).SummaryPanel.tsx— folder roll-up modal: loading / preview / error phases; Regenerate / Close / Save-to-folder. Preview renders the digest as Markdown (react-markdown+remark-gfm, styled via the shared.editor-renderedrules + a.summary-renderedcontainer override). A steer text box drives Regenerate: with an instruction it callsrefineSummary(session resume) to revise; empty it re-runs a freshsummarizeFolder. Keeps asessionId(updated each response) and areqIdref to drop stale/post-unmount responses.App.handleSaveSummarywrites the result.Dialog.tsx—ConfirmDialog: reusable confirm modal (Enter confirms, Esc/backdrop cancels;dangerprop → red primary button). Used for note/folder delete (Sidebar) and recycle-bin purges.TrashPanel.tsx— modal: list trashed items with Restore / Delete forever / Empty bin.api.ts— typed fetch helpers +Tree/TrashItem/Metypes. All paths go via/apiproxy and sendcredentials: "include". A sharedcheck(res)fires a registeredsetUnauthorizedHandleron any 401 (→ App shows login).me/loginbypasscheck(their 401 means "logged out"/"bad credentials", not a dropped session).exportPdfreturns the PDF blob.Login.tsx— full-screen email+password<form>reusing modal/button/input styles; loading/disabled + inline error;onSuccess(user)lifts state to App.export.ts—downloadMarkdown(name, md)(Blob →<a download>),exportNotePdf(name, md)(callsapi.exportPdfthen downloads),safeFilename.styles.css— minimal warm palette (cream bg, green accent); sidebar, folders, popover menu, drag-over highlight, modal, trash list, spinner.vite.config.ts— react plugin, port 12345, proxy/api→http://localhost:23456.
notes/— per-user rootsnotes/<userId>/(each with.mdnotes, folder subdirs,.trash/+trash.json) plus the SQLite auth DBnotes/.noter.db(+-wal/-shm). All gitignored.package.json(root) — scripts:dev(both via concurrently),dev:server,dev:client,build(= build:client),build:client,start(prod),add-user,install:all. New deps:better-sqlite3,md-to-pdf,github-markdown-css(+@types/better-sqlite3).- Plan/spec lives at
~/.claude/plans/i-want-to-build-humming-gizmo.md.
cd /Users/ethan/code/noter
npm run install:all # first-time deps (root + client; pulls Chromium for md-to-pdf)
npm run add-user -- you@bcg.com 'password' "Your Name" # create an account (no signup UI)
npm run dev # both servers (foreground); open http://localhost:12345 → log in
# individually:
npm run dev:server # backend :23456
npm run dev:client # frontend :12345
# production (one process serves built client + API on :23456, behind a TLS reverse proxy):
npm run build && npm startBackground (via Makefile):
make run-bg # start both in background; logs -> dev.log
make logs # tail -f dev.log
make kill # stop whatever's on :12345 / :23456
make run # foreground run (same as npm run dev)Release-style messages. First line is the version only (Release vX.Y.Z); leave a blank
line, then a short bullet list of what shipped — concise and meaningful, one bullet per
user-facing change. Example:
Release v1.1.2
- App logo (favicon + sidebar)
- Last-edited timestamp in header
- Esc returns to preview
- Browser click-through of the new auth + export flows (login screen, wrong-password
error, logout, mid-session 401 bounce,
.md/PDF buttons) — curl-verified end-to-end, not yet clicked in the browser. - Legacy notes migration — old flat
notes/*.md(if any) are not auto-moved into a user dir; document the manualmv notes/*.md notes/<id>/step or add anadd-user --claim-legacy. - Optional hardening (not built, trusted-team assumption): rate-limit
/api/login; namespace/verify--resumesessionId ownership (storesessionId→user_id) if the trust model tightens. Read the cleartext password off a hidden prompt inadd-userinstead of argv. - Browser click-test drag-and-drop (only build-verified so far).
- Browser click-through of ghost-text accept (Tab) and the Sanitize modal (questions + preview) — never done interactively.
-
Title-field Save creates a new file rather than renaming in place— resolved:saveNow()now renames the open file in place when the title changes. - Browser click-test of the new features (search results, autosave indicator transitions,
⌘N/⌘S/⌘Kshortcuts) — build- and curl-verified, not yet exercised in the browser. - Browser click-test folder summarize (✨ → preview → steer/Regenerate → Save). The endpoints are curl-verified end-to-end (real Opus run, steered refine over a session, self-inclusion guard); the modal/icon and steer box aren't yet clicked in the browser.
- No tests yet (personal v1). Add endpoint tests if it grows.
npm run add-user …, thennpm run devand click through the browser: log in, create/edit notes, sanitize, summarize,.md+ PDF export, logout. Confirm a second account is isolated.- Decide hosting: build +
npm startbehind a TLS-terminating reverse proxy (Caddy/nginx) sosecurecookies work (trust proxyis already on). Optionally add a Dockerfile.