Peek Server #
Webhook server for the Peek mobile app. Receives URLs, texts, tagsets, and images from the mobile app and stores them in SQLite. Built with Hono running on Node.js, designed for deployment on Railway.
Commands #
# Dependencies: run yarn install at the repo root. apps/server is a yarn workspace, and npm install inside it rewrites the root yarn.lock.
# From this directory (apps/server/)
npm start # Run the production server
npm run dev # Run with file watching (auto-restart on changes)
npm test # Run the test suite
# From project root
yarn server:start # Run the production server
yarn server:dev # Run with file watching
yarn server:test # Run the test suite
Important: Run tests after making changes to verify nothing is broken.
Architecture #
- index.js - Hono HTTP server with API endpoints
- db.js - SQLite database module (node:sqlite)
- users.js - Multi-user authentication with API keys
API Endpoints #
GET /- Health checkPOST /webhook- Receive items from mobile app ({ urls: [...], texts: [...], tagsets: [...] })
URLs
GET /urls- List all saved URLs with tagsDELETE /urls/:id- Delete a URLPATCH /urls/:id/tags- Update tags for a URL
Texts
POST /texts- Create a text itemGET /texts- List all textsDELETE /texts/:id- Delete a textPATCH /texts/:id/tags- Update tags
Tagsets
POST /tagsets- Create a tagsetGET /tagsets- List all tagsetsDELETE /tagsets/:id- Delete a tagsetPATCH /tagsets/:id/tags- Update tags
Images
POST /images- Upload an image (multipart or base64)GET /images- List all imagesGET /images/:id- Get image fileDELETE /images/:id- Delete an imagePATCH /images/:id/tags- Update tags
Unified Items
POST /items- Create any item typePOST /items/batch- Create/update up to 500 items in one transaction, with a per-row resultGET /items- List items (optional?type=filter)DELETE /items/:id- Delete an itemPATCH /items/:id/tags- Update tags
Sync pulls
GET /items/since/:timestamp,GET /events/since/:timestamp,GET /tags/since/:timestamp- Each returns the whole matching set unless the client sends
limit(1..1000), in which case the response carries anextCursorto pass back ascursorfor the following page. A client that sends neither gets the unpaged response, so paging costs nothing in compatibility either direction. Seedocs/sync.mdfor the full contract.
Tags
GET /tags- List tags sorted by frecency
Database Schema #
Multi-user SQLite databases (one per user):
items- Unified table for URLs, texts, tagsets, imagestags- Tag names with frecency scoringitem_tags- Many-to-many junction tablesettings- Key-value configuration
System database:
users- User IDs and hashed API keys
Database stored in ./data/{userId}/peek.db. Override with DATA_DIR env var.
Authentication #
All endpoints except / require Bearer token authentication:
Authorization: Bearer <api_key>
API keys are hashed with SHA-256 and stored in the system database.
MCP Item-Event Routes #
Four routes under /mcp/* record and read the event history attached to one item (the
item_events table backing series/feed items — see docs/feeds-api.md). They authenticate
with a grant credential, not the API key above: Authorization: Bearer <grant token> (minted by
peek-mcp-init, see docs/mcp-remote-backend-design.md §6). A grant confines every /mcp/*
route to one scope tag and can be marked read-only; a read-only grant gets 403 from all three
writes below (POST .../events, DELETE .../events/:eventId, DELETE .../events). Business
logic lives in apps/server/mcp-items.js (recordEvent(), listEvents(), deleteEvent(),
deleteEvents()); routes are registered in apps/server/index.js.
POST /mcp/items/:id/events — record an event #
curl -X POST "$PEEK_URL/mcp/items/$ITEM_ID/events" \
-H "Authorization: Bearer $PEEK_MCP_TOKEN" \
-H "Content-Type: application/json" \
-d '{"type": "started", "value": "kicked off"}'
# 200 {"success":true,"id":"<eventId>","itemId":"<id>","type":"started"}
type is required on every call, even when content is also supplied — a call with only
content/occurredAt/metadata and no type gets 400.
There is no type column on item_events. The row has one column, content, storing
content ?? type — a call that supplies content keeps it and the type value is not stored
anywhere; it exists only to satisfy the required field and to echo back in this response's
type. A caller that needs its events distinguishable by kind later must carry that label
inside metadata instead (mcp-items.js recordEvent()).
Legacy shape — {type, value} only. Supplying nothing else writes exactly the old row:
content = type, value = 0, metadata = {"notes": value} (or {} when value is empty).
apps/server/test.js pins this byte-for-byte.
New shape — add content, occurredAt, and/or metadata. Each stores directly into the
column of the same name, unwrapped:
curl -X POST "$PEEK_URL/mcp/items/$ITEM_ID/events" \
-H "Authorization: Bearer $PEEK_MCP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"type": "feed_entry",
"content": "https://example.com/article",
"occurredAt": 1700000000000,
"metadata": {"guid": "abc123", "title": "An article"}
}'
Supplying metadata also switches what value means: instead of being wrapped into
metadata.notes as free text, it is stored as a real number in the value column. A numeric
string ("42") is coerced; an empty or absent value defaults to 0; anything else non-numeric
(e.g. "kicked off") is refused with 400 and no row is written — it is not silently coerced to
0, since that failure mode would otherwise hide real text on exactly the request shape a caller
migrating off {type, value} would plausibly send.
Status codes: 200 on success, 404 if the item doesn't exist (or is out of the grant's scope),
400 for a missing type or a non-numeric value alongside metadata, 403 for a read-only
grant.
GET /mcp/items/:id/events?limit=&offset= — paged read of an item's history #
curl "$PEEK_URL/mcp/items/$ITEM_ID/events?limit=50&offset=0" \
-H "Authorization: Bearer $PEEK_MCP_TOKEN"
# 200 {"events":[{"id":"...","content":"...","value":0,"occurredAt":1700000000000,"metadata":"{\"guid\":\"abc123\"}"}, ...]}
Rows come back ordered occurredAt DESC. limit defaults to 50, capped at 200; offset
pages through the rest. There is no since/until/order parameter — apply those to the rows
you read back client-side.
metadata comes back as a raw JSON string, not parsed — JSON.parse() it yourself. (An item's
own metadata field, by contrast, comes back already parsed; events do not.)
The shape trap. This is the one route that returns the content column under its own name.
The other read of an item's events — GET /mcp/items/:idOrPrefix?events=N (the events window
embedded in a get_item response) — still returns the same column aliased to type, a holdover
from before content was a field callers could set, and it is hard-capped at 20 rows no matter
what N asks for (mcp-items.js fetchEvents()). Two different reads of the same column under
two different names: use this route, not the embedded one, when you need more than 20 rows or
need the real content value rather than whatever ends up under type.
Status codes: 200 always, unless the item doesn't exist or is out of scope (404).
DELETE /mcp/items/:id/events/:eventId — remove one event #
curl -X DELETE "$PEEK_URL/mcp/items/$ITEM_ID/events/$EVENT_ID" \
-H "Authorization: Bearer $PEEK_MCP_TOKEN"
# 200 {"success":true,"id":"<eventId>","itemId":"<id>"}
A hard delete — item_events has no deletedAt column to soft-delete into. An eventId that
doesn't exist under this item is not a 404: it comes back 200 {"success":false,"error":"Event not found"}, the same way a missing tag does on untag_item. Only a missing/out-of-scope item
gets 404.
DELETE /mcp/items/:id/events — remove every event under an item #
curl -X DELETE "$PEEK_URL/mcp/items/$ITEM_ID/events" \
-H "Authorization: Bearer $PEEK_MCP_TOKEN"
# 200 {"success":true,"itemId":"<id>","deleted":3}
Also a hard delete, reporting the count removed.
Deleting an item does not delete its events. DELETE /mcp/items/:id is a soft delete and
deliberately does not touch item_events (mcp-items.js deleteItem()). A soft-deleted item is
still reachable by its exact id — fetchItemRowById() does not filter deletedAt — so these
routes still reach its events afterwards. What it stops appearing in is list_items and
search_items, which do filter it, so an id not captured beforehand cannot be recovered and the
events become unreachable. Clear the events first, or keep the id.
Deployment #
Configured for Railway (railway.json) using Nixpacks builder with npm. Do not add yarn.lock to this directory — Nixpacks will switch to yarn and fail.
Deploys are manual, uploaded straight to Railway. See docs/server-backup-and-deploy.md at the
repository root for the procedure and its constraints.
Quick setup:
- Attach a volume and set
DATA_DIRto the mount path for persistent storage - Create users and their API keys (see AGENTS.md for commands)
Testing #
npm test # Run unit tests
npm run test:api:local # Test against local server (needs PEEK_LOCAL_KEY env var)
npm run test:api:prod # Test against production (needs PEEK_PROD_KEY, PEEK_PROD_URL)
Environment Variables #
PORT- Server port (default: 3000)DATA_DIR- Data directory for SQLite databases (default:./data)API_KEY- Legacy single-user API key (auto-migrates to multi-user system)TRUSTED_PROXY_HOPS- Reverse-proxy hops in front of the server that append toX-Forwarded-For(default:1, matching a Railway deployment). Set to0when self-hosting with no reverse proxy; otherwise a client can forge the header and choose its own/enrolrate-limit bucket