You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Scopes: projects:read, projects:write, manuscript:read, manuscript:write, entities:read, entities:write, stats:read, profile:read. Entity routes choose by collection: content needs manuscript:*, every other collection entities:*.
Default-deny: a key reaches only routes that declare an API-key policy (ApiKeyPolicy in middleware/auth.ts); every other route answers 403 {"error": "Session auth required"}. The complete list is KEY_ROUTES in apps/api/src/routes/__tests__/api-key-policy.test.ts. On public optionalAuth routes, a key the route doesn't admit is served as anonymous. A key restricted to one project can't create projects or publish templates.
Rate limits: 100 req/min (free), 500 req/min (supporter). Keyed per API key (vs per-IP for browser sessions).
Internal
Server-to-server calls authenticated via X-Bobbins-Secret header.
API Endpoints
All routes are prefixed with /api unless noted. Authentication is JWT-based (session) or API key (bby_ prefix, scoped and default-deny — see API Keys).
Health & Internal
Method
Path
Auth
Description
GET
/health
None
Health check with DB connectivity
GET
/internal/metrics
Internal
Metrics snapshot (requires X-Bobbins-Secret)
Authentication (auth.ts)
Method
Path
Auth
Description
POST
/api/auth/signup
None
Register with email/password
POST
/api/auth/login
None
Login, returns JWT + 2FA status
GET
/api/auth/session
JWT only
Current user session info
GET
/api/auth/verify-email
None
Verify email via token
POST
/api/auth/resend-verification
JWT
Resend verification email
POST
/api/auth/forgot-password
None
Request password reset
POST
/api/auth/reset-password
None
Reset password with token
POST
/api/auth/totp/setup
JWT
Generate TOTP secret + QR
POST
/api/auth/totp/enable
JWT
Enable 2FA
POST
/api/auth/totp/disable
JWT
Disable 2FA
POST
/api/auth/totp/verify
None
Verify 2FA code during login
Projects (projects.ts)
Method
Path
Auth
Scope
Description
GET
/api/projects
JWT/Key
projects:read
List user's projects
GET
/api/projects/:projectId
JWT/Key
projects:read
Get project details (owner)
POST
/api/projects
JWT
—
Create project
PUT
/api/projects/:projectId
JWT
—
Update project
POST
/api/projects/:projectId/bobbins/install
JWT
—
Install bobbin to project
GET
/api/projects/:projectId/bobbins
JWT
—
List project bobbins
DELETE
/api/projects/:projectId/bobbins/:bobbinId
JWT
—
Uninstall bobbin
Entities (entities.ts)
Method
Path
Auth
Scope
Description
GET
/api/collections/:collection/entities
JWT/Key
manuscript:read / entities:read
Query entities (requires ?projectId=)
GET
/api/entities/:entityId
JWT/Key
manuscript:read / entities:read
Get entity (requires ?projectId=&collection=)
HEAD
/api/entities/:entityId
JWT/Key
manuscript:read / entities:read
Version check (lightweight)
POST
/api/entities
JWT
—
Create entity
PUT
/api/entities/:entityId
JWT
—
Update entity (optimistic locking)
DELETE
/api/entities/:entityId
JWT
—
Delete entity
POST
/api/entities/batch/atomic
JWT
—
Atomic batch create/update/delete
Query parameters for GET /api/collections/:collection/entities:
Param
Type
Default
Description
projectId
UUID
required
Filter by project
limit
number
50
Results per page (1–5000)
offset
number
0
Pagination offset
search
string
—
Full-text search across title, name, and entity data
filters
JSON string
—
Field-level filters against entity data, e.g. {"status":"draft"}
Entity response shape: Entity data fields are spread at the top level, with metadata in a _meta envelope:
Deleting is reversible.DELETE /entities/:id and POST /entities/bulk-delete move rows to the trash rather than removing them; they are purged after 30 days. Deleting a container trashes everything inside it under one batchId, and restoring any member restores the whole batch. Comments, annotations, publications and reader slugs survive the window and come back on restore.
Trash (entities.ts)
Method
Path
Auth
Scope
Description
GET
/projects/:projectId/trash
Owner
session only
Trashed entities (metadata only) with autoDeleteAt
POST
/entities/bulk-untrash
Owner
session only
Restore; expands to whole batches
POST
/entities/bulk-delete-permanent
Owner
session only
Delete forever. Only touches already-trashed rows
Change Feed (entity-changes.ts)
GET /projects/:projectId/changes — cursor-based feed of everything that changed, recorded at write time. Poll this instead of reconstructing changes from updatedAt.
Query
Default
Description
since
—
Cursor from the previous poll. Omit for bootstrap: returns the current cursor and no changes
collection
all
Restrict to one collection, e.g. content
coalesce
true
true → one entry per entity (changes); false → raw event rows (events)
wordsAdded / wordsRemoved are churn. They deliberately double-count a write-then-cut, because that is what separates "spent an hour rewriting" from "did nothing" — wordCountDelta alone cannot. Do not graph them as productivity. The invariant wordsAdded − wordsRemoved === wordCountDelta always holds, including across creates and deletes, and because they are additive you may also sum them across pages.
0 and null differ: 0 means computed-and-unchanged, null means not computed (non-content entity, oversized body, or a row predating this feature). Historical rows are permanently null.
lifecycle is trashed | untrashed | purged | archived | unarchived, or null for an ordinary edit. action stays created | updated | deleted so existing consumers keep working: a trash arrives as deleted, a restore as created. A trash and a restore inside one window net out to the entity still existing, not to deleted.
sources lists non-edit origins seen in the window (restore, import, search_replace, publish, system). Without it a restore looks identical to a large paste.
revisionIdFirst is the restore point holding the state at the start of the window — the from for the diff endpoint below.
Cursor safety: seq is a bigserial allocated before commit, so the high-water mark is held back by a short horizon (15s) and the feed does not serve the last few seconds of activity. Persist the cursor only after you have finished processing a page.
Revisions & Diff (entity-revisions.ts)
Restore points for chapter content, captured once per ~15-minute writing session. Each revision holds the state before the session it names, so diff(revision → live) is exactly "what changed since then".
Method
Path
Auth
Description
GET
/entities/:entityId/revisions
Owner
Timeline, metadata only (limit, before)
GET
/entities/:entityId/revisions/:revisionId
Owner
One snapshot's restorable fields
POST
/entities/:entityId/revisions
Owner
Pin a manual checkpoint ({ note? })
POST
/entities/:entityId/revisions/:revisionId/restore
Owner
Restore ({ expectedVersion? })
GET
/entities/:entityId/diff
Owner
?from=<revisionId>&to=<revisionId|live>&maxHunks=
Restore is an ordinary edit: it moves versionforward through the same optimistic-lock CAS the editor uses, checkpoints the current state first (so it is itself undoable), and merges through an allowlist (body, title, notes, synopsis) — reverting text never moves a chapter or reverts its publish state. The response includes _meta.version; an editor must adopt it, or its in-flight autosave will overwrite the restore.
Diff response:
{
"stats": { "wordsAdded": 6, "wordsRemoved": 7,
"paragraphs": { "added": 0, "removed": 0, "modified": 1 } },
"hunks": [{ "type": "replace", "index": 0,
"before": "Garron drew his blade and lunged at the succubus.",
"after": "Garron leaned in and kissed the succubus." }],
"truncated": false
}
truncated: true means output was capped (oversized body, too many hunks, or the diff timed out) — stats stays exact regardless. A pruned restore point returns 404 with code: "REVISION_PRUNED", distinct from a missing entity.
Retention: both tiers keep every restore point for 30 days. Past that, free keeps labeled checkpoints only (cap 100/entity); supporters keep one per day to a year and one per week beyond (cap 1000). Labeled checkpoints — publish, import, search-replace, pre-restore, manual — are never thinned. A lapsed supporter keeps supporter depth for a further 30 days.
Typical consumer flow — detect a revision pass and report what changed:
# 1. What changed since the last poll
curl -H "Authorization: Bearer $KEY" \
"$API/api/projects/$PID/changes?since=$CURSOR&collection=content"# 2. Classify: wordsAdded >> wordsRemoved -> new writing;# both large with delta ~0 -> revision pass; sources contains 'restore' -> rollback# 3. For the ones that qualify, ask what the prose change actually was
curl -H "Authorization: Bearer $KEY" \
"$API/api/entities/$EID/diff?from=$REVISION_ID_FIRST&to=live"
Entity Types (entity-types.ts)
First-class CRUD for custom entity type definitions (schemas like "characters", "spells"). These are the schemas that define what fields an entity of a given type has. Under the hood they're stored in the entity_type_definitions collection.
Method
Path
Auth
Scope
Description
GET
/api/projects/:projectId/entity-types
JWT/Key
entities:read
List all type defs for a project
GET
/api/projects/:projectId/entity-types/:typeId
JWT/Key
entities:read
Get a type def (full schema)
POST
/api/projects/:projectId/entity-types
JWT/Key
entities:write
Create a type def
PUT
/api/projects/:projectId/entity-types/:typeId
JWT/Key
entities:write
Update (type_id immutable; bumps schema_version on field change)
DELETE
/api/projects/:projectId/entity-types/:typeId
JWT/Key
entities:write
Delete (does NOT cascade to entities)
Request body for POST / PUT (PUT accepts a partial body, type_id is ignored):
type_id must match /^[a-z][a-z0-9_]{0,63}$/ and is unique per project (409 on conflict). The server manages schema_version (starts at 1) and appends to _field_history when custom_fields change.
# Resolve by slug first
curl "https://api.bobbinry.com/api/public/projects/by-slug/quantum-error"# Then read TOC
curl "https://api.bobbinry.com/api/public/projects/<projectId>/toc"# Then read a chapter
curl "https://api.bobbinry.com/api/public/projects/<projectId>/chapters/<chapterId>"
Rate limits
Responses include X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers. When exceeded, returns 429 with Retry-After.
A route that doesn't admit API keys, or a key missing the route's scope, answers 403:
{"error": "Session auth required", "message": "This endpoint requires session authentication and cannot be accessed with an API key"}
{"error": "Insufficient scope", "message": "This API key does not have the 'manuscript:write' scope"}
Database
ORM: Drizzle with PostgreSQL
Schema: apps/api/src/db/schema.ts
Migrations: infra/db/migrations/ — auto-run on startup via runMigrations()
Generate migration: cd apps/api && bunx drizzle-kit generate --name <name>