Compare commits

...
18 Commits
Author SHA1 Message Date
github-actions[bot] a98efc0024 chore: bump version to 0.9.0 2026-03-09 01:32:03 +00:00
Frederic CabassutandClaude Opus 4.6 8966fcddf6 fix: resolve type errors in notes-service, MCP server, and test helpers
- Add missing userId arg to syncNoteTags() calls in notes-service
- Add seedUser option to createTestDb() for test setup
- Add explicit type annotations to MCP server.tool() callbacks (zod v4)
- Fix ambiguous getByText selector in settings e2e test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:31:18 +01:00
Frederic CabassutandClaude Opus 4.6 908bcf4d96 docs: update CLAUDE.md to reflect multi-user auth and current project structure
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:25:56 +01:00
Frederic CabassutandClaude Opus 4.6 b3f25457fe fix: clarify multi-user feature description on landing page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:25:11 +01:00
Frederic CabassutandClaude Opus 4.6 cfa1693d86 feat: add MCP, multi-user, and OAuth features to landing page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:25:11 +01:00
Frederic CabassutandClaude Opus 4.6 629afa1c8d docs: add OAuth/auth docs and install instructions in README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:20:07 +01:00
Frederic CabassutandClaude Opus 4.6 27b167edb1 feat: admin can revoke user sessions (force logout)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:20:03 +01:00
Frederic CabassutandClaude Opus 4.6 9d67e7b9d1 feat: URL-based routing for archive, trash, and tags
Add /archive, /trash, /tag/[name] routes with proper <a> links
in the sidebar. Tags visible regardless of current filter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:19:55 +01:00
Frederic CabassutandClaude Opus 4.6 4d5ea35204 feat: add logout button and delete account to settings
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:19:47 +01:00
Frederic CabassutandClaude Opus 4.6 7255169da8 refactor: remove user avatar dropdown from header
Logout and Settings access moved to sidebar and settings page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:19:43 +01:00
Frederic CabassutandClaude Opus 4.6 fe88b15a0d docs: add MCP server and API key documentation
- Update FEATURES.md, ARCHITECTURE.md, DEPLOYMENT.md with MCP sections
- Add MCP endpoints to OpenAPI spec and regenerate API.md
- Add rebase-before-PR best practice to CLAUDE.md git conventions
- Update README with MCP/API feature mention

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:03:04 +01:00
Frederic CabassutandClaude Opus 4.6 f327e8dcb2 test: add E2E tests for MCP endpoint and Settings page
- MCP tests: initialize, create/get note, search, auth rejection
- Settings tests: create key, revoke key, sidebar nav, subpage nav

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:03:04 +01:00
Frederic CabassutandClaude Opus 4.6 d4ca635346 feat: add Settings subpages with API key management UI
- Settings layout with sidebar nav (Profile, MCP Server, Users)
- Profile page: display name, email, password, sessions
- MCP page: API key CRUD, JSON config with copy button, LLM prompt
- Config auto-populates with actual API key when one is created
- Remove duplicate Settings link from sidebar
- Users page integrated into settings nav (admin only)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:03:04 +01:00
Frederic CabassutandClaude Opus 4.6 e1426c1ef6 feat: add MCP server with 14 tools and shared notes service
- Shared notes-service.ts with userId-scoped queries for DRY
- McpServer with tools: list/get/create/update/trash/restore/
  archive/unarchive/delete notes, search, list tags, pin,
  reorder, upload image
