- Strip server paths from public attachment responses (new PublicAttachment type) - Use $page.url.origin instead of window.location.origin (SSR-safe) - Fix toggle switch left-4.5 → left-[1.125rem] (valid Tailwind) - Add aria-label to SharingIndicator button for screen readers - Add opportunistic pruning to IP rate limiter to prevent unbounded growth - Reduce shared attachment cache to 1 hour (respect share revocation) - Update OpenAPI schema with PublicAttachment type - Add unit test asserting server paths are not leaked Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
14 KiB
Crumbs API
REST API for Crumbs by Bretzel — a self-hostable, offline-first notes app.
Base URL: http://localhost:3000
All endpoints require a session cookie (set by login) except where noted.
Auth
POST /api/auth/setup
First-time password setup
Creates the single user account. Only works once — returns 400 if already set up.
No authentication required.
Request:
{
"password": "password"
}
Response: 201
{
"success": true
}
Errors: 400 Setup already completed or invalid password
POST /api/auth/login
Log in
No authentication required.
Request:
{
"password": "password"
}
Response: 200
{
"success": true
}
Errors: 401 Invalid password
POST /api/auth/logout
Log out
Response: 200
{
"success": true
}
Notes
GET /api/notes
List notes
Parameters:
| Name | In | Type | Required | Description |
|---|---|---|---|---|
filter |
query | string | no | (all, archived, trashed) — default: all |
Response: 200
[
{
"id": "uuid",
"title": "title",
"content": "content",
"color": "default",
"pinned": true,
"archived": true,
"trashed": true,
"trashedAt": "2025-01-01T00:00:00.000Z",
"checklistMode": true,
"sortOrder": 1,
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z",
"version": 1,
"tags": [
"item"
],
"attachments": [
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
}
]
POST /api/notes
Create a note
Request:
{
"id": "uuid",
"title": "title",
"content": "content",
"color": "default",
"pinned": true,
"archived": true,
"trashed": true,
"checklistMode": true,
"sortOrder": 1
}
Response: 201
{
"id": "uuid",
"title": "title",
"content": "content",
"color": "default",
"pinned": true,
"archived": true,
"trashed": true,
"trashedAt": "2025-01-01T00:00:00.000Z",
"checklistMode": true,
"sortOrder": 1,
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z",
"version": 1,
"tags": [
"item"
],
"attachments": [
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
}
GET /api/notes/{id}
Get a note
Parameters:
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | string | yes |
Response: 200
{
"id": "uuid",
"title": "title",
"content": "content",
"color": "default",
"pinned": true,
"archived": true,
"trashed": true,
"trashedAt": "2025-01-01T00:00:00.000Z",
"checklistMode": true,
"sortOrder": 1,
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z",
"version": 1,
"tags": [
"item"
],
"attachments": [
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
}
Errors: 404 Note not found
PATCH /api/notes/{id}
Update a note
Partial update — only include changed fields.
Parameters:
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | string | yes |
Request:
{
"id": "uuid",
"title": "title",
"content": "content",
"color": "default",
"pinned": true,
"archived": true,
"trashed": true,
"checklistMode": true,
"sortOrder": 1
}
Response: 200
{
"id": "uuid",
"title": "title",
"content": "content",
"color": "default",
"pinned": true,
"archived": true,
"trashed": true,
"trashedAt": "2025-01-01T00:00:00.000Z",
"checklistMode": true,
"sortOrder": 1,
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z",
"version": 1,
"tags": [
"item"
],
"attachments": [
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
}
Errors: 404 Note not found
DELETE /api/notes/{id}
Permanently delete a note
Parameters:
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | string | yes |
Response: 200
{
"success": true
}
Errors: 404 Note not found
Collaboration
GET /api/notes/{id}/collaborators
List collaborators
Returns all collaborators for a note. Requires access (owner or collaborator).
Parameters:
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | string | yes |
Response: 200
[
{
"userId": 1,
"displayName": "displayName",
"email": "email",
"addedAt": "2025-01-01T00:00:00.000Z"
}
]
POST /api/notes/{id}/collaborators
Add a collaborator
Owner-only. Adds a user as collaborator on the note.
Parameters:
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | string | yes |
Request:
{
"userId": 1
}
Response: 201
[
{
"userId": 1,
"displayName": "displayName",
"email": "email",
"addedAt": "2025-01-01T00:00:00.000Z"
}
]
Errors: 400 Invalid request (missing userId, owner as collaborator), 409 User is already a collaborator
DELETE /api/notes/{id}/collaborators
Remove a collaborator
Owner can remove anyone. Collaborator can only remove self (leave). Use userId=self to leave.
Parameters:
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | string | yes | |
userId |
query | yes |
Response: 200
{
"success": true
}
Sharing
POST /api/notes/{id}/share
Create a public share link
Owner-only. Creates a public share URL for the note. Idempotent — returns existing token if already shared.
Parameters:
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | string | yes |
Response: 201
{
"token": "token",
"url": "url"
}
Errors: 404 Note not found or not owner
DELETE /api/notes/{id}/share
Revoke a public share link
Owner-only. Removes the public share link for the note.
Parameters:
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | string | yes |
Response: 204
Errors: 404 Note not found or not owner
GET /api/shared/{token}
Get a shared note (public)
No authentication required. Returns the note content for a valid share token. Rate limited to 60 requests per minute per IP.
No authentication required.
Parameters:
| Name | In | Type | Required | Description |
|---|---|---|---|---|
token |
path | string | yes |
Response: 200
{
"title": "title",
"content": "content",
"checklistMode": true,
"color": "default",
"attachments": [
{
"id": "id",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"featured": true,
"createdAt": "2025-01-01T00:00:00.000Z"
}
],
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z"
}
Errors: 404 Invalid token or note is trashed, 429 Rate limit exceeded
GET /api/shared/{token}/attachment/{attachmentId}
Get an attachment from a shared note (public)
No authentication required. Serves image binary for an attachment belonging to the shared note. Rate limited.
No authentication required.
Parameters:
| Name | In | Type | Required | Description |
|---|---|---|---|---|
token |
path | string | yes | |
attachmentId |
path | string | yes | |
thumb |
query | string | no | Set to '1' to get the thumbnail version (1) |
Response: 200
Errors: 404 Invalid token, note trashed, or attachment not found, 429 Rate limit exceeded
Users
GET /api/users/search
Search users
Search registered users by email or display name. Excludes the current user. Any authenticated user can access.
Parameters:
| Name | In | Type | Required | Description |
|---|---|---|---|---|
q |
query | string | yes | Search query (matches email or displayName) |
Response: 200
[
{
"id": 1,
"displayName": "displayName",
"email": "email"
}
]
Attachments
GET /api/notes/{id}/attachments
List or download attachments
Without attachmentId, lists all attachments. With attachmentId, returns the file.
Parameters:
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | string | yes | |
attachmentId |
query | string | no | |
thumb |
query | string | no | Set to "1" to return the thumbnail instead of the full image (requires attachmentId) (1) |
Response: 200
[
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
POST /api/notes/{id}/attachments
Upload an image attachment
Parameters:
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | string | yes |
Request: multipart/form-data with file field.
Response: 201
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
Errors: 400 Invalid file
DELETE /api/notes/{id}/attachments
Delete an attachment
Parameters:
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | string | yes | |
attachmentId |
query | string | yes |
Response: 200
{
"success": true
}
Search
GET /api/search
Full-text search
Searches across note titles, content, and tags. Excludes trashed notes.
Parameters:
| Name | In | Type | Required | Description |
|---|---|---|---|---|
q |
query | string | yes |
Response: 200
[
{
"id": "uuid",
"title": "title",
"content": "content",
"color": "default",
"pinned": true,
"archived": true,
"trashed": true,
"trashedAt": "2025-01-01T00:00:00.000Z",
"checklistMode": true,
"sortOrder": 1,
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z",
"version": 1,
"tags": [
"item"
],
"attachments": [
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
}
]
Tags
GET /api/tags
List all tags
Response: 200
[
{
"id": 1,
"name": "name"
}
]
Sync
GET /api/sync
Pull changes since timestamp
Parameters:
| Name | In | Type | Required | Description |
|---|---|---|---|---|
since |
query | integer | yes | Unix timestamp in milliseconds |
Response: 200
[
{
"id": "uuid",
"title": "title",
"content": "content",
"color": "default",
"pinned": true,
"archived": true,
"trashed": true,
"trashedAt": "2025-01-01T00:00:00.000Z",
"checklistMode": true,
"sortOrder": 1,
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z",
"version": 1,
"tags": [
"item"
],
"attachments": [
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
}
]
POST /api/sync
Push local changes
Request:
{
"changes": [
{
"noteId": "uuid",
"operation": "create",
"timestamp": 1,
"data": {
"id": "uuid",
"title": "title",
"content": "content",
"color": "default",
"pinned": true,
"archived": true,
"trashed": true,
"checklistMode": true,
"sortOrder": 1
}
}
]
}
Response: 200
{
"success": true
}
Errors: 400 Invalid changes payload
Settings
GET /api/settings/api-keys
List API keys
Response: 200
[
{
"id": "uuid",
"name": "name",
"keyPrefix": "keyPrefix",
"createdAt": "2025-01-01T00:00:00.000Z",
"lastUsedAt": "2025-01-01T00:00:00.000Z"
}
]
POST /api/settings/api-keys
Create API key
Request:
{
"name": "name"
}
Response: 201
{
"id": "id",
"key": "key",
"name": "name",
"keyPrefix": "keyPrefix"
}
DELETE /api/settings/api-keys/{id}
Revoke API key
Parameters:
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | string | yes |
Response: 200
{
"success": true
}
Errors: 404 Key not found
MCP
POST /api/mcp
MCP Streamable HTTP endpoint
Model Context Protocol endpoint. Accepts JSON-RPC 2.0 messages. Authenticate via Bearer token (API key).
Request:
{}
Response: 200
Errors: 400 Invalid session, 401 Invalid or missing API key