All endpoints are served under the /api prefix. Unless noted otherwise, every endpoint requires an Authorization: Bearer <token> header obtained from the login endpoint.
As of v3.10.0, the auth middleware also accepts the token via a ?token=<JWT> query parameter on GET requests. This is intended for browser elements that can't set a custom request header — <img src=…> for uploaded images, future <a href=…> for downloads — and accepts both JWT and mdnest_… API tokens. The Authorization header still takes precedence when both are present.
All error responses return JSON with an error field:
{"error": "description of the problem"}Common HTTP status codes across all endpoints:
| Status | Meaning |
|---|---|
| 400 | Bad request -- missing or invalid parameters, malformed body |
| 401 | Unauthorized -- missing, invalid, or expired JWT token |
| 404 | Not found -- namespace, file, or folder does not exist |
| 405 | Method not allowed -- wrong HTTP method for the endpoint |
| 409 | Conflict -- resource already exists (e.g., creating a note that already exists) |
| 500 | Internal server error |
Authenticate with username and password (local mode), or post a Firebase ID token (Firebase mode). In SSO mode this endpoint is unused — use /api/auth/sso/start instead. Returns a JWT valid for 30 days.
This is the only endpoint that does not require the Authorization header.
Request body (JSON):
| Field | Type | Required | Description |
|---|---|---|---|
username |
string | yes | Login username |
password |
string | yes | Login password |
Response (200 OK):
{"token": "eyJhbGciOiJIUzI1NiIs..."}Example:
curl -X POST http://localhost:8286/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "changeme"}'Error responses:
| Status | Body | Cause |
|---|---|---|
| 400 | {"error":"invalid request body"} |
Malformed or missing JSON body |
| 401 | {"error":"invalid credentials"} |
Wrong username or password |
Change the current user's password. Requires authentication.
Request body:
{
"current_password": "old-password",
"new_password": "new-password"
}Response (200 OK):
{"status": "password changed"}Example:
curl -X POST "http://localhost:8286/api/auth/change-password" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"current_password": "oldpass", "new_password": "newpass"}'Error responses:
| Status | Body | Cause |
|---|---|---|
| 400 | {"error":"new password is required"} |
Empty new password |
| 401 | {"error":"current password is incorrect"} |
Wrong current password |
Generate TOTP secret and QR code. Requires authentication.
Response: { secret, qrCode, url, recoveryCodes }
Verify the first TOTP code and enable 2FA. Requires authentication.
Body: { "code": "123456" }
Disable 2FA. Requires password confirmation.
Body: { "password": "current_password" }
Verify TOTP code during login (uses temp token, no auth required).
Body: { "tempToken": "...", "code": "123456" }
Response: { "token": "jwt..." }
Forced 2FA setup during login. Without code: returns QR + secret. With code: verifies and returns JWT.
Body: { "tempToken": "...", "code": "" } or { "tempToken": "...", "code": "123456" }
Change password during first login (uses temp token, no auth required).
Body: { "tempToken": "...", "newPassword": "new_pass" }
Admin: reset a user's 2FA. Requires admin role.
Body: { "userId": 5 }
Both endpoints are only mounted when USER_PROVIDER=sso is set and the OIDC client initializes successfully at startup. Under any other configuration they return 404. See docs/sso-setup.md for the operator checklist.
Kicks off the OIDC authorization-code + PKCE flow. Generates CSRF state, OIDC nonce, and the PKCE verifier; packs them into a short-lived HMAC-signed cookie; returns a 302 to the IdP's authorize URL.
Query parameters:
| Param | Description |
|---|---|
from |
Optional absolute path on this origin (e.g. /growth/foo.md) — where to land after a successful sign-in. Anything with a scheme, host, or query string is rejected and replaced with / to prevent open-redirect abuse. |
Response: 302 redirect to the IdP, with Set-Cookie: mdnest_sso_state=...; HttpOnly; SameSite=Lax; Max-Age=600.
The IdP redirects the browser here after the user authenticates. The backend verifies the state cookie, exchanges the code with PKCE, verifies the ID token, checks the email against the local users table, and mints the same JWT the classic password flow issues.
Query parameters (sent by the IdP): state, code, or error on failure.
Response: 302 redirect back to the frontend. On success:
Location: <FRONTEND_ORIGIN>/<from>#sso_token=<jwt>
On failure:
Location: <FRONTEND_ORIGIN>/#sso_error=<code>
| Error code | Meaning |
|---|---|
sso_denied:<reason> |
IdP rejected the sign-in (user cancelled, scope not approved). |
sso_failed |
State cookie expired, code exchange failed, or ID token verification failed. |
sso_not_invited |
IdP authenticated the user, but no mdnest row matches their email. |
sso_blocked |
User row exists but blocked=true. |
sso_internal |
Backend error during user lookup or JWT signing. |
The frontend consumes the fragment on load (localStorage.setItem('mdnest_token', …)), strips the hash, and proceeds normally. 2FA is not prompted in SSO mode — the IdP owns MFA.
Manage long-lived API tokens for CLI and MCP access. Tokens are prefixed with mdnest_ and stored as SHA-256 hashes.
List all API tokens (without the token values).
Response (200 OK):
[
{"id": "a1b2c3d4", "name": "my-laptop", "created_at": "2026-03-20T10:00:00Z"},
{"id": "e5f6g7h8", "name": "mcp-server", "created_at": "2026-03-21T15:30:00Z"}
]Create a new API token. The token value is only returned once — save it immediately.
Request body:
{"name": "my-laptop"}Response (201 Created):
{
"id": "a1b2c3d4",
"name": "my-laptop",
"token": "mdnest_abc123...",
"created_at": "2026-03-20T10:00:00Z"
}Revoke an API token.
Response (200 OK):
{"status": "revoked"}Examples:
# List tokens
curl "http://localhost:8286/api/auth/tokens" -H "Authorization: Bearer $TOKEN"
# Create a token
curl -X POST "http://localhost:8286/api/auth/tokens" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "my-laptop"}'
# Revoke a token
curl -X DELETE "http://localhost:8286/api/auth/tokens?id=a1b2c3d4" \
-H "Authorization: Bearer $TOKEN"These endpoints are only available when AUTH_MODE=multi. All require an admin role (superadmin or admin) — collaborators receive a 403.
v3.5.0 role hierarchy: superadmin (global), admin (namespace-scoped via the namespace_admins table), collaborator (grants only). Endpoints below indicate which role is required and how the response is filtered for namespace admins.
Create a new user. Available to any admin role; the namespace field is required for namespace admins (the new user is auto-granted permission='write' on / of that namespace; if role='admin' is also passed, the new user is added to namespace_admins for that ns). SuperAdmin can omit namespace and grant access separately.
Request body (JSON):
| Field | Type | Required | Description |
|---|---|---|---|
email |
string | yes | User's email (must be unique) |
username |
string | yes | Login username (must be unique) |
password |
string | yes | Initial password |
role |
string | no | superadmin, admin, or collaborator (default: collaborator). Only SuperAdmin can invite a SuperAdmin. |
namespace |
string | required for namespace admins | The namespace the new user is granted access to. Caller must admin this namespace. |
Response (201 Created):
{
"id": 2,
"email": "bob@example.com",
"username": "bob",
"role": "collaborator",
"invited_by": 1,
"created_at": "2026-03-28T12:00:00Z"
}Example:
curl -X POST "http://localhost:8286/api/admin/invite" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"email": "bob@example.com", "username": "bob", "password": "securepass", "role": "collaborator", "namespace": "growth"}'Error responses:
| Status | Body | Cause |
|---|---|---|
| 400 | {"error":"email, username, and password are required"} |
Missing fields |
| 400 | {"error":"role must be superadmin, admin, or collaborator"} |
Invalid role |
| 400 | {"error":"namespace is required when inviting as a namespace admin"} |
Non-superadmin caller didn't pass namespace |
| 403 | {"error":"admin access required"} |
Collaborator caller |
| 403 | {"error":"only superadmin can invite a superadmin"} |
Non-superadmin tried to mint a superadmin |
| 403 | {"error":"you don't admin that namespace"} |
Caller is not an admin of the target namespace |
| 409 | {"error":"email already in use"} |
Duplicate email |
| 409 | {"error":"username already in use"} |
Duplicate username |
List users. Available to any admin role; the result is filtered by caller scope: SuperAdmin sees all; namespace admin sees only users with grants or namespace_admins entries on namespaces they administer (plus self).
Response (200 OK):
[
{"id": 1, "email": "admin@mdnest.local", "username": "admin", "role": "superadmin", "created_at": "2026-03-28T10:00:00Z"},
{"id": 2, "email": "bob@example.com", "username": "bob", "role": "collaborator", "invited_by": 1, "created_at": "2026-03-28T12:00:00Z"}
]Update a user's global role. The new role must be one of superadmin, admin, or collaborator. Setting admin here only flips the global role string — to actually grant administrative power on a namespace, use POST /api/admin/namespace-admins instead.
Request body:
{"role": "admin"}Response (200 OK):
{"status": "ok"}Error responses:
| Status | Body | Cause |
|---|---|---|
| 400 | {"error":"role must be superadmin, admin, or collaborator"} |
Invalid role |
| 400 | {"error":"cannot remove the last superadmin"} |
Demoting the only superadmin |
| 403 | {"error":"superadmin access required"} |
Non-superadmin caller |
Delete a user. Access grants and namespace_admins rows are cascade-deleted.
Response (200 OK):
{"status": "deleted"}Error responses:
| Status | Body | Cause |
|---|---|---|
| 400 | {"error":"cannot delete yourself"} |
Attempting self-deletion |
| 400 | {"error":"cannot remove the last superadmin"} |
Deleting the only superadmin |
| 403 | {"error":"superadmin access required"} |
Non-superadmin caller |
| 404 | {"error":"user not found"} |
User ID does not exist |
Examples:
# List users (filtered for namespace admins)
curl "http://localhost:8286/api/admin/users" -H "Authorization: Bearer $TOKEN"
# Change role (superadmin only)
curl -X PUT "http://localhost:8286/api/admin/users?id=2" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"role": "admin"}'
# Delete user (superadmin only)
curl -X DELETE "http://localhost:8286/api/admin/users?id=2" \
-H "Authorization: Bearer $TOKEN"Reset another user's password. The new password is written immediately and must_change_password is set so the target is forced to pick their own on next login.
Resetting another superadmin's password is rejected (403). That's a lateral-escalation primitive — one compromised superadmin could lock out the others. The legitimate recovery path is the host-side mdnest-server reset-password CLI, which requires shell access on the server.
Available only when USER_PROVIDER=local. Federated providers reject the call (the IdP owns identity).
Request body:
{"user_id": 7, "new_password": "temp-Hk7p2Q9x"}Response (200 OK):
{"status": "ok"}Error responses:
| Status | Body | Cause |
|---|---|---|
| 400 | {"error":"user_id and new_password are required"} |
Missing field |
| 400 | {"error":"password reset is not available — identity is owned by your IdP"} |
USER_PROVIDER is firebase or sso |
| 403 | {"error":"superadmin access required"} |
Caller is not a superadmin |
| 403 | {"error":"cannot reset another superadmin's password from the UI — use the mdnest-server reset-password CLI on the host"} |
Target's role is superadmin |
| 404 | {"error":"user not found"} |
user_id does not exist |
Example:
curl -X POST "http://localhost:8286/api/admin/reset-password" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"user_id": 7, "new_password": "temp-Hk7p2Q9x"}'The new namespace_admins table maps users to the namespaces they can administer. Three endpoints manage it.
List the admins of a namespace. Caller must admin the namespace (or be SuperAdmin).
Response (200 OK):
[
{
"user_id": 20,
"username": "farooq",
"email": "farooq@example.com",
"namespace": "growth",
"granted_by": 19,
"created_at": "2026-04-27T11:38:26Z"
}
]Promote a user to admin of a namespace. Caller must already admin the namespace (or be SuperAdmin). Side effects: the target's users.role is bumped to admin if currently collaborator; an access_grants row with permission='write' on path='/' is created if one doesn't already exist (so the new admin can actually open the notes they administer). Idempotent — re-running on an existing pair returns {"status":"ok"} without changes.
Request body:
{"user_id": 20, "namespace": "growth"}Response (201 Created):
{"status": "ok"}Demote. Removes the namespace_admins row. If the user has no other rows after the delete, their users.role is reverted to collaborator. The auto-created write grant is left in place — operators who want to remove that access too should DELETE /api/admin/grants?id=… separately.
Response (200 OK):
{"status": "deleted"}Create an access grant for a user on a namespace or directory.
Request body (JSON):
| Field | Type | Required | Description |
|---|---|---|---|
user_id |
int | yes | User ID to grant access to |
namespace |
string | yes | Namespace name |
path |
string | no | Path within namespace (/ = full namespace, default) |
permission |
string | no | read or write (default: write) |
Response (201 Created):
{
"id": 1,
"user_id": 2,
"namespace": "work",
"path": "/",
"permission": "write",
"granted_by": 1,
"created_at": "2026-03-28T12:00:00Z"
}Access rules:
- Grant on
/covers the entire namespace - Grant on
/subdircovers that directory and everything below it writepermission impliesread- Admins have implicit full access (no grants needed)
List grants filtered by user or namespace.
Query parameters (one required):
| Param | Description |
|---|---|
user_id |
List all grants for a user |
namespace |
List all grants for a namespace |
Response (200 OK):
[
{"id": 1, "user_id": 2, "namespace": "work", "path": "/", "permission": "write", "granted_by": 1, "created_at": "2026-03-28T12:00:00Z"}
]Revoke an access grant.
Response (200 OK):
{"status": "deleted"}Examples:
# Grant user 2 write access to the entire 'work' namespace
curl -X POST "http://localhost:8286/api/admin/grants" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"user_id": 2, "namespace": "work", "path": "/", "permission": "write"}'
# Grant user 2 read-only access to 'work/docs'
curl -X POST "http://localhost:8286/api/admin/grants" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"user_id": 2, "namespace": "work", "path": "/docs", "permission": "read"}'
# List grants for user 2
curl "http://localhost:8286/api/admin/grants?user_id=2" \
-H "Authorization: Bearer $TOKEN"
# Revoke a grant
curl -X DELETE "http://localhost:8286/api/admin/grants?id=1" \
-H "Authorization: Bearer $TOKEN"Returns the current user's profile, role, access grants, and (v3.5.0+) the namespaces they administer. Requires authentication (any role).
Response (200 OK):
{
"id": 2,
"email": "bob@example.com",
"username": "bob",
"avatar_url": "https://lh3.googleusercontent.com/a/...",
"role": "admin",
"created_at": "2026-03-28T12:00:00Z",
"grants": [
{"id": 1, "namespace": "growth", "path": "/", "permission": "write"}
],
"is_super_admin": false,
"admin_namespaces": ["growth"]
}roleis one ofsuperadmin,admin, orcollaborator(v3.5.0+).is_super_administrueonly for the globalsuperadminrole.admin_namespacesis the list of namespaces this user administers (always empty forsuperadminandcollaborator;superadmin's authority is global, not stored as namespace_admins rows).avatar_urlis populated from the IdP'spictureOIDC claim on every SSO login (seedocs/sso-setup.md). Omitted from the JSON response when empty.
Report git-sync state for a namespace. Works in single mode (no user context → allowed) and multi mode (superadmin, or an admin of that namespace). Returns the repo/remote facts plus — when the git-sync daemon is running — its self-reported health, read from a git-excluded .mdnest-sync-status.json the daemon writes each cycle.
{
"isGitRepo": true,
"hasRemote": true,
"remoteUrl": "git@github.com:you/notes.git",
"branch": "main",
"lastCommit": "2026-07-02 16:53:09 +0000",
"hasSSHKey": true,
"daemonState": "ok",
"daemonMessage": "",
"daemonUpdated": "2026-07-02T16:53:00Z",
"ahead": 0,
"behind": 0
}daemonState—ok,error, orlocal-only(committed locally, no remote/key). Absent if the daemon hasn't written a status yet.daemonMessage— human-readable reason whendaemonStateiserror(e.g. "diverged from upstream (not fast-forward)").ahead/behind— commit counts vs the upstream at the daemon's last cycle.
The sidebar polls this every 60s and shows a red ✕ + Retry when daemonState is error, so a wedged background sync is visible instead of silent (v3.11.4+).
Trigger an immediate sync for a namespace (commit pending changes, pull --ff-only, push). Same auth as sync-status. This is what the sidebar Retry / Sync button calls.
Search notes by filename and content within a namespace. Returns filename matches first, then content matches with line numbers and snippets.
Query parameters:
| Param | Required | Description |
|---|---|---|
ns |
yes | Namespace name |
q |
yes | Search query (case-insensitive) |
Response (200 OK):
[
{"path": "ideas/search-feature.md", "line": 0, "snippet": "filename match"},
{"path": "notes/meeting.md", "line": 15, "snippet": "We discussed the search feature and decided to..."}
]line: 0= filename match;line: N= content match at line N- Snippets are truncated at 200 characters
Example:
curl "http://localhost:8286/api/search?ns=personal&q=meeting" \
-H "Authorization: Bearer $TOKEN"Tuning (via mdnest.conf):
| Setting | Default | Description |
|---|---|---|
SEARCH_MAX_RESULTS |
30 | Max results per query |
SEARCH_MAX_FILE_SIZE |
1048576 | Skip files larger than this (bytes) |
SEARCH_WORKERS |
8 | Parallel file readers |
SEARCH_CACHE_TTL |
30 | File list cache lifetime (seconds) |
List all available namespaces. A namespace corresponds to a mounted directory (a top-level subdirectory inside NOTES_DIR).
Query parameters: none
Response (200 OK):
["personal", "work"]Returns a sorted JSON array of namespace name strings. Hidden directories (those starting with .) are excluded.
Example:
TOKEN="eyJhbGciOiJIUzI1NiIs..."
curl http://localhost:8286/api/namespaces \
-H "Authorization: Bearer $TOKEN"Retrieve the full directory tree for a namespace.
Query parameters:
| Param | Required | Description |
|---|---|---|
ns |
yes | Namespace name |
Response (200 OK):
{
"name": "root",
"type": "folder",
"path": "",
"children": [
{
"name": "guides",
"type": "folder",
"path": "guides",
"children": [
{
"name": "getting-started.md",
"type": "file",
"path": "guides/getting-started.md"
}
]
},
{
"name": "todo.md",
"type": "file",
"path": "todo.md"
}
]
}Folders are sorted before files. Within each group, items are sorted alphabetically (case-insensitive). Hidden files and directories (names starting with .) are excluded.
Example:
curl "http://localhost:8286/api/tree?ns=personal" \
-H "Authorization: Bearer $TOKEN"Error responses:
| Status | Body | Cause |
|---|---|---|
| 400 | {"error":"ns parameter is required"} |
Missing ns query parameter |
| 404 | {"error":"namespace not found"} |
Namespace directory does not exist |
All note endpoints use the same URL path with different HTTP methods.
Read the contents of a note.
Query parameters:
| Param | Required | Description |
|---|---|---|
ns |
yes | Namespace name |
path |
yes | Relative path to the file within the namespace |
Response (200 OK):
Returns the raw file content with Content-Type: text/markdown; charset=utf-8.
# My Note
Some content here.
Example:
curl "http://localhost:8286/api/note?ns=personal&path=todo.md" \
-H "Authorization: Bearer $TOKEN"Error responses:
| Status | Body | Cause |
|---|---|---|
| 400 | {"error":"invalid path"} |
Path is empty or attempts directory traversal |
| 404 | {"error":"not found"} |
File does not exist |
Create a new note. Fails if the file already exists.
Query parameters:
| Param | Required | Description |
|---|---|---|
ns |
yes | Namespace name |
path |
yes | Relative path for the new file |
Request body: Raw text content for the note (can be empty).
Response (201 Created):
{"status": "created"}Parent directories are created automatically if they do not exist.
Example:
curl -X POST "http://localhost:8286/api/note?ns=personal&path=journal/2025-01-15.md" \
-H "Authorization: Bearer $TOKEN" \
-d "# January 15
Today I started using mdnest."Error responses:
| Status | Body | Cause |
|---|---|---|
| 400 | {"error":"invalid path"} |
Path is empty or attempts directory traversal |
| 409 | {"error":"file already exists"} |
A file already exists at that path |
Update an existing note. Fails if the file does not exist.
Query parameters:
| Param | Required | Description |
|---|---|---|
ns |
yes | Namespace name |
path |
yes | Relative path to the file |
allow-empty |
no | Set to 1 to permit overwriting a non-empty file with empty content. Without this, the backend returns 409 to defend against destructive autosave (v3.6.1+). |
restore-from |
no | A 7-40 char hex commit SHA. When set, the request is treated as a deliberate version restore and the websocket file-changed broadcast carries reason: "restored" so other connected users see an info banner instead of the conflict banner (v3.7.0+). |
Request body: The new file content (replaces the entire file).
Headers: Optional If-Match: <etag> enforces optimistic concurrency — a stale ETag returns 409 with the current ETag in the response.
Response (200 OK):
{"status": "ok", "etag": "\"<sha256>\""}Example:
curl -X PUT "http://localhost:8286/api/note?ns=personal&path=todo.md" \
-H "Authorization: Bearer $TOKEN" \
-d "# Todo
- [x] Set up mdnest
- [ ] Write documentation"
# Restore a file to an old version (also re-broadcasts as a restore event):
curl -X PUT "http://localhost:8286/api/note?ns=personal&path=todo.md&restore-from=a1b2c3d" \
-H "Authorization: Bearer $TOKEN" \
--data-binary @old-version.mdError responses:
| Status | Body | Cause |
|---|---|---|
| 400 | {"error":"invalid path"} |
Path is empty or attempts directory traversal |
| 404 | {"error":"not found"} |
File does not exist |
| 409 | {"error":"refusing to overwrite a non-empty note with empty content; pass ?allow-empty=1 to confirm"} |
v3.6.1+ guard against destructive autosave |
| 409 | {"error":"file was modified by another user", "etag":"..."} |
If-Match ETag is stale |
Return up to 50 most recent commits affecting the given file, newest first. Reads from the namespace's git-sync repository (.git/ in the namespace directory). Works in single mode and multi mode identically; the only requirement is that git-sync is configured for the namespace.
Query parameters:
| Param | Required | Description |
|---|---|---|
ns |
yes | Namespace name |
path |
yes | Relative path to the file |
Response (200 OK):
[
{"commit": "a1b2c3d4...", "unix_ts": 1714567890, "author": "Alice", "message": "sync: 2026-05-03 10:42:13 UTC"},
{"commit": "e5f6789a...", "unix_ts": 1714560000, "author": "Bob", "message": "Edit"}
]Returns an empty array when the namespace is a git repo but no commits have touched the file yet.
Error responses:
| Status | Body | Cause |
|---|---|---|
| 400 | {"error":"invalid path"} |
Path traversal or empty path |
| 404 | {"error":"namespace not found"} |
Namespace doesn't exist |
| 404 | {"error":"git-sync is not configured for this namespace"} |
Namespace dir has no .git/ |
Return the file's content as it was at a specific commit. Read-only; the response carries no ETag (history is a snapshot, not editable through this endpoint — restoration goes through PUT /api/note?restore-from=<sha>).
Query parameters:
| Param | Required | Description |
|---|---|---|
ns |
yes | Namespace name |
path |
yes | Relative path to the file |
ref |
yes | A 7-40 char hex commit SHA. Branch names, HEAD~N, tags, and other ref forms are rejected. |
Response (200 OK): The file's content at that commit, as text/markdown; charset=utf-8. The mdnest invisible note-ID marker is stripped (matching GET /api/note behaviour).
Error responses:
| Status | Body | Cause |
|---|---|---|
| 400 | {"error":"invalid ref — must be a commit SHA"} |
ref doesn't match ^[0-9a-f]{7,40}$ |
| 404 | {"error":"file not found at that commit"} |
The path didn't exist at that commit, or the SHA is unknown |
| 404 | {"error":"git-sync is not configured for this namespace"} |
Namespace dir has no .git/ |
Append or prepend text to a note. Creates the file if it doesn't exist.
Query parameters:
| Param | Required | Description |
|---|---|---|
ns |
yes | Namespace name |
path |
yes | Relative path to the note |
position |
no | top (prepend) or bottom (append, default) |
Request body: Plain text to append/prepend.
Response (200 OK):
{"status": "ok"}Examples:
# Append text to a note
curl -X PATCH "http://localhost:8286/api/note?ns=personal&path=log.md&position=bottom" \
-H "Authorization: Bearer $TOKEN" \
-d "## $(date) - New entry"
# Prepend text to the top of a note
curl -X PATCH "http://localhost:8286/api/note?ns=personal&path=log.md&position=top" \
-H "Authorization: Bearer $TOKEN" \
-d "# Important update"
# Append to a file that doesn't exist yet (creates it)
curl -X PATCH "http://localhost:8286/api/note?ns=personal&path=new-log.md" \
-H "Authorization: Bearer $TOKEN" \
-d "First entry"Error responses:
| Status | Body | Cause |
|---|---|---|
| 400 | {"error":"invalid path"} |
Path is empty or attempts directory traversal |
| 400 | {"error":"position must be top or bottom"} |
Invalid position value |
Delete a note or folder. If the path points to a directory, it and all its contents are removed recursively.
Query parameters:
| Param | Required | Description |
|---|---|---|
ns |
yes | Namespace name |
path |
yes | Relative path to the file or folder |
Response (200 OK):
{"status": "deleted"}Example:
# Delete a single note
curl -X DELETE "http://localhost:8286/api/note?ns=personal&path=old-note.md" \
-H "Authorization: Bearer $TOKEN"
# Delete an entire folder
curl -X DELETE "http://localhost:8286/api/note?ns=personal&path=archive/2023" \
-H "Authorization: Bearer $TOKEN"Error responses:
| Status | Body | Cause |
|---|---|---|
| 400 | {"error":"invalid path"} |
Path is empty or attempts directory traversal |
| 404 | {"error":"not found"} |
File or folder does not exist |
Create a new folder. Parent directories are created automatically.
Query parameters:
| Param | Required | Description |
|---|---|---|
ns |
yes | Namespace name |
path |
yes | Relative path for the new folder |
Request body: None.
Response (201 Created):
{"status": "created"}Example:
curl -X POST "http://localhost:8286/api/folder?ns=personal&path=projects/mdnest" \
-H "Authorization: Bearer $TOKEN"Error responses:
| Status | Body | Cause |
|---|---|---|
| 400 | {"error":"invalid path"} |
Path is empty or attempts directory traversal |
Upload a file (typically an image) as a multipart form. The file is saved in the same directory as the note referenced by path.
Query parameters:
| Param | Required | Description |
|---|---|---|
ns |
yes | Namespace name |
path |
yes | Relative path to the note the upload is associated with |
Request body: multipart/form-data with a file field. Maximum upload size is 32 MB.
Response (200 OK):
{"url": "journal/screenshot.png"}The url field contains the relative path of the uploaded file within the namespace. Use this path with the file serving endpoint to reference the image in your notes.
Example:
curl -X POST "http://localhost:8286/api/upload?ns=personal&path=journal/2025-01-15.md" \
-H "Authorization: Bearer $TOKEN" \
-F "file=@screenshot.png"Error responses:
| Status | Body | Cause |
|---|---|---|
| 400 | {"error":"missing file field"} |
No file field in the multipart form |
| 400 | {"error":"invalid path"} |
Path is empty or attempts directory traversal |
| 400 | {"error":"invalid upload destination"} |
Destination path resolves outside namespace |
Move a file or folder from one location to another within the same namespace.
Query parameters:
| Param | Required | Description |
|---|---|---|
ns |
yes | Namespace name |
from |
yes | Current relative path of the file or folder |
to |
yes | Destination relative path |
Response (200 OK):
{"status": "moved"}The destination's parent directories are created automatically if they do not exist.
Example:
# Move a note
curl -X POST "http://localhost:8286/api/move?ns=personal&from=todo.md&to=archive/todo.md" \
-H "Authorization: Bearer $TOKEN"
# Move a folder
curl -X POST "http://localhost:8286/api/move?ns=personal&from=drafts&to=archive/drafts" \
-H "Authorization: Bearer $TOKEN"Error responses:
| Status | Body | Cause |
|---|---|---|
| 400 | {"error":"invalid source path"} |
Source path is empty or attempts directory traversal |
| 400 | {"error":"invalid destination path"} |
Destination path is empty or attempts directory traversal |
| 404 | {"error":"source not found"} |
Source file or folder does not exist |
Requires multi-user mode with live collab enabled (
AUTH_MODE=multiandENABLE_LIVE_COLLAB=true). The/api/commentsroute is only registered under that combination; in any other mode the endpoints return 404. All endpoints require a valid JWT.
Comments are anchored to notes by an invisible UUID marker (<!-- mdnest:<uuid> -->) appended at the end of each note's content. The marker is stripped from the response body on GET and re-injected on PUT, so clients never see it. Comment data lives at <namespace>/.mdnest/comments/<uuid>.jsonl — append-only JSONL with soft deletes. Moving or renaming a file preserves its UUID and therefore its comments.
List all active (non-deleted) comments for a note, including both top-level threads and replies.
Query parameters:
| Param | Description |
|---|---|
ns |
Namespace name (required) |
path |
Note path within the namespace (required) |
Response:
[
{
"id": "c_a1b2c3d4",
"parentId": "",
"authorId": 42,
"author": "alice",
"rangeStart": 120,
"rangeEnd": 145,
"anchorText": "the selected phrase",
"body": "What did you mean here?",
"createdAt": "2026-04-21T10:14:32Z",
"resolved": false
},
{
"id": "c_e5f6a7b8",
"parentId": "c_a1b2c3d4",
"authorId": 7,
"author": "bob",
"rangeStart": 120,
"rangeEnd": 145,
"anchorText": "the selected phrase",
"body": "Reworded — better now?",
"createdAt": "2026-04-21T10:18:05Z",
"resolved": false
}
]A comment with a non-empty parentId is a reply within the parent's thread.
Create a comment or reply.
Query parameters:
| Param | Description |
|---|---|
ns |
Namespace name (required) |
path |
Note path within the namespace (required) |
Request body:
{
"parentId": "",
"rangeStart": 120,
"rangeEnd": 145,
"anchorText": "the selected phrase",
"body": "What did you mean here?"
}- Omit or empty-string
parentIdfor a top-level thread. - Set
parentIdto an existing comment'sidto post a reply. Replies typically copy the parent'srangeStart/rangeEnd/anchorTextso they share an anchor. bodyis required.
Response: the created comment (201 Created).
Update a comment — typically to toggle resolved state, optionally to edit the body.
Query parameters:
| Param | Description |
|---|---|
ns |
Namespace name (required) |
path |
Note path within the namespace (required) |
id |
Comment id (required) |
Request body (any subset):
{
"resolved": true,
"body": "Updated comment text"
}Response: {"status":"ok"}.
Soft-delete a comment. The JSONL file is rewritten with a deletedAt timestamp on the matching entry; subsequent GET calls filter it out.
Query parameters:
| Param | Description |
|---|---|
ns |
Namespace name (required) |
path |
Note path within the namespace (required) |
id |
Comment id (required) |
Response: {"status":"ok"}.
Serve a file from a namespace. Primarily used to display uploaded images in the preview. The namespace is embedded in the URL path, not as a query parameter.
URL parameters:
| Segment | Description |
|---|---|
{namespace} |
Namespace name (first path segment after /api/files/) |
{path} |
Remaining path segments identify the file within the namespace |
Response: The raw file content with an appropriate Content-Type header inferred by the server.
Example:
curl "http://localhost:8286/api/files/personal/journal/screenshot.png" \
-H "Authorization: Bearer $TOKEN" \
--output screenshot.pngError responses:
| Status | Body | Cause |
|---|---|---|
| 400 | {"error":"missing path"} |
No path provided after /api/files/ |
| 400 | {"error":"invalid namespace"} |
Namespace contains slashes, dots, or traversal patterns |
| 400 | {"error":"invalid path"} |
File path attempts directory traversal |
| 404 | {"error":"namespace not found"} |
Namespace directory does not exist |