- Streamable HTTP endpoint at POST /api/mcp
- Session management with GET/DELETE handlers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:03:04 +01:00
Frederic CabassutandClaude Opus 4.6 a82ba8f61d feat: add API key CRUD and Bearer token auth for MCP
- API key generation with SHA-256 hashing and crumbs_ prefix
- User-scoped key validation, listing, and deletion
- Bearer token auth in hooks.server.ts for /api/mcp paths
- Populates event.locals.user from API key for downstream scoping
- 13 unit tests covering CRUD, validation, and user isolation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:03:04 +01:00
Frederic CabassutandClaude Opus 4.6 32a3504568 feat: add API keys schema and MCP SDK dependency
- Add `api_keys` table with userId foreign key to schema
- Add `@modelcontextprotocol/sdk` and `zod` dependencies
- Add auto-create SQL and test helpers for api_keys table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:03:04 +01:00
Frederic CabassutandClaude Opus 4.6 b3d40999c9 fix: enforce note ownership across all API endpoints
- Add userId check to /api/notes/reorder (was completely unprotected)
- Scope syncNoteTags by userId to prevent cross-user tag pollution
- Verify attachmentId belongs to noteId in attachment operations (IDOR fix)
- Remove DEFAULT 0 from userId columns so missing userId fails loudly
- Update test helpers and test data to provide required userId

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:50:12 +01:00
github-actions[bot] d45bc6fa21 chore: pin docker-compose to v0.8.1 [skip ci] 2026-03-09 00:33:34 +00:00
52 changed files with 3835 additions and 447 deletions
+10 -3
View File
@@ -2,7 +2,7 @@
## Project overview
Crumbs by Bretzel — self-hostable note-taking app. Single-user, offline-first PWA with CRDT-based sync.
Crumbs by Bretzel — self-hostable note-taking app. Multi-user with password auth + OAuth/SSO, offline-first PWA with CRDT-based sync.
## Tech stack
@@ -10,7 +10,7 @@ Crumbs by Bretzel — self-hostable note-taking app. Single-user, offline-first
- **Adapter**: `@sveltejs/adapter-node` (builds to `build/`, runs via `node build`)
- **Styling**: Tailwind CSS v4 (via `@tailwindcss/vite`)
- **Database**: SQLite via better-sqlite3 + Drizzle ORM (WAL mode, schema at `src/lib/server/db/schema.ts`)
- **Auth**: Single-user password auth with Argon2 hashing, session cookies (30-day expiry)
- **Auth**: Multi-user with password auth (Argon2) + OAuth (Google, GitHub, OIDC), role-based (admin/user), session cookies (30-day expiry)
- **PWA**: `@vite-pwa/sveltekit` for service worker and offline caching
- **Package manager**: pnpm
@@ -75,7 +75,12 @@ src/
(app)/ # Layout group: main app (header, sidebar, footer)
+layout.svelte # App shell with Header, Sidebar, Toast
+page.svelte # Main notes view
api/auth/ # Login, logout, setup endpoints
archive/ # Archived notes view
trash/ # Trashed notes view
tag/[name]/ # Notes filtered by tag
settings/ # Settings with tabbed layout (profile, MCP, users)
api/auth/ # Login, logout, setup, OAuth endpoints
api/admin/users/ # Admin user management (CRUD, role, sessions)
api/notes/ # CRUD + attachments
api/search/ # Full-text search
api/sync/ # Offline sync
@@ -129,7 +134,9 @@ A `Makefile` wraps all common tasks for tool-agnostic usage. Run `make help` to
- **Conventional commits**: Use `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:` prefixes
- **Commit regularly**: Bundle related changes into logical commits after each step or feature slice — don't accumulate a large diff
- **Main branch**: `main` — use as base for all PRs and feature branches
- **Feature branches**: Use `feat/<name>` branches with worktrees for isolation
- **Rebase before PR**: Always `git rebase origin/main` (not merge) before pushing or creating a PR to keep a clean linear history and avoid merge conflicts in the PR
## CI/CD
+34 -3
View File
@@ -11,7 +11,7 @@
A self-hostable, offline-first note-taking app inspired by Google Keep. Part of the [Bretzel](https://bretzel.app) app universe.
## Quick Start
## Install
### Docker (Recommended)
@@ -19,7 +19,37 @@ A self-hostable, offline-first note-taking app inspired by Google Keep. Part of
docker compose up -d
```
Open http://localhost:3000 and set your password on first visit.
Open http://localhost:3000 and create your admin account on first visit.
#### With OAuth (optional)
```yaml
# docker-compose.yml
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:latest
ports:
- "3000:3000"
volumes:
- crumbs-data:/data
environment:
- ORIGIN=https://notes.example.com
- AUTH_GOOGLE_ID=your-google-client-id
- AUTH_GOOGLE_SECRET=your-google-client-secret
restart: unless-stopped
volumes:
crumbs-data:
```
### Manual (Node.js)
```bash
git clone <repo-url> crumbs && cd crumbs
pnpm install
pnpm build
DATABASE_URL=./data/crumbs.db ORIGIN=http://localhost:3000 node build
```
### Development
@@ -42,7 +72,8 @@ pnpm build # Production build
- Organize with #tags, pinning, archive, and trash
- Full-text search across titles, content, and tags
- PWA — installable, works offline via IndexedDB + LWW CRDT sync
- Single-user password auth (Argon2)
- MCP server — let AI assistants (Claude Code, etc.) manage your notes
- Multi-user auth (Argon2) with optional OAuth/SSO (Google, GitHub, OIDC)
- Docker deployment with a single command
See [docs/FEATURES.md](docs/FEATURES.md) for detailed feature documentation.
+1 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.7.2
image: ghcr.io/bretzel-app/crumbs:0.8.1
container_name: crumbs
ports:
- "3000:3000"
+158
View File
@@ -111,6 +111,17 @@ List notes
"version": 1,
"tags": [
"item"
],
"attachments": [
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
}
]
@@ -157,6 +168,17 @@ Create a note
"version": 1,
"tags": [
"item"
],
"attachments": [
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
}
```
@@ -192,6 +214,17 @@ Get a note
"version": 1,
"tags": [
"item"
],
"attachments": [
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
}
```
@@ -247,6 +280,17 @@ Partial update — only include changed fields.
"version": 1,
"tags": [
"item"
],
"attachments": [
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
}
```
@@ -291,6 +335,7 @@ Without `attachmentId`, lists all attachments. With `attachmentId`, returns the
|------|----|------|----------|-------------|
| `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`
@@ -302,6 +347,7 @@ Without `attachmentId`, lists all attachments. With `attachmentId`, returns the
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
@@ -330,6 +376,7 @@ Upload an image attachment
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
```
@@ -393,6 +440,17 @@ Searches across note titles, content, and tags. Excludes trashed notes.
"version": 1,
"tags": [
"item"
],
"attachments": [
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
}
]
@@ -451,6 +509,17 @@ Pull changes since timestamp
"version": 1,
"tags": [
"item"
],
"attachments": [
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
}
]
@@ -498,3 +567,92 @@ Push local changes
**Errors:** `400` Invalid changes payload
---
## Settings
### `GET /api/settings/api-keys`
List API keys
**Response:** `200`
```json
[
{
"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:**
```json
{
"name": "name"
}
```
**Response:** `201`
```json
{
"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`
```json
{
"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:**
```json
{}
```
**Response:** `200`
**Errors:** `400` Invalid session, `401` Invalid or missing API key
---
+27 -3
View File
@@ -92,23 +92,47 @@ For a single-user app with multiple devices:
See `src/lib/server/db/schema.ts` for the complete Drizzle schema.
Key tables:
- `users` - Single user (password hash)
- `users` - User accounts (password hash, OAuth provider link, role)
- `sessions` - Auth sessions (30-day TTL)
- `notes` - Core note data with version tracking
- `tags` - Unique tag names
- `note_tags` - Many-to-many note ↔ tag
- `attachments` - Image file metadata (path + thumbnailPath)
- `api_keys` - MCP API keys (SHA-256 hashed, prefix for display)
- `sync_log` - Sync operation history
## MCP Server
Crumbs includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) server that allows AI assistants (Claude Code, Claude Desktop, etc.) to interact with notes programmatically.
### Architecture
- **Endpoint**: `POST /api/mcp` (Streamable HTTP transport)
- **Auth**: Bearer token via API keys (generated in Settings)
- **Sessions**: Stateful per MCP spec — session ID in `mcp-session-id` header
- **Transport**: `WebStandardStreamableHTTPServerTransport` (works with SvelteKit's Web Standard Request/Response)
### Shared Logic
MCP tool handlers and REST API routes both call the same service layer (`src/lib/server/notes-service.ts`). This avoids duplicating business logic:
```
REST API routes ──┐
├──► notes-service.ts ──► Drizzle DB
MCP tool handlers ┘
```
### Tools (14)
`list_notes`, `get_note`, `create_note`, `update_note`, `trash_note`, `restore_note`, `archive_note`, `unarchive_note`, `delete_note`, `search_notes`, `list_tags`, `pin_note`, `reorder_notes`, `upload_image`
## File Organization
```
src/lib/server/ # Server-only code (DB, auth, attachments)
src/lib/server/ # Server-only code (DB, auth, attachments, notes-service)
src/lib/server/mcp/ # MCP server + tool definitions
src/lib/sync/ # Sync engine (shared types, server + client implementations)
src/lib/stores/ # Svelte reactive stores (notes, theme, auth, search)
src/lib/components/ # Svelte UI components
src/lib/utils/ # Pure utility functions (markdown, tags, colors)
src/lib/types/ # TypeScript type definitions
src/routes/api/ # REST API endpoints
src/routes/api/ # REST API + MCP endpoints
src/routes/ # Page routes (SvelteKit file-based routing)
```
+88
View File
@@ -100,6 +100,8 @@ node build
## Environment Variables
### Core
| Variable | Default | Description |
|----------|---------|-------------|
| `DATABASE_URL` | `./data/crumbs.db` | SQLite database path |
@@ -108,6 +110,49 @@ node build
| `PORT` | `3000` | HTTP port |
| `NODE_ENV` | `development` | Set to `production` for deployment |
### OAuth Providers (optional)
Enable social login by setting the client ID and secret for any provider. Users who sign in via OAuth are created automatically (invite-only when combined with admin approval).
| Variable | Description |
|----------|-------------|
| `AUTH_GOOGLE_ID` | Google OAuth client ID |
| `AUTH_GOOGLE_SECRET` | Google OAuth client secret |
| `AUTH_GITHUB_ID` | GitHub OAuth client ID |
| `AUTH_GITHUB_SECRET` | GitHub OAuth client secret |
| `AUTH_OIDC_ISSUER` | Generic OIDC issuer URL (e.g. `https://accounts.example.com`) |
| `AUTH_OIDC_ID` | OIDC client ID |
| `AUTH_OIDC_SECRET` | OIDC client secret |
| `AUTH_OIDC_NAME` | Display name for the OIDC button (default: "SSO") |
**Callback URLs** — register these in your provider's dashboard:
| Provider | Callback URL |
|----------|-------------|
| Google | `https://your-domain/api/auth/callback/google` |
| GitHub | `https://your-domain/api/auth/callback/github` |
| OIDC | `https://your-domain/api/auth/callback/oidc` |
#### Docker example with Google OAuth
```yaml
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:latest
ports:
- "3000:3000"
volumes:
- crumbs-data:/data
environment:
- ORIGIN=https://notes.example.com
- AUTH_GOOGLE_ID=123456789.apps.googleusercontent.com
- AUTH_GOOGLE_SECRET=GOCSPX-xxxxxxxxxxxxx
restart: unless-stopped
volumes:
crumbs-data:
```
## Health Check
The Docker image includes a health check that pings `/login`:
@@ -116,6 +161,48 @@ The Docker image includes a health check that pings `/login`:
docker inspect --format='{{.State.Health.Status}}' crumbs
```
## MCP Server Configuration
Crumbs includes a built-in MCP (Model Context Protocol) server that allows AI assistants to interact with your notes.
### Setup
1. Open **Settings** in the Crumbs UI
2. Create an API key (give it a name like "Claude Code")
3. Copy the key (shown only once)
4. Configure your MCP client:
```json
{
"mcpServers": {
"crumbs": {
"type": "streamable-http",
"url": "https://your-crumbs-instance/api/mcp",
"headers": {
"Authorization": "Bearer YOUR_API_KEY"
}
}
}
}
```
### Available Tools
| Tool | Description |
|------|-------------|
| `list_notes` | List notes (filter by status/tag) |
| `get_note` | Get a note by ID |
| `create_note` | Create a new note |
| `update_note` | Update a note |
| `trash_note` / `restore_note` | Move to/from trash |
| `archive_note` / `unarchive_note` | Archive/unarchive |
| `delete_note` | Permanently delete |
| `search_notes` | Full-text search |
| `list_tags` | List all tags |
| `pin_note` | Pin/unpin a note |
| `reorder_notes` | Set sort orders |
| `upload_image` | Attach image from URL |
## Security Notes
- Always use HTTPS in production (via reverse proxy)
@@ -123,3 +210,4 @@ docker inspect --format='{{.State.Health.Status}}' crumbs
- The `ORIGIN` variable must match your actual domain for CSRF protection
- Sessions expire after 30 days
- Passwords are hashed with Argon2
- API keys are SHA-256 hashed (never stored in plain text)
+32 -3
View File
@@ -86,12 +86,26 @@
- Clear search to restore original view
- Case-insensitive matching
### Single-User Auth
- First-run setup: create password (min 8 chars)
### Authentication
- Multi-user support with role-based access (admin / user)
- First-run setup: create admin account (min 8 chars password)
- Argon2 password hashing (industry standard)
- Session-based auth with 30-day expiry
- Active session management (view, revoke individual or all other sessions)
- All routes protected except /login and /setup
- Logout clears session
- Account deletion with password confirmation
### OAuth / SSO
- Optional social login via Google, GitHub, or generic OIDC
- OAuth users created automatically on first sign-in
- Invite-only mode when combined with admin controls
- Configure via environment variables (`AUTH_GOOGLE_ID`, `AUTH_GITHUB_ID`, `AUTH_OIDC_ISSUER`, etc.)
### User Management (admin)
- Admin dashboard to create, list, and delete users
- Assign roles (admin / user)
- Reset passwords for any user
- Revoke all sessions (force logout) for any user
### PWA / Offline-First
- Installable as standalone app
@@ -102,6 +116,21 @@
- LWW conflict resolution for multi-device use
- Sync status indicator (synced/syncing/offline/error)
### MCP Server (Model Context Protocol)
- Built-in MCP endpoint at `POST /api/mcp` (Streamable HTTP transport)
- 14 tools: list, get, create, update, trash, restore, archive, unarchive, delete, search notes; list tags; pin, reorder notes; upload images
- API key authentication (Bearer token)
- Compatible with Claude Code, Claude Desktop, and other MCP clients
- Stateful sessions with automatic cleanup
### API Key Management
- Generate API keys from the Settings page
- Keys use `crumbs_` prefix for easy identification
- SHA-256 hashed storage (keys shown once at creation)
- Track last-used timestamps
- Revoke keys with confirmation dialog
- MCP client config example shown in Settings
### Docker Deployment
- Multi-stage Dockerfile (build + slim runtime)
- docker-compose.yml with persistent volume
+108
View File
@@ -325,12 +325,101 @@ paths:
'400':
description: Invalid changes payload
/api/settings/api-keys:
get:
summary: List API keys
tags: [Settings]
responses:
'200':
description: List of API keys (without hashes)
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/ApiKey'
post:
summary: Create API key
tags: [Settings]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name:
type: string
responses:
'201':
description: API key created (key shown only once)
content:
application/json:
schema:
type: object
properties:
id:
type: string
key:
type: string
name:
type: string
keyPrefix:
type: string
/api/settings/api-keys/{id}:
delete:
summary: Revoke API key
tags: [Settings]
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Key revoked
content:
application/json:
schema:
$ref: '#/components/schemas/Success'
'404':
description: Key not found
/api/mcp:
post:
summary: MCP Streamable HTTP endpoint
description: Model Context Protocol endpoint. Accepts JSON-RPC 2.0 messages. Authenticate via Bearer token (API key).
tags: [MCP]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
description: JSON-RPC 2.0 message
responses:
'200':
description: JSON-RPC response (may be SSE stream)
'400':
description: Invalid session
'401':
description: Invalid or missing API key
components:
securitySchemes:
sessionCookie:
type: apiKey
in: cookie
name: session
bearerAuth:
type: http
scheme: bearer
description: API key (crumbs_...)
schemas:
Success:
@@ -452,3 +541,22 @@ components:
description: Unix timestamp in milliseconds
data:
$ref: '#/components/schemas/NoteInput'
ApiKey:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
keyPrefix:
type: string
description: First 12 characters of the key for display
createdAt:
type: string
format: date-time
lastUsedAt:
type: string
format: date-time
nullable: true
+37
View File
@@ -0,0 +1,37 @@
CREATE TABLE `login_attempts` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`ip` text NOT NULL,
`email` text NOT NULL,
`success` integer NOT NULL,
`timestamp` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX `login_attempts_ip_timestamp_idx` ON `login_attempts` (`ip`,`timestamp`);--> statement-breakpoint
DROP INDEX `tags_name_unique`;--> statement-breakpoint
ALTER TABLE `tags` ADD `user_id` integer NOT NULL REFERENCES users(id);--> statement-breakpoint
CREATE UNIQUE INDEX `tags_name_user_unique` ON `tags` (`name`,`user_id`);--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`email` text DEFAULT '' NOT NULL,
`display_name` text DEFAULT '' NOT NULL,
`role` text DEFAULT 'user' NOT NULL,
`password_hash` text,
`auth_provider` text DEFAULT 'password' NOT NULL,
`provider_id` text,
`created_at` integer NOT NULL
);
--> statement-breakpoint
INSERT INTO `__new_users`("id", "email", "display_name", "role", "password_hash", "auth_provider", "provider_id", "created_at") SELECT "id", "email", "display_name", "role", "password_hash", "auth_provider", "provider_id", "created_at" FROM `users`;--> statement-breakpoint
DROP TABLE `users`;--> statement-breakpoint
ALTER TABLE `__new_users` RENAME TO `users`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
ALTER TABLE `attachments` ADD `user_id` integer NOT NULL REFERENCES users(id);--> statement-breakpoint
ALTER TABLE `attachments` ADD `featured` integer DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE `notes` ADD `user_id` integer NOT NULL REFERENCES users(id);--> statement-breakpoint
CREATE INDEX `notes_user_id_idx` ON `notes` (`user_id`);--> statement-breakpoint
ALTER TABLE `sessions` ADD `created_at` integer;--> statement-breakpoint
ALTER TABLE `sessions` ADD `user_agent` text;--> statement-breakpoint
ALTER TABLE `sessions` ADD `ip` text;--> statement-breakpoint
ALTER TABLE `sessions` ADD `last_used_at` integer;--> statement-breakpoint
ALTER TABLE `sync_log` ADD `user_id` integer NOT NULL REFERENCES users(id);
+684
View File
@@ -0,0 +1,684 @@
{
"version": "6",
"dialect": "sqlite",
"id": "9eb400be-28ff-4b92-be35-1cd0b344f2e3",
"prevId": "682b02aa-a28d-4904-8996-cbc4d740f626",
"tables": {
"attachments": {
"name": "attachments",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mime_type": {
"name": "mime_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"thumbnail_path": {
"name": "thumbnail_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"featured": {
"name": "featured",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"attachments_user_id_users_id_fk": {
"name": "attachments_user_id_users_id_fk",
"tableFrom": "attachments",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"attachments_note_id_notes_id_fk": {
"name": "attachments_note_id_notes_id_fk",
"tableFrom": "attachments",
"tableTo": "notes",
"columnsFrom": [
"note_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"login_attempts": {
"name": "login_attempts",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"ip": {
"name": "ip",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"success": {
"name": "success",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"login_attempts_ip_timestamp_idx": {
"name": "login_attempts_ip_timestamp_idx",
"columns": [
"ip",
"timestamp"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"note_tags": {
"name": "note_tags",
"columns": {
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tag_id": {
"name": "tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"note_tags_note_id_idx": {
"name": "note_tags_note_id_idx",
"columns": [
"note_id"
],
"isUnique": false
},
"note_tags_tag_id_idx": {
"name": "note_tags_tag_id_idx",
"columns": [
"tag_id"
],
"isUnique": false
}
},
"foreignKeys": {
"note_tags_note_id_notes_id_fk": {
"name": "note_tags_note_id_notes_id_fk",
"tableFrom": "note_tags",
"tableTo": "notes",
"columnsFrom": [
"note_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"note_tags_tag_id_tags_id_fk": {
"name": "note_tags_tag_id_tags_id_fk",
"tableFrom": "note_tags",
"tableTo": "tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"notes": {
"name": "notes",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'default'"
},
"pinned": {
"name": "pinned",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"archived": {
"name": "archived",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"trashed": {
"name": "trashed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"trashed_at": {
"name": "trashed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"checklist_mode": {
"name": "checklist_mode",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
}
},
"indexes": {
"notes_user_id_idx": {
"name": "notes_user_id_idx",
"columns": [
"user_id"
],
"isUnique": false
},
"notes_trashed_archived_idx": {
"name": "notes_trashed_archived_idx",
"columns": [
"trashed",
"archived"
],
"isUnique": false
},
"notes_updated_at_idx": {
"name": "notes_updated_at_idx",
"columns": [
"updated_at"
],
"isUnique": false
}
},
"foreignKeys": {
"notes_user_id_users_id_fk": {
"name": "notes_user_id_users_id_fk",
"tableFrom": "notes",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ip": {
"name": "ip",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_used_at": {
"name": "last_used_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sync_log": {
"name": "sync_log",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"operation": {
"name": "operation",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"sync_log_timestamp_idx": {
"name": "sync_log_timestamp_idx",
"columns": [
"timestamp"
],
"isUnique": false
}
},
"foreignKeys": {
"sync_log_user_id_users_id_fk": {
"name": "sync_log_user_id_users_id_fk",
"tableFrom": "sync_log",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"sync_log_note_id_notes_id_fk": {
"name": "sync_log_note_id_notes_id_fk",
"tableFrom": "sync_log",
"tableTo": "notes",
"columnsFrom": [
"note_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tags": {
"name": "tags",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"tags_name_user_unique": {
"name": "tags_name_user_unique",
"columns": [
"name",
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"tags_user_id_users_id_fk": {
"name": "tags_user_id_users_id_fk",
"tableFrom": "tags",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"auth_provider": {
"name": "auth_provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'password'"
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+7
View File
@@ -8,6 +8,13 @@
"when": 1773000424830,
"tag": "0000_wooden_vargas",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1773016788899,
"tag": "0001_strange_lord_hawal",
"breakpoints": true
}
]
}
+3 -1
View File
@@ -1,7 +1,7 @@
{
"name": "crumbs",
"private": true,
"version": "0.8.1",
"version": "0.9.0",
"type": "module",
"license": "MIT",
"author": "Bretzel (https://bretzel.app)",
@@ -29,6 +29,7 @@
"docs:api": "tsx scripts/generate-api-docs.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
"@sveltejs/adapter-node": "^5.5.4",
"@tiptap/core": "^3.20.1",
"@tiptap/extension-link": "^3.20.1",
@@ -49,6 +50,7 @@
"svelte-dnd-action": "^0.9.69",
"tiptap-markdown": "^0.9.0",
"uuid": "^11.1.0",
"zod": "^4.3.6",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
+492
View File
@@ -13,6 +13,9 @@ importers:
.:
dependencies:
'@modelcontextprotocol/sdk':
specifier: ^1.27.1
version: 1.27.1(zod@4.3.6)
'@sveltejs/adapter-node':
specifier: ^5.5.4
version: 5.5.4(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))
@@ -73,6 +76,9 @@ importers:
uuid:
specifier: ^11.1.0
version: 11.1.0
zod:
specifier: ^4.3.6
version: 4.3.6
zxcvbn:
specifier: ^4.4.2
version: 4.4.2
@@ -961,6 +967,12 @@ packages:
cpu: [x64]
os: [win32]
'@hono/node-server@1.19.11':
resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: ^4
'@isaacs/cliui@9.0.0':
resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==}
engines: {node: '>=18'}
@@ -984,6 +996,16 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@modelcontextprotocol/sdk@1.27.1':
resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==}
engines: {node: '>=18'}
peerDependencies:
'@cfworker/json-schema': ^4.1.1
zod: ^3.25 || ^4.0
peerDependenciesMeta:
'@cfworker/json-schema':
optional: true
'@oslojs/asn1@1.0.0':
resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==}
@@ -1613,11 +1635,23 @@ packages:
'@vitest/utils@3.2.4':
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'}
hasBin: true
ajv-formats@3.0.1:
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
peerDependencies:
ajv: ^8.0.0
peerDependenciesMeta:
ajv:
optional: true
ajv@8.18.0:
resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
@@ -1705,6 +1739,10 @@ packages:
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
body-parser@2.2.2:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'}
brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
@@ -1723,6 +1761,10 @@ packages:
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
@@ -1771,9 +1813,21 @@ packages:
commondir@1.0.1:
resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==}
content-disposition@1.0.1:
resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==}
engines: {node: '>=18'}
content-type@1.0.5:
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
engines: {node: '>= 0.6'}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cookie-signature@1.2.2:
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
engines: {node: '>=6.6.0'}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
@@ -1781,6 +1835,10 @@ packages:
core-js-compat@3.48.0:
resolution: {integrity: sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==}
cors@2.8.6:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'}
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
@@ -1842,6 +1900,10 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -1949,6 +2011,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
ejs@3.1.10:
resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==}
engines: {node: '>=0.10.0'}
@@ -1957,6 +2022,10 @@ packages:
electron-to-chromium@1.5.307:
resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==}
encodeurl@2.0.0:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
end-of-stream@1.4.5:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
@@ -2014,6 +2083,9 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
@@ -2037,6 +2109,18 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
etag@1.8.1:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
eventsource-parser@3.0.6:
resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==}
engines: {node: '>=18.0.0'}
eventsource@3.0.7:
resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
engines: {node: '>=18.0.0'}
expand-template@2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
@@ -2045,6 +2129,16 @@ packages:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
express-rate-limit@8.3.0:
resolution: {integrity: sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==}
engines: {node: '>= 16'}
peerDependencies:
express: '>= 4.11'
express@5.2.1:
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
engines: {node: '>= 18'}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -2069,6 +2163,10 @@ packages:
filelist@1.0.6:
resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==}
finalhandler@2.1.1:
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
engines: {node: '>= 18.0.0'}
for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
@@ -2077,6 +2175,14 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
@@ -2173,6 +2279,18 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
hono@4.12.5:
resolution: {integrity: sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==}
engines: {node: '>=16.9.0'}
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
iconv-lite@0.7.2:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
idb@7.1.1:
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
@@ -2192,6 +2310,14 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
ip-address@10.1.0:
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
engines: {node: '>= 12'}
ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'}
@@ -2251,6 +2377,9 @@ packages:
resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==}
engines: {node: '>=0.10.0'}
is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
is-reference@1.2.1:
resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==}
@@ -2320,6 +2449,9 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
jose@6.2.0:
resolution: {integrity: sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -2334,6 +2466,9 @@ packages:
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
json-schema-typed@8.0.2:
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
json-schema@0.4.0:
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
@@ -2487,6 +2622,22 @@ packages:
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
media-typer@1.1.0:
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
engines: {node: '>= 0.8'}
merge-descriptors@2.0.0:
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
engines: {node: '>=18'}
mime-db@1.54.0:
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
engines: {node: '>= 0.6'}
mime-types@3.0.2:
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
engines: {node: '>=18'}
mimic-response@3.1.0:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
@@ -2528,6 +2679,10 @@ packages:
napi-build-utils@2.0.0:
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
negotiator@1.0.0:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
node-abi@3.87.0:
resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==}
engines: {node: '>=10'}
@@ -2543,6 +2698,10 @@ packages:
node-releases@2.0.36:
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
@@ -2558,6 +2717,10 @@ packages:
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@@ -2571,6 +2734,10 @@ packages:
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
@@ -2582,6 +2749,9 @@ packages:
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
engines: {node: 18 || 20 || >=22}
path-to-regexp@8.3.0:
resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -2600,6 +2770,10 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
pkce-challenge@5.0.1:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'}
playwright-core@1.58.2:
resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
engines: {node: '>=18'}
@@ -2694,6 +2868,10 @@ packages:
prosemirror-view@1.41.6:
resolution: {integrity: sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==}
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
pump@3.0.4:
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
@@ -2705,6 +2883,18 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qs@6.15.0:
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
engines: {node: '>=0.6'}
range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
raw-body@3.0.2:
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
engines: {node: '>= 0.10'}
rc@1.2.8:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
@@ -2768,6 +2958,10 @@ packages:
rope-sequence@1.3.4:
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
router@2.2.0:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
sade@1.8.1:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
engines: {node: '>=6'}
@@ -2787,6 +2981,9 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
@@ -2796,10 +2993,18 @@ packages:
engines: {node: '>=10'}
hasBin: true
send@1.2.1:
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
engines: {node: '>= 18'}
serialize-javascript@7.0.4:
resolution: {integrity: sha512-DuGdB+Po43Q5Jxwpzt1lhyFSYKryqoNjQSA9M92tyw0lyHIOur+XCalOUe0KTJpyqzT8+fQ5A0Jf7vCx/NKmIg==}
engines: {node: '>=20.0.0'}
serve-static@2.2.1:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
set-cookie-parser@3.0.1:
resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==}
@@ -2815,6 +3020,9 @@ packages:
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
engines: {node: '>= 0.4'}
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -2883,6 +3091,10 @@ packages:
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
@@ -2999,6 +3211,10 @@ packages:
peerDependencies:
'@tiptap/core': ^3.0.1
toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
totalist@3.0.1:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
@@ -3018,6 +3234,10 @@ packages:
resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==}
engines: {node: '>=10'}
type-is@2.0.1:
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
engines: {node: '>= 0.6'}
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
@@ -3073,6 +3293,10 @@ packages:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
unpipe@1.0.0:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
upath@1.2.0:
resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==}
engines: {node: '>=4'}
@@ -3090,6 +3314,10 @@ packages:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
vite-node@3.2.4:
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -3281,6 +3509,14 @@ packages:
zimmerframe@1.1.4:
resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
zod-to-json-schema@3.25.1:
resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==}
peerDependencies:
zod: ^3.25 || ^4
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
zxcvbn@4.4.2:
resolution: {integrity: sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==}
@@ -4115,6 +4351,10 @@ snapshots:
'@esbuild/win32-x64@0.27.3':
optional: true
'@hono/node-server@1.19.11(hono@4.12.5)':
dependencies:
hono: 4.12.5
'@isaacs/cliui@9.0.0': {}
'@jridgewell/gen-mapping@0.3.13':
@@ -4141,6 +4381,28 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)':
dependencies:
'@hono/node-server': 1.19.11(hono@4.12.5)
ajv: 8.18.0
ajv-formats: 3.0.1(ajv@8.18.0)
content-type: 1.0.5
cors: 2.8.6
cross-spawn: 7.0.6
eventsource: 3.0.7
eventsource-parser: 3.0.6
express: 5.2.1
express-rate-limit: 8.3.0(express@5.2.1)
hono: 4.12.5
jose: 6.2.0
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
raw-body: 3.0.2
zod: 4.3.6
zod-to-json-schema: 3.25.1(zod@4.3.6)
transitivePeerDependencies:
- supports-color
'@oslojs/asn1@1.0.0':
dependencies:
'@oslojs/binary': 1.0.0
@@ -4723,8 +4985,17 @@ snapshots:
loupe: 3.2.1
tinyrainbow: 2.0.0
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
negotiator: 1.0.0
acorn@8.16.0: {}
ajv-formats@3.0.1(ajv@8.18.0):
optionalDependencies:
ajv: 8.18.0
ajv@8.18.0:
dependencies:
fast-deep-equal: 3.1.3
@@ -4824,6 +5095,20 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
body-parser@2.2.2:
dependencies:
bytes: 3.1.2
content-type: 1.0.5
debug: 4.4.3
http-errors: 2.0.1
iconv-lite: 0.7.2
on-finished: 2.4.1
qs: 6.15.0
raw-body: 3.0.2
type-is: 2.0.1
transitivePeerDependencies:
- supports-color
brace-expansion@2.0.2:
dependencies:
balanced-match: 1.0.2
@@ -4847,6 +5132,8 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
bytes@3.1.2: {}
cac@6.7.14: {}
call-bind-apply-helpers@1.0.2:
@@ -4892,14 +5179,25 @@ snapshots:
commondir@1.0.1: {}
content-disposition@1.0.1: {}
content-type@1.0.5: {}
convert-source-map@2.0.0: {}
cookie-signature@1.2.2: {}
cookie@1.1.1: {}
core-js-compat@3.48.0:
dependencies:
browserslist: 4.28.1
cors@2.8.6:
dependencies:
object-assign: 4.1.1
vary: 1.1.2
crelt@1.0.6: {}
cross-spawn@7.0.6:
@@ -4956,6 +5254,8 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
depd@2.0.0: {}
detect-libc@2.1.2: {}
devalue@5.6.3: {}
@@ -4980,12 +5280,16 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
ee-first@1.1.1: {}
ejs@3.1.10:
dependencies:
jake: 10.9.4
electron-to-chromium@1.5.307: {}
encodeurl@2.0.0: {}
end-of-stream@1.4.5:
dependencies:
once: 1.4.0
@@ -5144,6 +5448,8 @@ snapshots:
escalade@3.2.0: {}
escape-html@1.0.3: {}
escape-string-regexp@4.0.0: {}
esm-env@1.2.2: {}
@@ -5162,10 +5468,56 @@ snapshots:
esutils@2.0.3: {}
etag@1.8.1: {}
eventsource-parser@3.0.6: {}
eventsource@3.0.7:
dependencies:
eventsource-parser: 3.0.6
expand-template@2.0.3: {}
expect-type@1.3.0: {}
express-rate-limit@8.3.0(express@5.2.1):
dependencies:
express: 5.2.1
ip-address: 10.1.0
express@5.2.1:
dependencies:
accepts: 2.0.0
body-parser: 2.2.2
content-disposition: 1.0.1
content-type: 1.0.5
cookie: 1.1.1
cookie-signature: 1.2.2
debug: 4.4.3
depd: 2.0.0
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
finalhandler: 2.1.1
fresh: 2.0.0
http-errors: 2.0.1
merge-descriptors: 2.0.0
mime-types: 3.0.2
on-finished: 2.4.1
once: 1.4.0
parseurl: 1.3.3
proxy-addr: 2.0.7
qs: 6.15.0
range-parser: 1.2.1
router: 2.2.0
send: 1.2.1
serve-static: 2.2.1
statuses: 2.0.2
type-is: 2.0.1
vary: 1.1.2
transitivePeerDependencies:
- supports-color
fast-deep-equal@3.1.3: {}
fast-json-stable-stringify@2.1.0: {}
@@ -5182,6 +5534,17 @@ snapshots:
dependencies:
minimatch: 5.1.9
finalhandler@2.1.1:
dependencies:
debug: 4.4.3
encodeurl: 2.0.0
escape-html: 1.0.3
on-finished: 2.4.1
parseurl: 1.3.3
statuses: 2.0.2
transitivePeerDependencies:
- supports-color
for-each@0.3.5:
dependencies:
is-callable: 1.2.7
@@ -5191,6 +5554,10 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
forwarded@0.2.0: {}
fresh@2.0.0: {}
fs-constants@1.0.0: {}
fs-extra@9.1.0:
@@ -5293,6 +5660,20 @@ snapshots:
dependencies:
function-bind: 1.1.2
hono@4.12.5: {}
http-errors@2.0.1:
dependencies:
depd: 2.0.0
inherits: 2.0.4
setprototypeof: 1.2.0
statuses: 2.0.2
toidentifier: 1.0.1
iconv-lite@0.7.2:
dependencies:
safer-buffer: 2.1.2
idb@7.1.1: {}
idb@8.0.3: {}
@@ -5309,6 +5690,10 @@ snapshots:
hasown: 2.0.2
side-channel: 1.1.0
ip-address@10.1.0: {}
ipaddr.js@1.9.1: {}
is-array-buffer@3.0.5:
dependencies:
call-bind: 1.0.8
@@ -5374,6 +5759,8 @@ snapshots:
is-obj@1.0.1: {}
is-promise@4.0.0: {}
is-reference@1.2.1:
dependencies:
'@types/estree': 1.0.8
@@ -5441,6 +5828,8 @@ snapshots:
jiti@2.6.1: {}
jose@6.2.0: {}
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
@@ -5449,6 +5838,8 @@ snapshots:
json-schema-traverse@1.0.0: {}
json-schema-typed@8.0.2: {}
json-schema@0.4.0: {}
json5@2.2.3: {}
@@ -5565,6 +5956,16 @@ snapshots:
mdurl@2.0.0: {}
media-typer@1.1.0: {}
merge-descriptors@2.0.0: {}
mime-db@1.54.0: {}
mime-types@3.0.2:
dependencies:
mime-db: 1.54.0
mimic-response@3.1.0: {}
minimatch@10.2.4:
@@ -5591,6 +5992,8 @@ snapshots:
napi-build-utils@2.0.0: {}
negotiator@1.0.0: {}
node-abi@3.87.0:
dependencies:
semver: 7.7.4
@@ -5601,6 +6004,8 @@ snapshots:
node-releases@2.0.36: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
object-keys@1.1.1: {}
@@ -5616,6 +6021,10 @@ snapshots:
obug@2.1.1: {}
on-finished@2.4.1:
dependencies:
ee-first: 1.1.1
once@1.4.0:
dependencies:
wrappy: 1.0.2
@@ -5630,6 +6039,8 @@ snapshots:
package-json-from-dist@1.0.1: {}
parseurl@1.3.3: {}
path-key@3.1.1: {}
path-parse@1.0.7: {}
@@ -5639,6 +6050,8 @@ snapshots:
lru-cache: 11.2.6
minipass: 7.1.3
path-to-regexp@8.3.0: {}
pathe@2.0.3: {}
pathval@2.0.1: {}
@@ -5649,6 +6062,8 @@ snapshots:
picomatch@4.0.3: {}
pkce-challenge@5.0.1: {}
playwright-core@1.58.2: {}
playwright@1.58.2:
@@ -5792,6 +6207,11 @@ snapshots:
prosemirror-state: 1.4.4
prosemirror-transform: 1.11.0
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
ipaddr.js: 1.9.1
pump@3.0.4:
dependencies:
end-of-stream: 1.4.5
@@ -5801,6 +6221,19 @@ snapshots:
punycode@2.3.1: {}
qs@6.15.0:
dependencies:
side-channel: 1.1.0
range-parser@1.2.1: {}
raw-body@3.0.2:
dependencies:
bytes: 3.1.2
http-errors: 2.0.1
iconv-lite: 0.7.2
unpipe: 1.0.0
rc@1.2.8:
dependencies:
deep-extend: 0.6.0
@@ -5904,6 +6337,16 @@ snapshots:
rope-sequence@1.3.4: {}
router@2.2.0:
dependencies:
debug: 4.4.3
depd: 2.0.0
is-promise: 4.0.0
parseurl: 1.3.3
path-to-regexp: 8.3.0
transitivePeerDependencies:
- supports-color
sade@1.8.1:
dependencies:
mri: 1.2.0
@@ -5929,12 +6372,39 @@ snapshots:
es-errors: 1.3.0
is-regex: 1.2.1
safer-buffer@2.1.2: {}
semver@6.3.1: {}
semver@7.7.4: {}
send@1.2.1:
dependencies:
debug: 4.4.3
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
fresh: 2.0.0
http-errors: 2.0.1
mime-types: 3.0.2
ms: 2.1.3
on-finished: 2.4.1
range-parser: 1.2.1
statuses: 2.0.2
transitivePeerDependencies:
- supports-color
serialize-javascript@7.0.4: {}
serve-static@2.2.1:
dependencies:
encodeurl: 2.0.0
escape-html: 1.0.3
parseurl: 1.3.3
send: 1.2.1
transitivePeerDependencies:
- supports-color
set-cookie-parser@3.0.1: {}
set-function-length@1.2.2:
@@ -5959,6 +6429,8 @@ snapshots:
es-errors: 1.3.0
es-object-atoms: 1.1.1
setprototypeof@1.2.0: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -6030,6 +6502,8 @@ snapshots:
stackback@0.0.2: {}
statuses@2.0.2: {}
std-env@3.10.0: {}
stop-iteration-iterator@1.1.0:
@@ -6189,6 +6663,8 @@ snapshots:
markdown-it-task-lists: 2.1.1
prosemirror-markdown: 1.13.4
toidentifier@1.0.1: {}
totalist@3.0.1: {}
tr46@1.0.1:
@@ -6208,6 +6684,12 @@ snapshots:
type-fest@0.16.0: {}
type-is@2.0.1:
dependencies:
content-type: 1.0.5
media-typer: 1.1.0
mime-types: 3.0.2
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4
@@ -6271,6 +6753,8 @@ snapshots:
universalify@2.0.1: {}
unpipe@1.0.0: {}
upath@1.2.0: {}
update-browserslist-db@1.2.3(browserslist@4.28.1):
@@ -6283,6 +6767,8 @@ snapshots:
uuid@11.1.0: {}
vary@1.1.2: {}
vite-node@3.2.4(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
cac: 6.7.14
@@ -6558,4 +7044,10 @@ snapshots:
zimmerframe@1.1.4: {}
zod-to-json-schema@3.25.1(zod@4.3.6):
dependencies:
zod: 4.3.6
zod@4.3.6: {}
zxcvbn@4.4.2: {}
+33
View File
@@ -1,6 +1,7 @@
import type { Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import { validateSession, isSetupComplete } from '$lib/server/auth.js';
import { validateApiKey, getUserForApiKey } from '$lib/server/api-keys.js';
const PUBLIC_PATHS = [
'/login',
@@ -21,6 +22,38 @@ export const handle: Handle = async ({ event, resolve }) => {
return resolve(event);
}
// MCP endpoint: authenticate via Bearer token (API key)
if (pathname.startsWith('/api/mcp')) {
const authHeader = event.request.headers.get('authorization');
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
if (!token) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
const { valid, userId } = validateApiKey(token);
if (!valid || !userId) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
const user = getUserForApiKey(userId);
if (!user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
event.locals.user = user;
return resolve(event);
}
// Check if setup is complete
const setupDone = await isSetupComplete();
if (!setupDone) {
+1 -52
View File
@@ -6,28 +6,12 @@
interface Props {
onMenuToggle: () => void;
user: { id: number; email: string; displayName: string; role: 'admin' | 'user' } | null;
}
let { onMenuToggle, user }: Props = $props();
let { onMenuToggle }: Props = $props();
let mobileSearchOpen = $state(false);
let dropdownOpen = $state(false);
function getInitial(user: Props['user']): string {
if (!user) return '?';
if (user.displayName) return user.displayName.charAt(0).toUpperCase();
if (user.email) return user.email.charAt(0).toUpperCase();
return '?';
}
async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST' });
window.location.href = '/login';
}
</script>
<svelte:window onclick={() => (dropdownOpen = false)} />
<header class="sticky top-0 z-30 flex h-16 items-center gap-4 border-b border-[var(--border)] bg-[var(--bg-surface)] px-4">
{#if mobileSearchOpen}
<!-- Mobile expanded search -->
@@ -64,41 +48,6 @@
<div class="flex items-center gap-2">
<SyncIndicator />
{#if user}
<div class="relative">
<button
onclick={(e) => { e.stopPropagation(); dropdownOpen = !dropdownOpen; }}
class="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--primary)] text-sm font-medium text-white hover:bg-[var(--primary-hover)]"
aria-label="User menu"
data-testid="user-menu-btn"
>
{getInitial(user)}
</button>
{#if dropdownOpen}
<div class="absolute right-0 top-10 z-50 w-48 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] py-1 shadow-[var(--card-shadow)]">
<div class="border-b border-[var(--border-subtle)] px-4 py-2">
<p class="text-sm font-medium text-[var(--text)]">{user.displayName || user.email}</p>
<p class="text-xs text-[var(--text-muted)]">{user.email}</p>
</div>
<a
href="/settings"
class="block px-4 py-2 text-sm text-[var(--text)] hover:bg-[var(--bg-base)]"
>
Settings
</a>
<button
onclick={handleLogout}
class="block w-full px-4 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--bg-base)]"
data-testid="logout-btn"
>
Log out
</button>
</div>
{/if}
</div>
{/if}
</div>
{/if}
</header>
+37 -63
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { currentFilter, loadNotes, allTags, selectedTag } from '$lib/stores/notes.js';
import { StickyNote, Archive, Trash2, Tag } from 'lucide-svelte';
import type { NoteFilter } from '$lib/types/index.js';
import { page } from '$app/stores';
import { allTags } from '$lib/stores/notes.js';
import { StickyNote, Archive, Trash2, Tag, Settings } from 'lucide-svelte';
interface Props {
open: boolean;
@@ -17,17 +17,11 @@
}
}
function setFilter(filter: NoteFilter) {
selectedTag.set(null);
loadNotes(filter);
closeMobile();
}
function selectTag(tag: string | null) {
selectedTag.set(tag);
loadNotes('all');
closeMobile();
}
const navItems = [
{ href: '/', label: 'Crumbs', icon: StickyNote, match: (p: string) => p === '/' },
{ href: '/archive', label: 'Archive', icon: Archive, match: (p: string) => p === '/archive' },
{ href: '/trash', label: 'Trash', icon: Trash2, match: (p: string) => p === '/trash' }
];
</script>
{#if open}
@@ -44,51 +38,19 @@
>
<nav class="p-2">
<ul class="space-y-1">
<li>
<button
onclick={() => setFilter('all')}
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'all' && !$selectedTag ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
>
<StickyNote size={20} />
Crumbs
</button>
</li>
<li>
<button
onclick={() => setFilter('archived')}
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'archived' ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
>
<Archive size={20} />
Archive
</button>
</li>
<li>
<button
onclick={() => setFilter('trashed')}
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'trashed' ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
>
<Trash2 size={20} />
Trash
</button>
</li>
</ul>
<div class="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
<ul class="space-y-1">
{#each navItems as item}
<li>
<a
href="/settings"
class="flex w-full items-center gap-3 rounded-full px-6 py-3 text-left text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
href={item.href}
onclick={closeMobile}
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {item.match($page.url.pathname) ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
<item.icon size={20} />
{item.label}
</a>
</li>
</ul>
</div>
{/each}
</ul>
{#if $allTags.length > 0}
<div class="mt-6 border-t border-[var(--border-subtle)] pt-4">
@@ -96,22 +58,34 @@
<ul class="mt-2 space-y-1">
{#each $allTags as tag}
<li>
<button
onclick={() => selectTag($selectedTag === tag ? null : tag)}
class="flex w-full items-center gap-3 rounded-sm px-6 py-2 text-left text-sm transition-colors {$selectedTag === tag ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
<a
href="/tag/{tag}"
onclick={closeMobile}
class="flex w-full items-center gap-3 rounded-sm px-6 py-2 text-left text-sm transition-colors {$page.url.pathname === `/tag/${tag}` ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
>
<Tag size={16} />
#{tag}
</button>
</a>
</li>
{/each}
</ul>
</div>
{/if}
</nav>
{#if appVersion}
<div class="absolute bottom-0 left-0 w-full border-t border-[var(--border-subtle)] px-6 py-3 text-right text-xs text-[var(--text-muted)]">
v{appVersion}
</div>
{/if}
<div class="absolute bottom-0 left-0 w-full border-t border-[var(--border-subtle)]">
<a
href="/settings"
class="flex w-full items-center gap-3 px-6 py-3 text-sm text-[var(--text-muted)] transition-colors hover:text-[var(--primary)]"
onclick={closeMobile}
data-testid="settings-link"
>
<Settings size={16} />
Settings
</a>
{#if appVersion}
<div class="border-t border-[var(--border-subtle)] px-6 py-2 text-right text-xs text-[var(--text-muted)]">
v{appVersion}
</div>
{/if}
</div>
</aside>
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import NoteGrid from '$lib/components/NoteGrid.svelte';
import NoteEditor from '$lib/components/NoteEditor.svelte';
import TagFilter from '$lib/components/TagFilter.svelte';
import SortSelector from '$lib/components/SortSelector.svelte';
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded, loadNotes, updateSortOrders } from '$lib/stores/notes.js';
import { sortMode } from '$lib/stores/sort.js';
import { onMount } from 'svelte';
import type { Note, NoteFilter } from '$lib/types/index.js';
import Plus from 'lucide-svelte/icons/plus';
interface Props {
filter: NoteFilter;
tag?: string | null;
}
let { filter, tag = null }: Props = $props();
let editingNote: Note | null = $state(null);
let showNewNote = $state(false);
// Sync store with route props
$effect(() => {
currentFilter.set(filter);
selectedTag.set(tag);
});
function openEditor(note: Note) {
editingNote = note;
history.replaceState(null, '', `#${note.id}`);
}
function closeEditor() {
editingNote = null;
showNewNote = false;
history.replaceState(null, '', location.pathname);
}
function handleReorder(noteIds: string[]) {
const orders = noteIds.map((id, index) => ({ id, sortOrder: index }));
updateSortOrders(orders);
}
onMount(() => {
const hash = location.hash.slice(1);
if (!hash) return;
const unsubscribe = notes.subscribe(($notes) => {
if ($notes.length === 0) return;
const note = $notes.find((n) => n.id === hash);
if (note) editingNote = note;
unsubscribe();
});
});
</script>
{#if filter === 'all' && !tag}
<div class="mx-auto mb-6 max-w-xl">
<button
onclick={() => (showNewNote = true)}
class="flex w-full items-center gap-3 rounded-sm border border-dashed border-[var(--border-subtle)] bg-[var(--bg-surface)] px-4 py-3 text-left text-sm text-[var(--text-muted)] transition-colors hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-testid="new-note-btn"
>
<Plus class="h-4 w-4" />
Add a crumb...
</button>
</div>
{/if}
{#if filter === 'all'}
<div class="mb-4 flex flex-wrap items-start justify-between gap-3">
<TagFilter />
<SortSelector />
</div>
{:else}
<div class="mb-4">
<TagFilter />
</div>
{/if}
{#if filter === 'archived'}
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Archive</h2>
{:else if filter === 'trashed'}
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Trash</h2>
{:else if tag}
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">#{tag}</h2>
{/if}
{#if $pinnedNotes.length > 0}
<div class="mb-6">
<NoteGrid notes={$pinnedNotes} label="Pinned" onEdit={openEditor} draggable={$sortMode === 'custom'} dndType="pinned-notes" onReorder={handleReorder} />
</div>
{/if}
<NoteGrid
notes={$unpinnedNotes}
label={$pinnedNotes.length > 0 ? 'Others' : ''}
onEdit={openEditor}
draggable={$sortMode === 'custom'}
dndType="unpinned-notes"
onReorder={handleReorder}
/>
{#if $notesLoaded && $pinnedNotes.length === 0 && $unpinnedNotes.length === 0}
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-muted)]">
<img src="/favicon.svg" alt="" class="mb-4 h-18 w-18" />
<p class="font-['Press_Start_2P'] text-xs">
{#if filter === 'trashed'}
No crumbs in trash
{:else if filter === 'archived'}
No archived crumbs
{:else if tag}
No crumbs with tag #{tag}
{:else}
No crumbs yet
{/if}
</p>
</div>
{/if}
{#if showNewNote}
<NoteEditor note={null} isNew={true} onClose={closeEditor} />
{/if}
{#if editingNote}
<NoteEditor note={editingNote} onClose={closeEditor} />
{/if}
+10 -3
View File
@@ -1,16 +1,23 @@
<script lang="ts">
import { allTags, selectedTag } from '$lib/stores/notes.js';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { allTags } from '$lib/stores/notes.js';
import TagChip from './TagChip.svelte';
function toggleTag(tag: string) {
selectedTag.update((current) => (current === tag ? null : tag));
const isActive = $page.url.pathname === `/tag/${tag}`;
goto(isActive ? '/' : `/tag/${tag}`);
}
function isTagActive(tag: string, pathname: string): boolean {
return pathname === `/tag/${tag}`;
}
</script>
{#if $allTags.length > 0}
<div class="flex flex-wrap gap-2" data-testid="tag-filter">
{#each $allTags as tag}
<TagChip {tag} active={$selectedTag === tag} onclick={() => toggleTag(tag)} />
<TagChip {tag} active={isTagActive(tag, $page.url.pathname)} onclick={() => toggleTag(tag)} />
{/each}
</div>
{/if}
+125
View File
@@ -0,0 +1,125 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { generateApiKey, validateApiKey, listApiKeys, deleteApiKey } from './api-keys.js';
import { users } from './db/schema.js';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import type * as schema from './db/schema.js';
describe('API Keys', () => {
let db: BetterSQLite3Database<typeof schema>;
let userId: number;
beforeEach(() => {
({ db } = createTestDb());
// Create a test user
db.insert(users)
.values({ email: 'test@example.com', displayName: 'Test', createdAt: new Date() })
.run();
userId = 1;
});
describe('generateApiKey', () => {
it('returns key with crumbs_ prefix', () => {
const result = generateApiKey('Test Key', userId, db);
expect(result.key).toMatch(/^crumbs_[a-f0-9]{64}$/);
expect(result.name).toBe('Test Key');
expect(result.id).toBeTruthy();
expect(result.keyPrefix).toBe(result.key.slice(0, 12));
});
it('generates unique keys each time', () => {
const key1 = generateApiKey('Key 1', userId, db);
const key2 = generateApiKey('Key 2', userId, db);
expect(key1.key).not.toBe(key2.key);
expect(key1.id).not.toBe(key2.id);
});
});
describe('validateApiKey', () => {
it('returns valid with userId for a valid key', () => {
const { key } = generateApiKey('Test Key', userId, db);
const result = validateApiKey(key, db);
expect(result.valid).toBe(true);
expect(result.userId).toBe(userId);
});
it('returns invalid for an invalid key', () => {
const result = validateApiKey('crumbs_invalid', db);
expect(result.valid).toBe(false);
expect(result.userId).toBeNull();
});
it('returns invalid for empty string', () => {
expect(validateApiKey('', db).valid).toBe(false);
});
it('returns invalid for key without prefix', () => {
expect(validateApiKey('no_prefix_here', db).valid).toBe(false);
});
it('updates lastUsedAt on validation', () => {
const { key, id } = generateApiKey('Test Key', userId, db);
const before = listApiKeys(userId, db).find((k) => k.id === id);
expect(before?.lastUsedAt).toBeNull();
validateApiKey(key, db);
const after = listApiKeys(userId, db).find((k) => k.id === id);
expect(after?.lastUsedAt).toBeInstanceOf(Date);
});
});
describe('listApiKeys', () => {
it('returns empty array when no keys exist', () => {
expect(listApiKeys(userId, db)).toEqual([]);
});
it('returns keys without hash', () => {
generateApiKey('Key 1', userId, db);
generateApiKey('Key 2', userId, db);
const keys = listApiKeys(userId, db);
expect(keys).toHaveLength(2);
expect(keys[0]).toHaveProperty('name');
expect(keys[0]).toHaveProperty('keyPrefix');
expect(keys[0]).toHaveProperty('createdAt');
expect(keys[0]).not.toHaveProperty('keyHash');
});
it('only returns keys for the specified user', () => {
db.insert(users)
.values({ email: 'other@example.com', displayName: 'Other', createdAt: new Date() })
.run();
const otherUserId = 2;
generateApiKey('My Key', userId, db);
generateApiKey('Other Key', otherUserId, db);
expect(listApiKeys(userId, db)).toHaveLength(1);
expect(listApiKeys(otherUserId, db)).toHaveLength(1);
});
});
describe('deleteApiKey', () => {
it('deletes an existing key and returns true', () => {
const { id, key } = generateApiKey('Test Key', userId, db);
expect(deleteApiKey(id, userId, db)).toBe(true);
expect(validateApiKey(key, db).valid).toBe(false);
expect(listApiKeys(userId, db)).toHaveLength(0);
});
it('returns false for non-existent key', () => {
expect(deleteApiKey('nonexistent', userId, db)).toBe(false);
});
it('cannot delete another user\'s key', () => {
db.insert(users)
.values({ email: 'other@example.com', displayName: 'Other', createdAt: new Date() })
.run();
const otherUserId = 2;
const { id } = generateApiKey('Other Key', otherUserId, db);
expect(deleteApiKey(id, userId, db)).toBe(false);
});
});
});
+105
View File
@@ -0,0 +1,105 @@
import { db } from './db/index.js';
import { apiKeys, users } from './db/schema.js';
import { eq } from 'drizzle-orm';
import { randomBytes, createHash } from 'crypto';
import { v4 as uuidv4 } from 'uuid';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import type * as schema from './db/schema.js';
type Db = BetterSQLite3Database<typeof schema>;
const PREFIX = 'crumbs_';
function hashKey(key: string): string {
return createHash('sha256').update(key).digest('hex');
}
export function generateApiKey(
name: string,
userId: number,
database: Db = db
): { id: string; key: string; name: string; keyPrefix: string } {
const id = uuidv4();
const raw = randomBytes(32).toString('hex');
const key = `${PREFIX}${raw}`;
const keyHash = hashKey(key);
const keyPrefix = key.slice(0, 12);
database
.insert(apiKeys)
.values({
id,
userId,
name,
keyHash,
keyPrefix,
createdAt: new Date()
})
.run();
return { id, key, name, keyPrefix };
}
export function validateApiKey(
key: string,
database: Db = db
): { valid: boolean; userId: number | null } {
if (!key || !key.startsWith(PREFIX)) return { valid: false, userId: null };
const keyHash = hashKey(key);
const row = database.select().from(apiKeys).where(eq(apiKeys.keyHash, keyHash)).get();
if (!row) return { valid: false, userId: null };
database
.update(apiKeys)
.set({ lastUsedAt: new Date() })
.where(eq(apiKeys.id, row.id))
.run();
return { valid: true, userId: row.userId };
}
export function getUserForApiKey(
userId: number,
database: Db = db
): App.Locals['user'] | null {
const user = database
.select({
id: users.id,
email: users.email,
displayName: users.displayName,
role: users.role
})
.from(users)
.where(eq(users.id, userId))
.get();
return user ? (user as App.Locals['user']) : null;
}
export function listApiKeys(userId: number, database: Db = db) {
return database
.select({
id: apiKeys.id,
name: apiKeys.name,
keyPrefix: apiKeys.keyPrefix,
createdAt: apiKeys.createdAt,
lastUsedAt: apiKeys.lastUsedAt
})
.from(apiKeys)
.where(eq(apiKeys.userId, userId))
.all();
}
export function deleteApiKey(id: string, userId: number, database: Db = db): boolean {
const existing = database
.select()
.from(apiKeys)
.where(eq(apiKeys.id, id))
.get();
if (!existing || existing.userId !== userId) return false;
const result = database.delete(apiKeys).where(eq(apiKeys.id, id)).run();
return result.changes > 0;
}
+12 -8
View File
@@ -4,7 +4,7 @@ import { join } from 'path';
import { v4 as uuidv4 } from 'uuid';
import { db } from './db/index.js';
import { attachments } from './db/schema.js';
import { eq, inArray } from 'drizzle-orm';
import { eq, and, inArray } from 'drizzle-orm';
import type { Attachment } from '$lib/types/index.js';
const DATA_DIR = process.env.DATA_DIR || './data';
@@ -80,25 +80,29 @@ export function fetchAttachmentsForNotes(noteIds: string[]): Map<string, Attachm
return map;
}
export async function getAttachment(id: string) {
return db.select().from(attachments).where(eq(attachments.id, id)).get();
export async function getAttachment(id: string, noteId: string) {
return db
.select()
.from(attachments)
.where(and(eq(attachments.id, id), eq(attachments.noteId, noteId)))
.get();
}
export async function getAttachmentsByNote(noteId: string) {
return db.select().from(attachments).where(eq(attachments.noteId, noteId));
}
export async function updateAttachment(id: string, data: { featured: boolean }) {
export async function updateAttachment(id: string, noteId: string, data: { featured: boolean }) {
const [updated] = await db
.update(attachments)
.set({ featured: data.featured })
.where(eq(attachments.id, id))
.where(and(eq(attachments.id, id), eq(attachments.noteId, noteId)))
.returning();
return updated ?? null;
}
export async function deleteAttachment(id: string) {
const attachment = await getAttachment(id);
export async function deleteAttachment(id: string, noteId: string) {
const attachment = await getAttachment(id, noteId);
if (attachment) {
try {
await unlink(attachment.path);
@@ -112,6 +116,6 @@ export async function deleteAttachment(id: string) {
// Thumbnail may already be deleted
}
}
await db.delete(attachments).where(eq(attachments.id, id));
await db.delete(attachments).where(and(eq(attachments.id, id), eq(attachments.noteId, noteId)));
}
}
+10
View File
@@ -167,6 +167,16 @@ sqlite.exec(`
CREATE INDEX IF NOT EXISTS note_tags_tag_id_idx ON note_tags(tag_id);
CREATE INDEX IF NOT EXISTS sync_log_timestamp_idx ON sync_log(timestamp);
CREATE INDEX IF NOT EXISTS login_attempts_ip_timestamp_idx ON login_attempts(ip, timestamp);
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
key_hash TEXT NOT NULL,
key_prefix TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER
);
`);
// Replace old tags unique index with compound index including user_id
+18 -6
View File
@@ -9,7 +9,7 @@ import type * as schema from './schema.js';
let db: BetterSQLite3Database<typeof schema>;
beforeEach(() => {
const testDb = createTestDb();
const testDb = createTestDb({ seedUser: true });
db = testDb.db;
});
@@ -20,6 +20,7 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({
id,
userId: 1,
title: 'Test Note',
content: 'Hello world',
createdAt: now,
@@ -45,6 +46,7 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({
id,
userId: 1,
title: 'Colored Note',
content: '',
color: 'coral',
@@ -62,16 +64,17 @@ describe('Notes CRUD', () => {
it('should read all active notes (not archived, not trashed)', async () => {
const now = new Date();
await db.insert(notes).values([
{ id: 'n1', title: 'Active', content: '', createdAt: now, updatedAt: now },
{ id: 'n1', userId: 1, title: 'Active', content: '', createdAt: now, updatedAt: now },
{
id: 'n2',
userId: 1,
title: 'Archived',
content: '',
archived: true,
createdAt: now,
updatedAt: now
},
{ id: 'n3', title: 'Trashed', content: '', trashed: true, createdAt: now, updatedAt: now }
{ id: 'n3', userId: 1, title: 'Trashed', content: '', trashed: true, createdAt: now, updatedAt: now }
]);
const active = await db
@@ -89,6 +92,7 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({
id,
userId: 1,
title: 'Original',
content: '',
createdAt: now,
@@ -113,6 +117,7 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({
id,
userId: 1,
title: 'To Trash',
content: '',
createdAt: now,
@@ -132,6 +137,7 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({
id,
userId: 1,
title: 'To Delete',
content: '',
createdAt: now,
@@ -149,6 +155,7 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({
id,
userId: 1,
title: 'To Archive',
content: '',
createdAt: now,
@@ -170,6 +177,7 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({
id,
userId: 1,
title: 'Pin Me',
content: '',
createdAt: now,
@@ -193,6 +201,7 @@ describe('Notes with Tags', () => {
await db.insert(notes).values({
id: noteId,
userId: 1,
title: 'Tagged Note',
content: '',
createdAt: now,
@@ -200,8 +209,8 @@ describe('Notes with Tags', () => {
});
// Create tags
const [tag1] = await db.insert(tags).values({ name: 'work' }).returning();
const [tag2] = await db.insert(tags).values({ name: 'important' }).returning();
const [tag1] = await db.insert(tags).values({ userId: 1, name: 'work' }).returning();
const [tag2] = await db.insert(tags).values({ userId: 1, name: 'important' }).returning();
// Associate
await db.insert(noteTags).values([
@@ -226,13 +235,14 @@ describe('Notes with Tags', () => {
await db.insert(notes).values({
id: noteId,
userId: 1,
title: '',
content: '',
createdAt: now,
updatedAt: now
});
const [tag] = await db.insert(tags).values({ name: 'temp' }).returning();
const [tag] = await db.insert(tags).values({ userId: 1, name: 'temp' }).returning();
await db.insert(noteTags).values({ noteId, tagId: tag.id });
// Delete note_tags first (FK cascade is disabled in test DB), then note
@@ -258,6 +268,7 @@ describe('Note ordering', () => {
await db.insert(notes).values([
{
id: 'unpinned',
userId: 1,
title: 'Unpinned',
content: '',
pinned: false,
@@ -266,6 +277,7 @@ describe('Note ordering', () => {
},
{
id: 'pinned',
userId: 1,
title: 'Pinned',
content: '',
pinned: true,
+12
View File
@@ -98,6 +98,18 @@ export const attachments = sqliteTable('attachments', {
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
});
export const apiKeys = sqliteTable('api_keys', {
id: text('id').primaryKey(),
userId: integer('user_id')
.references(() => users.id)
.notNull(),
name: text('name').notNull(),
keyHash: text('key_hash').notNull(),
keyPrefix: text('key_prefix').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
lastUsedAt: integer('last_used_at', { mode: 'timestamp' })
});
export const syncLog = sqliteTable(
'sync_log',
{
+6 -6
View File
@@ -8,18 +8,18 @@ import type * as schema from './schema.js';
let db: BetterSQLite3Database<typeof schema>;
beforeEach(async () => {
const testDb = createTestDb();
const testDb = createTestDb({ seedUser: true });
db = testDb.db;
const now = new Date();
await db.insert(notes).values([
{ id: 'n1', title: 'Shopping List', content: 'Buy milk and eggs', createdAt: now, updatedAt: now },
{ id: 'n2', title: 'Work Notes', content: 'Meeting at 3pm #work', createdAt: now, updatedAt: now },
{ id: 'n3', title: 'Recipe', content: 'Pasta with tomato sauce', createdAt: now, updatedAt: now },
{ id: 'n4', title: 'Trashed', content: 'Should not appear', trashed: true, createdAt: now, updatedAt: now }
{ id: 'n1', userId: 1, title: 'Shopping List', content: 'Buy milk and eggs', createdAt: now, updatedAt: now },
{ id: 'n2', userId: 1, title: 'Work Notes', content: 'Meeting at 3pm #work', createdAt: now, updatedAt: now },
{ id: 'n3', userId: 1, title: 'Recipe', content: 'Pasta with tomato sauce', createdAt: now, updatedAt: now },
{ id: 'n4', userId: 1, title: 'Trashed', content: 'Should not appear', trashed: true, createdAt: now, updatedAt: now }
]);
const [workTag] = await db.insert(tags).values({ name: 'work' }).returning();
const [workTag] = await db.insert(tags).values({ userId: 1, name: 'work' }).returning();
await db.insert(noteTags).values({ noteId: 'n2', tagId: workTag.id });
});
+22 -1
View File
@@ -6,7 +6,7 @@ import * as schema from './schema.js';
* Create an in-memory SQLite database for testing.
* Each test gets a fresh database.
*/
export function createTestDb() {
export function createTestDb(options?: { seedUser?: boolean }) {
const sqlite = new Database(':memory:');
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = OFF');
@@ -97,8 +97,29 @@ export function createTestDb() {
timestamp INTEGER NOT NULL
);
CREATE INDEX login_attempts_ip_timestamp_idx ON login_attempts(ip, timestamp);
CREATE TABLE api_keys (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
key_hash TEXT NOT NULL,
key_prefix TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER
);
`);
const db = drizzle(sqlite, { schema });
if (options?.seedUser) {
db.insert(schema.users).values({
email: 'test@test.com',
displayName: 'Test User',
role: 'admin',
authProvider: 'password',
createdAt: new Date()
}).run();
}
return { db, sqlite };
}
+343
View File
@@ -0,0 +1,343 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { NoteFilter } from '$lib/types/index.js';
import {
listNotes,
getNote,
createNote,
updateNote,
deleteNote,
searchNotes,
listAllTags,
reorderNotes
} from '../notes-service.js';
import { saveAttachment } from '../attachments.js';
const NOTE_COLORS = [
'default',
'coral',
'peach',
'sand',
'mint',
'sage',
'fog',
'storm',
'dusk',
'blossom',
'clay',
'chalk'
] as const;
export function createMcpServer(userId: number): McpServer {
const server = new McpServer({
name: 'crumbs',
version: '0.7.2'
});
server.tool(
'list_notes',
'List notes with optional filter (all, archived, trashed) and tag filter',
{
filter: z
.enum(['all', 'archived', 'trashed'])
.optional()
.describe('Filter notes by status'),
tag: z.string().optional().describe('Filter by tag name')
},
async ({ filter = 'all', tag }: { filter?: string; tag?: string }) => {
let result = listNotes(userId, filter as NoteFilter);
if (tag) {
result = result.filter((n) =>
(n.tags ?? []).some((t: string) => t.toLowerCase() === tag.toLowerCase())
);
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }]
};
}
);
server.tool(
'get_note',
'Get a single note by ID with its tags and attachments',
{ id: z.string().describe('Note ID') },
async ({ id }: { id: string }) => {
const note = getNote(userId, id);
if (!note) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
isError: true
};
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(note, null, 2) }]
};
}
);
server.tool(
'create_note',
'Create a new note',
{
title: z.string().optional().describe('Note title'),
content: z.string().optional().describe('Note content (supports markdown)'),
color: z.enum(NOTE_COLORS).optional().describe('Note color'),
checklistMode: z.boolean().optional().describe('Enable checklist mode')
},
async ({ title, content, color, checklistMode }: { title?: string; content?: string; color?: string; checklistMode?: boolean }) => {
const note = createNote(userId, { title, content, color, checklistMode });
return {
content: [{ type: 'text' as const, text: JSON.stringify(note, null, 2) }]
};
}
);
server.tool(
'update_note',
'Update an existing note',
{
id: z.string().describe('Note ID'),
title: z.string().optional().describe('New title'),
content: z.string().optional().describe('New content'),
color: z.enum(NOTE_COLORS).optional().describe('New color'),
pinned: z.boolean().optional().describe('Pin/unpin'),
checklistMode: z.boolean().optional().describe('Enable/disable checklist mode')
},
async ({ id, ...updates }: { id: string; title?: string; content?: string; color?: string; pinned?: boolean; checklistMode?: boolean }) => {
const result = updateNote(userId, id, updates);
if (!result) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
isError: true
};
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }]
};
}
);
server.tool(
'trash_note',
'Move a note to trash',
{ id: z.string().describe('Note ID') },
async ({ id }: { id: string }) => {
const result = updateNote(userId, id, { trashed: true });
if (!result) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
isError: true
};
}
return {
content: [{ type: 'text' as const, text: `Note "${result.title}" trashed` }]
};
}
);
server.tool(
'restore_note',
'Restore a note from trash',
{ id: z.string().describe('Note ID') },
async ({ id }: { id: string }) => {
const result = updateNote(userId, id, { trashed: false });
if (!result) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
isError: true
};
}
return {
content: [{ type: 'text' as const, text: `Note "${result.title}" restored` }]
};
}
);
server.tool(
'archive_note',
'Archive a note',
{ id: z.string().describe('Note ID') },
async ({ id }: { id: string }) => {
const result = updateNote(userId, id, { archived: true });
if (!result) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
isError: true
};
}
return {
content: [{ type: 'text' as const, text: `Note "${result.title}" archived` }]
};
}
);
server.tool(
'unarchive_note',
'Unarchive a note',
{ id: z.string().describe('Note ID') },
async ({ id }: { id: string }) => {
const result = updateNote(userId, id, { archived: false });
if (!result) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
isError: true
};
}
return {
content: [{ type: 'text' as const, text: `Note "${result.title}" unarchived` }]
};
}
);
server.tool(
'delete_note',
'Permanently delete a note',
{ id: z.string().describe('Note ID') },
async ({ id }: { id: string }) => {
const existing = getNote(userId, id);
if (!existing) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
isError: true
};
}
deleteNote(userId, id);
return {
content: [
{
type: 'text' as const,
text: `Note "${existing.title}" permanently deleted`
}
]
};
}
);
server.tool(
'search_notes',
'Search notes by title, content, or tag name',
{ query: z.string().describe('Search query') },
async ({ query }: { query: string }) => {
const results = searchNotes(userId, query);
return {
content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }]
};
}
);
server.tool('list_tags', 'List all tags', {}, async () => {
const allTags = listAllTags(userId);
return {
content: [{ type: 'text' as const, text: JSON.stringify(allTags, null, 2) }]
};
});
server.tool(
'pin_note',
'Pin or unpin a note',
{
id: z.string().describe('Note ID'),
pinned: z.boolean().describe('Whether to pin (true) or unpin (false)')
},
async ({ id, pinned }: { id: string; pinned: boolean }) => {
const result = updateNote(userId, id, { pinned });
if (!result) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
isError: true
};
}
return {
content: [
{
type: 'text' as const,
text: `Note "${result.title}" ${pinned ? 'pinned' : 'unpinned'}`
}
]
};
}
);
server.tool(
'reorder_notes',
'Reorder notes by setting sort orders',
{
orders: z
.array(
z.object({
id: z.string().describe('Note ID'),
sortOrder: z.number().describe('New sort order')
})
)
.describe('Array of note IDs with their new sort orders')
},
async ({ orders }: { orders: { id: string; sortOrder: number }[] }) => {
reorderNotes(userId, orders);
return {
content: [{ type: 'text' as const, text: `Reordered ${orders.length} notes` }]
};
}
);
server.tool(
'upload_image',
'Attach an image to a note by fetching it from a URL',
{
noteId: z.string().describe('Note ID to attach the image to'),
imageUrl: z.string().url().describe('URL of the image to fetch')
},
async ({ noteId, imageUrl }: { noteId: string; imageUrl: string }) => {
const existing = getNote(userId, noteId);
if (!existing) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
isError: true
};
}
try {
const response = await fetch(imageUrl);
if (!response.ok) {
return {
content: [
{
type: 'text' as const,
text: `Failed to fetch image: ${response.status} ${response.statusText}`
}
],
isError: true
};
}
const contentType = response.headers.get('content-type') || 'image/png';
const buffer = Buffer.from(await response.arrayBuffer());
const ext = contentType.split('/')[1]?.split(';')[0] || 'png';
const filename = `upload_${Date.now()}.${ext}`;
const file = new File([buffer], filename, { type: contentType });
const attachment = await saveAttachment(noteId, file, userId);
return {
content: [{ type: 'text' as const, text: JSON.stringify(attachment, null, 2) }]
};
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to upload image: ${err instanceof Error ? err.message : String(err)}`
}
],
isError: true
};
}
}
);
return server;
}
+236
View File
@@ -0,0 +1,236 @@
import { db as defaultDb } from './db/index.js';
import { notes, noteTags, tags } from './db/schema.js';
import { eq, and, desc, like, or, inArray } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
import { extractTags } from '$lib/utils/tags.js';
import { fetchTagsForNotes, syncNoteTags } from './tags.js';
import { fetchAttachmentsForNotes } from './attachments.js';
import type { NoteFilter } from '$lib/types/index.js';
const db = defaultDb;
export function listNotes(userId: number, filter: NoteFilter = 'all') {
let conditions;
switch (filter) {
case 'archived':
conditions = and(eq(notes.userId, userId), eq(notes.archived, true), eq(notes.trashed, false));
break;
case 'trashed':
conditions = and(eq(notes.userId, userId), eq(notes.trashed, true));
break;
default:
conditions = and(eq(notes.userId, userId), eq(notes.archived, false), eq(notes.trashed, false));
}
const result = db
.select()
.from(notes)
.where(conditions)
.orderBy(desc(notes.pinned), desc(notes.updatedAt))
.all();
const noteIds = result.map((n) => n.id);
const tagMap = fetchTagsForNotes(noteIds);
const attachmentMap = fetchAttachmentsForNotes(noteIds);
return result.map((note) => ({
...note,
tags: tagMap.get(note.id) ?? [],
attachments: attachmentMap.get(note.id) ?? []
}));
}
export function getNote(userId: number, id: string) {
const note = db
.select()
.from(notes)
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
.get();
if (!note) return null;
const tagMap = fetchTagsForNotes([id]);
const attachmentMap = fetchAttachmentsForNotes([id]);
return {
...note,
tags: tagMap.get(id) ?? [],
attachments: attachmentMap.get(id) ?? []
};
}
export interface CreateNoteInput {
id?: string;
title?: string;
content?: string;
color?: string;
pinned?: boolean;
checklistMode?: boolean;
sortOrder?: number;
}
export function createNote(userId: number, input: CreateNoteInput) {
const now = new Date();
const id = input.id || uuidv4();
const newNote = {
id,
userId,
title: input.title || '',
content: input.content || '',
color: input.color || 'default',
pinned: input.pinned || false,
archived: false,
trashed: false,
trashedAt: null,
checklistMode: input.checklistMode || false,
sortOrder: input.sortOrder || 0,
createdAt: now,
updatedAt: now,
version: 1
};
const content = `${newNote.title} ${newNote.content}`;
const extractedTags = extractTags(content);
db.transaction((tx) => {
tx.insert(notes).values(newNote).run();
});
syncNoteTags(id, extractedTags, userId);
return { ...newNote, tags: extractedTags };
}
export interface UpdateNoteInput {
title?: string;
content?: string;
color?: string;
pinned?: boolean;
archived?: boolean;
trashed?: boolean;
checklistMode?: boolean;
sortOrder?: number;
}
export function updateNote(userId: number, id: string, input: UpdateNoteInput) {
const existing = db
.select()
.from(notes)
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
.get();
if (!existing) return null;
const now = new Date();
const updates: Record<string, unknown> = {
updatedAt: now,
version: existing.version + 1
};
if (input.title !== undefined) updates.title = input.title;
if (input.content !== undefined) updates.content = input.content;
if (input.color !== undefined) updates.color = input.color;
if (input.pinned !== undefined) updates.pinned = input.pinned;
if (input.archived !== undefined) updates.archived = input.archived;
if (input.trashed !== undefined) {
updates.trashed = input.trashed;
updates.trashedAt = input.trashed ? now : null;
}
if (input.checklistMode !== undefined) updates.checklistMode = input.checklistMode;
if (input.sortOrder !== undefined) updates.sortOrder = input.sortOrder;
db.update(notes)
.set(updates)
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
.run();
const updated = db
.select()
.from(notes)
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
.get();
if (input.title !== undefined || input.content !== undefined) {
const content = `${updated!.title} ${updated!.content}`;
const extractedTags = extractTags(content);
syncNoteTags(id, extractedTags, userId);
}
const tagMap = fetchTagsForNotes([id]);
const attachmentMap = fetchAttachmentsForNotes([id]);
return {
...updated,
tags: tagMap.get(id) ?? [],
attachments: attachmentMap.get(id) ?? []
};
}
export function deleteNote(userId: number, id: string): boolean {
const existing = db
.select()
.from(notes)
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
.get();
if (!existing) return false;
db.delete(notes)
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
.run();
return true;
}
export function searchNotes(userId: number, query: string) {
if (!query) return [];
const pattern = `%${query}%`;
const results = db
.select()
.from(notes)
.where(
and(
eq(notes.userId, userId),
eq(notes.trashed, false),
or(like(notes.title, pattern), like(notes.content, pattern))
)
)
.all();
const tagResults = db
.select({ noteId: noteTags.noteId })
.from(noteTags)
.innerJoin(tags, eq(noteTags.tagId, tags.id))
.where(and(like(tags.name, pattern), eq(tags.userId, userId)))
.all();
const resultIds = new Set(results.map((n) => n.id));
const extraNoteIds = [...new Set(tagResults.map((r) => r.noteId))].filter(
(id) => !resultIds.has(id)
);
let extraNotes: typeof results = [];
if (extraNoteIds.length > 0) {
extraNotes = db
.select()
.from(notes)
.where(and(eq(notes.userId, userId), inArray(notes.id, extraNoteIds), eq(notes.trashed, false)))
.all();
}
const combined = [...results, ...extraNotes];
const tagMap = fetchTagsForNotes(combined.map((n) => n.id));
return combined.map((note) => ({
...note,
tags: tagMap.get(note.id) ?? []
}));
}
export function listAllTags(userId: number) {
return db.select().from(tags).where(eq(tags.userId, userId)).all();
}
export function reorderNotes(userId: number, orders: { id: string; sortOrder: number }[]) {
const now = new Date();
for (const { id, sortOrder } of orders) {
db.update(notes)
.set({ sortOrder, updatedAt: now })
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
.run();
}
}
+8 -4
View File
@@ -1,6 +1,6 @@
import { db } from './db/index.js';
import { noteTags, tags } from './db/schema.js';
import { eq, inArray } from 'drizzle-orm';
import { eq, and, inArray } from 'drizzle-orm';
/**
* Batch fetch tags for multiple notes in a single query.
@@ -32,13 +32,17 @@ export function fetchTagsForNotes(noteIds: string[]): Map<string, string[]> {
* Sync tags for a note: removes old associations, upserts tags, creates new associations.
* Should be called within a transaction for atomicity.
*/
export function syncNoteTags(noteId: string, tagNames: string[]) {
export function syncNoteTags(noteId: string, tagNames: string[], userId: number) {
db.delete(noteTags).where(eq(noteTags.noteId, noteId)).run();
for (const name of tagNames) {
let tagRow = db.select().from(tags).where(eq(tags.name, name)).get();
let tagRow = db
.select()
.from(tags)
.where(and(eq(tags.name, name), eq(tags.userId, userId)))
.get();
if (!tagRow) {
const result = db.insert(tags).values({ name }).returning().all();
const result = db.insert(tags).values({ name, userId }).returning().all();
tagRow = result[0];
}
db.insert(noteTags).values({ noteId, tagId: tagRow.id }).run();
+1 -3
View File
@@ -51,9 +51,7 @@ export const unpinnedNotes = derived([filteredNotes, sortMode], ([$notes, $sortM
export const allTags = derived(notes, ($notes) => {
const tagSet = new Set<string>();
$notes
.filter((n) => !n.archived && !n.trashed)
.forEach((n) => n.tags?.forEach((t) => tagSet.add(t)));
$notes.forEach((n) => n.tags?.forEach((t) => tagSet.add(t)));
return [...tagSet].sort();
});
+1 -1
View File
@@ -81,7 +81,7 @@ export async function processSyncPush(changes: SyncQueueItem[], userId: number):
const note = db.select().from(notes).where(and(eq(notes.id, noteId), eq(notes.userId, userId))).get();
if (note) {
const content = `${note.title} ${note.content}`;
syncNoteTags(noteId, extractTags(content));
syncNoteTags(noteId, extractTags(content), userId);
}
}
}
+1 -1
View File
@@ -25,7 +25,7 @@
</script>
<div class="flex min-h-screen flex-col bg-[var(--bg-base)] text-[var(--text)]">
<Header onMenuToggle={() => (sidebarOpen = !sidebarOpen)} user={data.user} />
<Header onMenuToggle={() => (sidebarOpen = !sidebarOpen)} />
<Sidebar open={sidebarOpen} onClose={() => (sidebarOpen = false)} appVersion={data.appVersion} />
<main class="flex-1 pt-4 transition-all {sidebarOpen ? 'lg:ml-64' : ''}">
+2 -111
View File
@@ -1,118 +1,9 @@
<script lang="ts">
import NoteGrid from '$lib/components/NoteGrid.svelte';
import NoteEditor from '$lib/components/NoteEditor.svelte';
import TagFilter from '$lib/components/TagFilter.svelte';
import SortSelector from '$lib/components/SortSelector.svelte';
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded, updateSortOrders } from '$lib/stores/notes.js';
import { sortMode } from '$lib/stores/sort.js';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import type { Note } from '$lib/types/index.js';
import Plus from 'lucide-svelte/icons/plus';
let editingNote: Note | null = $state(null);
let showNewNote = $state(false);
function openEditor(note: Note) {
editingNote = note;
history.replaceState(null, '', `#${note.id}`);
}
function closeEditor() {
editingNote = null;
showNewNote = false;
history.replaceState(null, '', location.pathname);
}
function handleReorder(noteIds: string[]) {
const orders = noteIds.map((id, index) => ({ id, sortOrder: index }));
updateSortOrders(orders);
}
onMount(() => {
const hash = location.hash.slice(1);
if (!hash) return;
// Notes may not be loaded yet — wait for them
const unsubscribe = notes.subscribe(($notes) => {
if ($notes.length === 0) return;
const note = $notes.find((n) => n.id === hash);
if (note) editingNote = note;
unsubscribe();
});
});
import NotesView from '$lib/components/NotesView.svelte';
</script>
<svelte:head>
<title>Crumbs</title>
</svelte:head>
{#if $currentFilter === 'all'}
<div class="mx-auto mb-6 max-w-xl">
<button
onclick={() => (showNewNote = true)}
class="flex w-full items-center gap-3 rounded-sm border border-dashed border-[var(--border-subtle)] bg-[var(--bg-surface)] px-4 py-3 text-left text-sm text-[var(--text-muted)] transition-colors hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-testid="new-note-btn"
>
<Plus class="h-4 w-4" />
Add a crumb...
</button>
</div>
{/if}
{#if $currentFilter === 'all'}
<div class="mb-4 flex flex-wrap items-start justify-between gap-3">
<TagFilter />
<SortSelector />
</div>
{:else}
<div class="mb-4">
<TagFilter />
</div>
{/if}
{#if $currentFilter === 'archived'}
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Archive</h2>
{:else if $currentFilter === 'trashed'}
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Trash</h2>
{/if}
{#if $pinnedNotes.length > 0}
<div class="mb-6">
<NoteGrid notes={$pinnedNotes} label="Pinned" onEdit={openEditor} draggable={$sortMode === 'custom'} dndType="pinned-notes" onReorder={handleReorder} />
</div>
{/if}
<NoteGrid
notes={$unpinnedNotes}
label={$pinnedNotes.length > 0 ? 'Others' : ''}
onEdit={openEditor}
draggable={$sortMode === 'custom'}
dndType="unpinned-notes"
onReorder={handleReorder}
/>
{#if $notesLoaded && $pinnedNotes.length === 0 && $unpinnedNotes.length === 0}
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-muted)]">
<img src="/favicon.svg" alt="" class="mb-4 h-18 w-18" />
<p class="font-['Press_Start_2P'] text-xs">
{#if $currentFilter === 'trashed'}
No crumbs in trash
{:else if $currentFilter === 'archived'}
No archived crumbs
{:else if $selectedTag}
No crumbs with tag #{$selectedTag}
{:else}
No crumbs yet
{/if}
</p>
</div>
{/if}
{#if showNewNote}
<NoteEditor note={null} isNew={true} onClose={closeEditor} />
{/if}
{#if editingNote}
<NoteEditor note={editingNote} onClose={closeEditor} />
{/if}
<NotesView filter="all" />
+9
View File
@@ -0,0 +1,9 @@
<script lang="ts">
import NotesView from '$lib/components/NotesView.svelte';
</script>
<svelte:head>
<title>Archive - Crumbs</title>
</svelte:head>
<NotesView filter="archived" />
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
import { page } from '$app/state';
import { User, Cpu, ShieldCheck } from 'lucide-svelte';
let { data, children } = $props();
let pathname = $derived(page.url.pathname);
</script>
<svelte:head>
<title>Settings - Crumbs</title>
</svelte:head>
<div class="mx-auto max-w-4xl px-4 py-8">
<h1 class="mb-6 text-2xl font-bold text-[var(--text)]">Settings</h1>
<div class="flex flex-col gap-6 md:flex-row">
<!-- Sidebar nav -->
<nav class="w-full shrink-0 md:w-48">
<ul class="flex gap-1 overflow-x-auto md:flex-col md:overflow-visible">
<li>
<a
href="/settings"
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
>
<User size={16} />
Profile
</a>
</li>
<li>
<a
href="/settings/mcp"
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/mcp' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
>
<Cpu size={16} />
MCP Server
</a>
</li>
{#if data.user?.role === 'admin'}
<li>
<a
href="/settings/users"
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/users' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
>
<ShieldCheck size={16} />
Users
</a>
</li>
{/if}
</ul>
</nav>
<!-- Content area -->
<div class="min-w-0 flex-1">
{@render children()}
</div>
</div>
</div>
+204 -145
View File
@@ -1,5 +1,4 @@
<script lang="ts">
import { goto } from '$app/navigation';
import PasswordStrengthMeter from '$lib/components/PasswordStrengthMeter.svelte';
type SessionInfo = {
@@ -14,20 +13,29 @@
let { data } = $props();
// Profile state
let displayName = $state(data.user?.displayName || '');
let email = $state(data.user?.email || '');
let profileMsg = $state('');
let profileError = $state(false);
// Password state
let currentPassword = $state('');
let newPassword = $state('');
let confirmNewPassword = $state('');
let passwordMsg = $state('');
let passwordError = $state(false);
// Sessions state
let activeSessions = $state<SessionInfo[]>([]);
let sessionsLoaded = $state(false);
// Delete account state
let deletePassword = $state('');
let deleteMsg = $state('');
let deleteError = $state(false);
let showDeleteConfirm = $state(false);
async function loadSessions() {
try {
const res = await fetch('/api/auth/sessions');
@@ -78,9 +86,31 @@
return new Date(d).toLocaleString();
}
$effect(() => {
loadSessions();
});
async function deleteAccount() {
deleteMsg = '';
try {
const res = await fetch('/api/auth/account', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: deletePassword })
});
if (res.ok) {
window.location.href = '/login';
} else {
const d = await res.json();
deleteMsg = d.message || 'Failed to delete account';
deleteError = true;
}
} catch {
deleteMsg = 'Connection error';
deleteError = true;
}
}
async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST' });
window.location.href = '/login';
}
async function saveProfile() {
profileMsg = '';
@@ -138,149 +168,178 @@
passwordError = true;
}
}
$effect(() => {
loadSessions();
});
</script>
<svelte:head>
<title>Settings - Crumbs</title>
</svelte:head>
<!-- Profile -->
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Profile</h2>
<div class="mx-auto max-w-2xl px-4 py-8">
<h1 class="mb-8 text-2xl font-bold text-[var(--text)]">Settings</h1>
<!-- Profile -->
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Profile</h2>
{#if profileMsg}
<div class="mb-4 rounded-sm border p-3 text-sm {profileError ? 'border-[var(--error-border)] bg-[var(--error-bg)] text-[var(--error-text)]' : 'border-[var(--success-bg)] bg-[var(--success-bg)] text-[var(--success-text)]'}">
{profileMsg}
</div>
{/if}
<div class="space-y-4">
<div>
<label for="display-name" class="mb-1 block text-sm font-medium text-[var(--text)]">Display Name</label>
<input
id="display-name"
type="text"
bind:value={displayName}
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none focus:border-[var(--primary)]"
/>
</div>
<div>
<label for="email" class="mb-1 block text-sm font-medium text-[var(--text)]">Email</label>
<input
id="email"
type="email"
bind:value={email}
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none focus:border-[var(--primary)]"
/>
</div>
<button
onclick={saveProfile}
class="rounded-sm bg-[var(--primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--primary-hover)]"
>
Save profile
</button>
{#if profileMsg}
<div class="mb-4 rounded-sm border p-3 text-sm {profileError ? 'border-[var(--error-border)] bg-[var(--error-bg)] text-[var(--error-text)]' : 'border-[var(--success-bg)] bg-[var(--success-bg)] text-[var(--success-text)]'}">
{profileMsg}
</div>
</section>
<!-- Change Password -->
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Change Password</h2>
{#if passwordMsg}
<div class="mb-4 rounded-sm border p-3 text-sm {passwordError ? 'border-[var(--error-border)] bg-[var(--error-bg)] text-[var(--error-text)]' : 'border-[var(--success-bg)] bg-[var(--success-bg)] text-[var(--success-text)]'}">
{passwordMsg}
</div>
{/if}
<div class="space-y-4">
<input
type="password"
bind:value={currentPassword}
placeholder="Current password"
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
/>
<input
type="password"
bind:value={newPassword}
placeholder="New password (min 8 characters)"
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
/>
<PasswordStrengthMeter password={newPassword} />
<input
type="password"
bind:value={confirmNewPassword}
placeholder="Confirm new password"
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
/>
<button
onclick={changePassword}
class="rounded-sm bg-[var(--primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--primary-hover)]"
>
Change password
</button>
</div>
</section>
<!-- Active Sessions -->
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-[var(--text)]">Active Sessions</h2>
{#if activeSessions.length > 1}
<button
onclick={revokeAllOtherSessions}
class="text-xs text-[var(--destructive)] hover:underline"
>
Log out everywhere else
</button>
{/if}
</div>
{#if !sessionsLoaded}
<p class="text-sm text-[var(--text-muted)]">Loading sessions...</p>
{:else if activeSessions.length === 0}
<p class="text-sm text-[var(--text-muted)]">No active sessions</p>
{:else}
<div class="space-y-3">
{#each activeSessions as session (session.id)}
<div class="flex items-center justify-between rounded-sm border border-[var(--border-subtle)] p-3">
<div>
<p class="text-sm font-medium text-[var(--text)]">
{formatAgent(session.userAgent)}
{#if session.isCurrent}
<span class="ml-2 rounded-sm bg-[var(--success-bg)] px-2 py-0.5 text-xs text-[var(--success-text)]">current</span>
{/if}
</p>
<p class="text-xs text-[var(--text-muted)]">
{session.ip || 'Unknown IP'} · Last active {formatDate(session.lastUsedAt)}
</p>
</div>
{#if !session.isCurrent}
<button
onclick={() => revokeSession(session.id)}
class="rounded-sm px-3 py-1 text-xs text-[var(--destructive)] hover:bg-[var(--error-bg)]"
>
Revoke
</button>
{/if}
</div>
{/each}
</div>
{/if}
</section>
<!-- Admin: User Management Link -->
{#if data.user?.role === 'admin'}
<section class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Administration</h2>
<a
href="/settings/users"
class="text-sm text-[var(--primary)] hover:text-[var(--primary-hover)] hover:underline"
>
Manage users →
</a>
</section>
{/if}
</div>
<div class="space-y-4">
<div>
<label for="display-name" class="mb-1 block text-sm font-medium text-[var(--text)]">Display Name</label>
<input
id="display-name"
type="text"
bind:value={displayName}
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none focus:border-[var(--primary)]"
/>
</div>
<div>
<label for="email" class="mb-1 block text-sm font-medium text-[var(--text)]">Email</label>
<input
id="email"
type="email"
bind:value={email}
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none focus:border-[var(--primary)]"
/>
</div>
<button
onclick={saveProfile}
class="rounded-sm bg-[var(--primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--primary-hover)]"
>
Save profile
</button>
</div>
</section>
<!-- Change Password -->
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Change Password</h2>
{#if passwordMsg}
<div class="mb-4 rounded-sm border p-3 text-sm {passwordError ? 'border-[var(--error-border)] bg-[var(--error-bg)] text-[var(--error-text)]' : 'border-[var(--success-bg)] bg-[var(--success-bg)] text-[var(--success-text)]'}">
{passwordMsg}
</div>
{/if}
<div class="space-y-4">
<input
type="password"
bind:value={currentPassword}
placeholder="Current password"
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
/>
<input
type="password"
bind:value={newPassword}
placeholder="New password (min 8 characters)"
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
/>
<PasswordStrengthMeter password={newPassword} />
<input
type="password"
bind:value={confirmNewPassword}
placeholder="Confirm new password"
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
/>
<button
onclick={changePassword}
class="rounded-sm bg-[var(--primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--primary-hover)]"
>
Change password
</button>
</div>
</section>
<!-- Active Sessions -->
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-[var(--text)]">Active Sessions</h2>
{#if activeSessions.length > 1}
<button
onclick={revokeAllOtherSessions}
class="text-xs text-[var(--destructive)] hover:underline"
>
Log out everywhere else
</button>
{/if}
</div>
{#if !sessionsLoaded}
<p class="text-sm text-[var(--text-muted)]">Loading sessions...</p>
{:else if activeSessions.length === 0}
<p class="text-sm text-[var(--text-muted)]">No active sessions</p>
{:else}
<div class="space-y-3">
{#each activeSessions as session (session.id)}
<div class="flex items-center justify-between rounded-sm border border-[var(--border-subtle)] p-3">
<div>
<p class="text-sm font-medium text-[var(--text)]">
{formatAgent(session.userAgent)}
{#if session.isCurrent}
<span class="ml-2 rounded-sm bg-[var(--success-bg)] px-2 py-0.5 text-xs text-[var(--success-text)]">current</span>
{/if}
</p>
<p class="text-xs text-[var(--text-muted)]">
{session.ip || 'Unknown IP'} · Last active {formatDate(session.lastUsedAt)}
</p>
</div>
{#if !session.isCurrent}
<button
onclick={() => revokeSession(session.id)}
class="rounded-sm px-3 py-1 text-xs text-[var(--destructive)] hover:bg-[var(--error-bg)]"
>
Revoke
</button>
{/if}
</div>
{/each}
</div>
{/if}
</section>
<!-- Log Out & Delete Account -->
<section class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
<div class="flex items-center justify-between">
<button
onclick={handleLogout}
class="rounded-sm border border-[var(--border-subtle)] px-4 py-2 text-sm font-medium text-[var(--text)] hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-testid="logout-btn"
>
Log out
</button>
<button
onclick={() => (showDeleteConfirm = !showDeleteConfirm)}
class="rounded-sm px-4 py-2 text-sm font-medium text-[var(--destructive)] hover:underline"
>
Delete account
</button>
</div>
{#if showDeleteConfirm}
<div class="mt-4 rounded-sm border border-[var(--error-border)] bg-[var(--error-bg)] p-4">
<p class="mb-3 text-sm text-[var(--error-text)]">
This will permanently delete your account and all your notes. This cannot be undone.
</p>
{#if deleteMsg}
<div class="mb-3 text-sm text-[var(--error-text)]">{deleteMsg}</div>
{/if}
<div class="flex items-center gap-3">
<input
type="password"
bind:value={deletePassword}
placeholder="Enter your password to confirm"
class="flex-1 rounded-sm border border-[var(--error-border)] bg-[var(--bg-base)] px-3 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--destructive)]"
/>
<button
onclick={deleteAccount}
class="rounded-sm bg-[var(--destructive)] px-4 py-2 text-sm font-medium text-white hover:opacity-90"
>
Confirm delete
</button>
</div>
</div>
{/if}
</section>
+284
View File
@@ -0,0 +1,284 @@
<script lang="ts">
import { Key, Copy, Trash2, Plus, Check } from 'lucide-svelte';
interface ApiKey {
id: string;
name: string;
keyPrefix: string;
createdAt: string;
lastUsedAt: string | null;
}
// API Keys state
let keys = $state<ApiKey[]>([]);
let newKeyName = $state('');
let createdKey = $state<string | null>(null);
let keyLoading = $state(false);
let keyErrorMsg = $state('');
let copiedKey = $state(false);
let copiedJson = $state(false);
let copiedPrompt = $state(false);
let deleteConfirmId = $state<string | null>(null);
let baseUrl = $derived(typeof window !== 'undefined' ? window.location.origin : 'https://your-crumbs-instance');
let activeApiKey = $derived(createdKey ?? 'YOUR_API_KEY');
let mcpJsonConfig = $derived(`{
"mcpServers": {
"crumbs": {
"type": "streamable-http",
"url": "${baseUrl}/api/mcp",
"headers": {
"Authorization": "Bearer ${activeApiKey}"
}
}
}
}`);
let llmInstallPrompt = $derived(`Install the Crumbs MCP server with the following configuration:
- Type: Streamable HTTP
- URL: ${baseUrl}/api/mcp
- Authentication: Bearer token in the Authorization header
- API Key: ${activeApiKey}
Add this to your MCP client settings (e.g. Claude Code's .claude/settings.json or Claude Desktop's config):
${mcpJsonConfig}`);
function formatDateShort(dateStr: string | null): string {
if (!dateStr) return 'Never';
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
async function loadKeys() {
const res = await fetch('/api/settings/api-keys');
if (res.ok) keys = await res.json();
}
async function createKey(e: Event) {
e.preventDefault();
if (!newKeyName.trim()) return;
keyLoading = true;
keyErrorMsg = '';
createdKey = null;
try {
const res = await fetch('/api/settings/api-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newKeyName.trim() })
});
if (res.ok) {
const data = await res.json();
createdKey = data.key;
newKeyName = '';
await loadKeys();
} else {
const data = await res.json();
keyErrorMsg = data.error || 'Failed to create key';
}
} catch {
keyErrorMsg = 'Connection error';
} finally {
keyLoading = false;
}
}
async function deleteKey(id: string) {
try {
const res = await fetch(`/api/settings/api-keys/${id}`, { method: 'DELETE' });
if (res.ok) {
deleteConfirmId = null;
await loadKeys();
}
} catch {
keyErrorMsg = 'Failed to delete key';
}
}
async function copyToClipboard(text: string, target: 'key' | 'json' | 'prompt') {
await navigator.clipboard.writeText(text);
if (target === 'key') { copiedKey = true; setTimeout(() => (copiedKey = false), 2000); }
if (target === 'json') { copiedJson = true; setTimeout(() => (copiedJson = false), 2000); }
if (target === 'prompt') { copiedPrompt = true; setTimeout(() => (copiedPrompt = false), 2000); }
}
$effect(() => {
loadKeys();
});
</script>
<!-- API Keys -->
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
<div class="mb-4 flex items-center gap-2">
<Key size={16} class="text-[var(--primary)]" />
<h2 class="text-lg font-semibold text-[var(--text)]">API Keys</h2>
</div>
<p class="mb-4 text-xs text-[var(--text-muted)]">
API keys allow external tools (like Claude Code) to access your notes via the MCP protocol.
</p>
<!-- Create Key Form -->
<form onsubmit={createKey} class="mb-6 flex gap-2">
<input
type="text"
bind:value={newKeyName}
placeholder="Key name (e.g. Claude Code)"
class="flex-1 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
data-testid="api-key-name-input"
/>
<button
type="submit"
disabled={keyLoading || !newKeyName.trim()}
class="flex items-center gap-1 rounded-sm bg-[var(--primary)] px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-[var(--primary-hover)] disabled:opacity-50"
data-testid="create-api-key-btn"
>
<Plus size={14} />
Create
</button>
</form>
{#if keyErrorMsg}
<div class="mb-4 rounded-sm border border-[var(--error-border)] bg-[var(--error-bg)] p-3 text-sm text-[var(--error-text)]">
{keyErrorMsg}
</div>
{/if}
<!-- Newly Created Key (show once) -->
{#if createdKey}
<div class="mb-4 rounded-sm border border-[var(--success-border,#a3b18a)] bg-[var(--success-bg,#f0f4e8)] p-4" data-testid="created-key-display">
<p class="mb-2 text-xs font-semibold text-[var(--success-text,#3a5a40)]">
Key created! Copy it now — it won't be shown again.
</p>
<div class="flex items-center gap-2">
<code class="flex-1 overflow-x-auto rounded-sm bg-[var(--bg-base)] px-3 py-2 text-xs text-[var(--text)]" data-testid="created-key-value">
{createdKey}
</code>
<button
onclick={() => copyToClipboard(createdKey!, 'key')}
class="flex items-center gap-1 rounded-sm border border-[var(--border-subtle)] px-3 py-2 text-xs transition-colors hover:border-[var(--primary)]"
data-testid="copy-key-btn"
>
{#if copiedKey}
<Check size={14} class="text-[var(--success-text,#3a5a40)]" />
{:else}
<Copy size={14} />
{/if}
</button>
</div>
</div>
{/if}
<!-- Key List -->
{#if keys.length === 0}
<p class="py-4 text-center text-xs text-[var(--text-muted)]">No API keys yet.</p>
{:else}
<div class="space-y-2" data-testid="api-keys-list">
{#each keys as key (key.id)}
<div class="flex items-center justify-between rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3" data-testid="api-key-item">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-[var(--text)]" data-testid="api-key-name">{key.name}</span>
<code class="text-xs text-[var(--text-muted)]">{key.keyPrefix}...</code>
</div>
<div class="mt-1 text-xs text-[var(--text-muted)]">
Created {formatDateShort(key.createdAt)}
{#if key.lastUsedAt}
· Last used {formatDateShort(key.lastUsedAt)}
{/if}
</div>
</div>
<div class="ml-4">
{#if deleteConfirmId === key.id}
<div class="flex items-center gap-1">
<button
onclick={() => deleteKey(key.id)}
class="rounded-sm bg-[var(--destructive)] px-3 py-1 text-xs text-white transition-colors hover:opacity-80"
data-testid="confirm-delete-btn"
>
Delete
</button>
<button
onclick={() => (deleteConfirmId = null)}
class="rounded-sm border border-[var(--border-subtle)] px-3 py-1 text-xs transition-colors hover:border-[var(--primary)]"
>
Cancel
</button>
</div>
{:else}
<button
onclick={() => (deleteConfirmId = key.id)}
class="rounded-sm p-2 text-[var(--text-muted)] transition-colors hover:text-[var(--destructive)]"
data-testid="delete-api-key-btn"
title="Revoke key"
>
<Trash2 size={14} />
</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</section>
<!-- MCP Config Help -->
<section class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Client Configuration</h2>
<p class="mb-3 text-xs text-[var(--text-muted)]">
Add this to your MCP client config (e.g. Claude Code settings):
</p>
<div class="relative">
<pre class="overflow-x-auto rounded-sm bg-[var(--bg-base)] p-3 pr-10 text-xs text-[var(--text)]">{mcpJsonConfig}</pre>
<button
onclick={() => copyToClipboard(mcpJsonConfig, 'json')}
class="absolute right-2 top-2 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-1.5 text-[var(--text-muted)] transition-colors hover:border-[var(--primary)] hover:text-[var(--text)]"
title="Copy JSON config"
>
{#if copiedJson}
<Check size={12} class="text-[var(--success-text,#3a5a40)]" />
{:else}
<Copy size={12} />
{/if}
</button>
</div>
{#if createdKey}
<p class="mt-1 text-xs text-[var(--success-text,#3a5a40)]">
Config updated with your new API key.
</p>
{/if}
<!-- LLM Installation Prompt -->
<div class="mt-6 border-t border-[var(--border-subtle)] pt-4">
<h3 class="mb-2 text-sm font-semibold text-[var(--text)]">LLM Installation Prompt</h3>
<p class="mb-3 text-xs text-[var(--text-muted)]">
Copy this prompt and paste it into your LLM to set up the MCP connection:
</p>
<div class="relative">
<pre class="overflow-x-auto rounded-sm bg-[var(--bg-base)] p-3 pr-10 text-xs text-[var(--text)] whitespace-pre-wrap">{llmInstallPrompt}</pre>
<button
onclick={() => copyToClipboard(llmInstallPrompt, 'prompt')}
class="absolute right-2 top-2 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-1.5 text-[var(--text-muted)] transition-colors hover:border-[var(--primary)] hover:text-[var(--text)]"
title="Copy LLM prompt"
>
{#if copiedPrompt}
<Check size={12} class="text-[var(--success-text,#3a5a40)]" />
{:else}
<Copy size={12} />
{/if}
</button>
</div>
{#if createdKey}
<p class="mt-1 text-xs text-[var(--success-text,#3a5a40)]">
Prompt updated with your new API key.
</p>
{/if}
</div>
</section>
+24 -12
View File
@@ -68,6 +68,22 @@
}
}
async function revokeSessions(user: User) {
try {
const res = await fetch(`/api/admin/users/${user.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ revokeSessions: true })
});
if (res.ok) {
createMsg = `All sessions revoked for ${user.displayName || user.email}`;
createError = false;
}
} catch {
// ignore
}
}
async function toggleRole(user: User) {
const newRoleValue = user.role === 'admin' ? 'user' : 'admin';
try {
@@ -85,17 +101,7 @@
}
</script>
<svelte:head>
<title>User Management - Crumbs</title>
</svelte:head>
<div class="mx-auto max-w-2xl px-4 py-8">
<div class="mb-8 flex items-center gap-4">
<a href="/settings" class="text-sm text-[var(--text-muted)] hover:text-[var(--primary)]">← Settings</a>
<h1 class="text-2xl font-bold text-[var(--text)]">User Management</h1>
</div>
<!-- Create User -->
<!-- Create User -->
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Create User</h2>
@@ -166,6 +172,13 @@
{user.role === 'admin' ? 'Make user' : 'Make admin'}
</button>
{#if user.id !== data.user?.id}
<button
onclick={() => revokeSessions(user)}
class="rounded-sm px-3 py-1 text-xs text-[var(--text-muted)] hover:bg-[var(--border-subtle)]/50"
title="Force logout"
>
Revoke
</button>
<button
onclick={() => deleteUser(user.id)}
class="rounded-sm px-3 py-1 text-xs text-[var(--destructive)] hover:bg-[var(--error-bg)]"
@@ -178,4 +191,3 @@
{/each}
</div>
</section>
</div>
+10
View File
@@ -0,0 +1,10 @@
<script lang="ts">
import NotesView from '$lib/components/NotesView.svelte';
import { page } from '$app/stores';
</script>
<svelte:head>
<title>#{$page.params.name} - Crumbs</title>
</svelte:head>
<NotesView filter="all" tag={$page.params.name} />
+9
View File
@@ -0,0 +1,9 @@
<script lang="ts">
import NotesView from '$lib/components/NotesView.svelte';
</script>
<svelte:head>
<title>Trash - Crumbs</title>
</svelte:head>
<NotesView filter="trashed" />
+5 -1
View File
@@ -1,7 +1,7 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { requireAdmin } from '$lib/server/api-utils.js';
import { getUser, deleteUser, updateUserRole, resetPassword } from '$lib/server/auth.js';
import { getUser, deleteUser, updateUserRole, resetPassword, revokeAllSessions } from '$lib/server/auth.js';
export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
const admin = requireAdmin(event);
@@ -26,6 +26,10 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
await resetPassword(userId, body.newPassword);
}
if (body.revokeSessions) {
revokeAllSessions(userId);
}
const updated = getUser(userId);
return json(updated);
};
+31
View File
@@ -0,0 +1,31 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { getUserId } from '$lib/server/api-utils.js';
import { verifyPassword, deleteUser, deleteSession, getUser } from '$lib/server/auth.js';
export const DELETE: RequestHandler = async ({ request, cookies, ...event }) => {
const userId = getUserId(event);
const user = getUser(userId);
if (!user) throw error(404, 'User not found');
const body = await request.json();
// OAuth users don't have passwords — allow deletion without password
if (user.authProvider === 'password') {
if (!body.password) throw error(400, 'Password is required');
const verified = await verifyPassword(user.email, body.password);
if (!verified) throw error(401, 'Invalid password');
}
// Delete user and all associated data
await deleteUser(userId);
// Clear session cookie
const token = cookies.get('session');
if (token) {
await deleteSession(token);
cookies.delete('session', { path: '/' });
}
return json({ success: true });
};
+80
View File
@@ -0,0 +1,80 @@
import type { RequestHandler } from './$types.js';
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { createMcpServer } from '$lib/server/mcp/server.js';
import { getUserId } from '$lib/server/api-utils.js';
const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
export const POST: RequestHandler = async ({ request, ...event }) => {
const userId = getUserId(event);
const sessionId = request.headers.get('mcp-session-id');
const body = await request.json();
// Reuse existing session
if (sessionId && transports.has(sessionId)) {
const transport = transports.get(sessionId)!;
return transport.handleRequest(request, { parsedBody: body });
}
// New session (initialize request)
if (!sessionId && isInitializeRequest(body)) {
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
enableJsonResponse: true,
onsessioninitialized: (id) => {
transports.set(id, transport);
}
});
transport.onclose = () => {
if (transport.sessionId) {
transports.delete(transport.sessionId);
}
};
const server = createMcpServer(userId);
await server.connect(transport);
return transport.handleRequest(request, { parsedBody: body });
}
// Invalid request
return new Response(
JSON.stringify({
jsonrpc: '2.0',
error: { code: -32000, message: 'Invalid session' },
id: null
}),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
}
);
};
export const GET: RequestHandler = async ({ request }) => {
const sessionId = request.headers.get('mcp-session-id');
if (sessionId && transports.has(sessionId)) {
return transports.get(sessionId)!.handleRequest(request);
}
return new Response(JSON.stringify({ error: 'Invalid session' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
};
export const DELETE: RequestHandler = async ({ request }) => {
const sessionId = request.headers.get('mcp-session-id');
if (sessionId && transports.has(sessionId)) {
return transports.get(sessionId)!.handleRequest(request);
}
return new Response(JSON.stringify({ error: 'Invalid session' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
};
+1 -1
View File
@@ -73,7 +73,7 @@ export const POST: RequestHandler = async ({ request, ...event }) => {
db.transaction((tx) => {
tx.insert(notes).values(newNote).run();
});
syncNoteTags(id, extractedTags);
syncNoteTags(id, extractedTags, userId);
return json({ ...newNote, tags: extractedTags }, { status: 201 });
};
+1 -1
View File
@@ -68,7 +68,7 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
if (body.title !== undefined || body.content !== undefined) {
const content = `${updated!.title} ${updated!.content}`;
const extractedTags = extractTags(content);
syncNoteTags(params.id, extractedTags);
syncNoteTags(params.id, extractedTags, userId);
}
const tagMap = fetchTagsForNotes([params.id]);
@@ -25,7 +25,7 @@ export const GET: RequestHandler = async ({ params, url, ...event }) => {
const attachmentId = url.searchParams.get('attachmentId');
if (attachmentId) {
const attachment = await getAttachment(attachmentId);
const attachment = await getAttachment(attachmentId, params.id);
if (!attachment) throw error(404, 'Attachment not found');
const wantThumb = url.searchParams.get('thumb') === '1';
@@ -81,7 +81,7 @@ export const PATCH: RequestHandler = async ({ params, url, request, ...event })
const body = await request.json();
if (typeof body.featured !== 'boolean') throw error(400, 'featured (boolean) required');
const updated = await updateAttachment(attachmentId, { featured: body.featured });
const updated = await updateAttachment(attachmentId, params.id, { featured: body.featured });
if (!updated) throw error(404, 'Attachment not found');
return json(updated);
@@ -94,6 +94,6 @@ export const DELETE: RequestHandler = async ({ params, url, ...event }) => {
const attachmentId = url.searchParams.get('attachmentId');
if (!attachmentId) throw error(400, 'attachmentId required');
await deleteAttachment(attachmentId);
await deleteAttachment(attachmentId, params.id);
return json({ success: true });
};
+5 -10
View File
@@ -1,10 +1,10 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { notes } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { reorderNotes } from '$lib/server/notes-service.js';
import { getUserId } from '$lib/server/api-utils.js';
export const POST: RequestHandler = async ({ request }) => {
export const POST: RequestHandler = async ({ request, ...event }) => {
const userId = getUserId(event);
const body = await request.json();
const orders: { id: string; sortOrder: number }[] = body.orders;
@@ -12,11 +12,6 @@ export const POST: RequestHandler = async ({ request }) => {
return json({ error: 'Invalid payload' }, { status: 400 });
}
db.transaction((tx) => {
for (const { id, sortOrder } of orders) {
tx.update(notes).set({ sortOrder }).where(eq(notes.id, id)).run();
}
});
reorderNotes(userId, orders);
return json({ ok: true });
};
@@ -0,0 +1,21 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { listApiKeys, generateApiKey } from '$lib/server/api-keys.js';
import { getUserId } from '$lib/server/api-utils.js';
export const GET: RequestHandler = async (event) => {
const userId = getUserId(event);
return json(listApiKeys(userId));
};
export const POST: RequestHandler = async ({ request, ...event }) => {
const userId = getUserId(event);
const { name } = await request.json();
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return json({ error: 'Name is required' }, { status: 400 });
}
const result = generateApiKey(name.trim(), userId);
return json(result, { status: 201 });
};
@@ -0,0 +1,11 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { deleteApiKey } from '$lib/server/api-keys.js';
import { getUserId } from '$lib/server/api-utils.js';
export const DELETE: RequestHandler = async ({ params, ...event }) => {
const userId = getUserId(event);
const deleted = deleteApiKey(params.id, userId);
if (!deleted) throw error(404, 'API key not found');
return json({ success: true });
};
+190
View File
@@ -0,0 +1,190 @@
import { test as base, expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import { setupAndLogin } from './helpers/fixtures.js';
/**
* MCP E2E tests exercise the full MCP protocol flow:
* 1. Create an API key via the settings API (authenticated via session)
* 2. Use the key to authenticate MCP requests
* 3. Test tool invocations via JSON-RPC over Streamable HTTP
*/
const test = base.extend<{ apiKey: string }>({
apiKey: async ({ page }, use) => {
await setupAndLogin(page);
// Create API key via settings endpoint (session-authenticated)
const res = await page.request.post('/api/settings/api-keys', {
data: { name: 'E2E Test Key' }
});
expect(res.ok()).toBeTruthy();
const { key } = await res.json();
await use(key);
}
});
async function mcpInitialize(page: Page, apiKey: string) {
const res = await page.request.post('/api/mcp', {
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream'
},
data: {
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: { name: 'e2e-test', version: '1.0.0' }
}
}
});
const status = res.status();
const body = await res.text();
if (!res.ok()) {
throw new Error(`MCP initialize failed with status ${status}: ${body.slice(0, 500)}`);
}
const sessionId = res.headers()['mcp-session-id'];
expect(sessionId).toBeTruthy();
// Send initialized notification
await page.request.post('/api/mcp', {
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-session-id': sessionId
},
data: {
jsonrpc: '2.0',
method: 'notifications/initialized'
}
});
return sessionId;
}
async function mcpCallTool(
page: Page,
apiKey: string,
sessionId: string,
name: string,
args: Record<string, unknown> = {},
id = 2
) {
const res = await page.request.post('/api/mcp', {
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-session-id': sessionId
},
data: {
jsonrpc: '2.0',
id,
method: 'tools/call',
params: { name, arguments: args }
}
});
expect(res.ok()).toBeTruthy();
const text = await res.text();
// Response may be SSE format or JSON
if (text.startsWith('event:') || text.startsWith('data:')) {
// Parse SSE - find the last data line with actual content
const lines = text.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6));
if (data.result) return data.result;
}
}
throw new Error('No result found in SSE response');
}
return JSON.parse(text).result;
}
test.describe('MCP Server', () => {
test('Scenario: MCP client can list notes via the protocol', async ({ page, apiKey }) => {
const sessionId = await mcpInitialize(page, apiKey);
// When calling list_notes tool
const result = await mcpCallTool(page, apiKey, sessionId, 'list_notes');
// Then the response contains notes content
expect(result.content).toBeDefined();
expect(result.content[0].type).toBe('text');
const notes = JSON.parse(result.content[0].text);
expect(Array.isArray(notes)).toBe(true);
});
test('Scenario: MCP client can create and retrieve a note', async ({ page, apiKey }) => {
const sessionId = await mcpInitialize(page, apiKey);
// When creating a note via MCP
const createResult = await mcpCallTool(page, apiKey, sessionId, 'create_note', {
title: 'MCP Test Note',
content: 'Created via MCP protocol'
});
const created = JSON.parse(createResult.content[0].text);
expect(created.title).toBe('MCP Test Note');
// Then the note can be retrieved
const getResult = await mcpCallTool(
page,
apiKey,
sessionId,
'get_note',
{ id: created.id },
3
);
const fetched = JSON.parse(getResult.content[0].text);
expect(fetched.title).toBe('MCP Test Note');
expect(fetched.content).toBe('Created via MCP protocol');
});
test('Scenario: MCP client can search notes', async ({ page, apiKey }) => {
const sessionId = await mcpInitialize(page, apiKey);
// Given a note with specific content exists
await mcpCallTool(page, apiKey, sessionId, 'create_note', {
title: 'Searchable Note',
content: 'unique-search-term-xyz'
});
// When searching for it
const searchResult = await mcpCallTool(
page,
apiKey,
sessionId,
'search_notes',
{ query: 'unique-search-term-xyz' },
3
);
const results = JSON.parse(searchResult.content[0].text);
// Then it appears in results
expect(results.length).toBeGreaterThan(0);
expect(results[0].title).toBe('Searchable Note');
});
test('Scenario: MCP endpoint rejects requests without API key', async ({ page }) => {
const res = await page.request.post('/api/mcp', {
headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' },
data: {
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: { name: 'test', version: '1.0.0' }
}
}
});
expect(res.status()).toBe(401);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { test, expect } from './helpers/fixtures.js';
test.describe('Settings — API Key Management', () => {
test('Scenario: Created API key appears in the keys list', async ({
authenticatedPage: page
}) => {
// When the user navigates to the MCP settings and creates an API key
await page.goto('/settings/mcp');
await page.getByTestId('api-key-name-input').fill('Settings Test Key');
await page.getByTestId('create-api-key-btn').click();
// Then the key is shown once for copying
await expect(page.getByTestId('created-key-display')).toBeVisible();
const keyValue = await page.getByTestId('created-key-value').textContent();
expect(keyValue).toMatch(/^crumbs_/);
// And it appears in the keys list
const keyItem = page.getByTestId('api-key-item').filter({ hasText: 'Settings Test Key' });
await expect(keyItem).toBeVisible();
});
test('Scenario: Revoked API key disappears from the keys list', async ({
authenticatedPage: page
}) => {
// Given an API key exists
await page.goto('/settings/mcp');
await page.getByTestId('api-key-name-input').fill('Revoke Me');
await page.getByTestId('create-api-key-btn').click();
await expect(page.getByTestId('created-key-display')).toBeVisible();
const keyItem = page.getByTestId('api-key-item').filter({ hasText: 'Revoke Me' });
await expect(keyItem).toBeVisible();
// When the user revokes the key
await keyItem.getByTestId('delete-api-key-btn').click();
await keyItem.getByTestId('confirm-delete-btn').click();
// Then the key is removed from the list
await expect(keyItem).not.toBeVisible();
});
test('Scenario: Settings page is accessible from sidebar', async ({
authenticatedPage: page
}) => {
// Given the user is on the main page
// When the user clicks the Settings link in the sidebar
await page.getByTestId('settings-link').click();
// Then the settings page is shown
await expect(page).toHaveURL('/settings');
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
});
test('Scenario: Settings subpages are navigable', async ({
authenticatedPage: page
}) => {
// Given the user is on the settings page
await page.goto('/settings');
// When the user clicks the MCP Server nav link
await page.getByRole('link', { name: 'MCP Server' }).click();
// Then the MCP settings page is shown
await expect(page).toHaveURL('/settings/mcp');
await expect(page.getByRole('heading', { name: 'API Keys' })).toBeVisible();
// When the user clicks back to Profile
await page.getByRole('link', { name: 'Profile' }).click();
await expect(page).toHaveURL('/settings');
});
});
+28 -1
View File
@@ -135,6 +135,33 @@
<h3>Full-Text Search</h3>
<p>Find anything instantly. SQLite-powered search across all your notes and tags.</p>
</div>
<!-- MCP / AI Integration -->
<div class="feature-card animate-in delay-1">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M9 9h6v6H9z"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg>
</div>
<h3>MCP Server</h3>
<p>Built-in AI integration. Connect Claude, Cursor, or any MCP client to read and write your notes.</p>
</div>
<!-- Multi-User -->
<div class="feature-card animate-in delay-2">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<h3>Multi-User</h3>
<p>Share one instance with your team or family. Admin manages accounts, each user gets their own private notes.</p>
</div>
<!-- OAuth / SSO -->
<div class="feature-card animate-in delay-3">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
</div>
<h3>OAuth & SSO</h3>
<p>Sign in with Google, GitHub, or any OIDC provider. Or just use a password — your choice.</p>
</div>
</div>
</div>
</section>
@@ -222,7 +249,7 @@
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
</ul>
<span class="footer-version">v0.8.1</span>
<span class="footer-version">v0.9.0</span>
</div>
</footer>