Initial implementation

This commit is contained in:
2026-08-19 17:18:45 +01:00
commit f411336b04
34 changed files with 7111 additions and 0 deletions
+391
View File
@@ -0,0 +1,391 @@
# MVP API Draft
Status: implemented MVP contract draft. REST remains normative; contract hardening and generated OpenAPI documentation may continue before a `1.0` compatibility freeze.
This API is informed by the official [Joplin Terminal documentation](https://joplinapp.org/help/apps/terminal/), [Joplin Data API](https://joplinapp.org/help/api/references/rest_api/), and [Joplin synchronisation specification](https://joplinapp.org/help/dev/spec/sync/).
## Boundary
The public API is a permission-filtered gateway over one dedicated Joplin CLI profile. It is not the Joplin Server API and it does not expose arbitrary shell or CLI command execution.
Joplin CLI includes an experimental local `server` command that exposes the Joplin Data API. That facility may be useful inside the gateway, but it is not the public contract. The adapter mechanism may change without changing this API.
The gateway exposes:
* REST under `/api/v1`;
* GraphQL at `/graphql`;
* OAuth-style bearer-token authentication.
REST and GraphQL expose the same authorised Joplin capabilities. GraphQL subscriptions are not part of the MVP.
## Common Behaviour
### Authentication
Every endpoint except liveness and token issuance requires:
```http
Authorization: Bearer <access-token>
```
Access tokens represent stable application clients, not people or sessions. Joplin Server credentials and Joplin E2EE keys are never exposed to gateway clients.
The MVP token flow is OAuth 2-style client credentials:
```http
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=...&client_secret=...
```
```json
{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 3600
}
```
An operator creates client IDs, login secrets, enabled state, and permissions in administrator-owned JSON configuration, then restarts the gateway container to apply changes. This configuration is read-only to the gateway. Gateway-generated durable state is stored in a separate gateway-managed JSON file. Human gateway login, self-registration, authorisation-code grants, refresh tokens, and an administration API are outside the MVP.
The implementation issues HMAC-signed JWT bearer tokens with a configurable lifetime (one hour by default). The signing key is generated at process start, so restarting invalidates every outstanding token. There is no self-service revocation endpoint in the MVP. Disabling or removing a client and restarting the container therefore causes its next request to be rejected even if its previous token had not expired.
### Synchronisation transaction
Every operation that reads or changes Joplin data runs inside the single profile queue:
1. synchronise the managed CLI profile with Joplin Server;
2. re-evaluate authorisation against the newly synchronised notebook hierarchy;
3. execute the read or mutation against the local Joplin profile;
4. after a local mutation, synchronise again;
5. return the response.
A GraphQL document receives one pre-operation sync. If it contains mutations, they execute serially and the gateway completes a post-operation sync before returning.
Liveness checks and token issuance do not read Joplin data and do not trigger a sync.
If the pre-operation sync fails, the gateway returns `503 SYNC_UNAVAILABLE` and does not execute the operation. It never silently serves stale Joplin data in the MVP.
If a local mutation succeeds but the post-operation sync fails, the gateway returns a degraded success. For REST the status is `202 Accepted`; GraphQL returns mutation data without a transport error. REST adds `sync` beside the returned object's fields, and GraphQL mutation result types expose a `sync` field. Both use the following status shape:
```json
{
"sync": {
"status": "pending",
"local_change_applied": true,
"operation_id": "...",
"warning": "The change is local and has not been confirmed on Joplin Server"
}
}
```
The gateway retains the local change and retries sync periodically. Other gateway clients share that local profile, but human Joplin clients cannot see the change until it reaches Joplin Server. Humans may meanwhile change the same content, potentially causing normal Joplin conflicts when sync resumes.
Create requests should carry an `Idempotency-Key` so ambiguous operations can be retried safely. The exact operation-recovery record remains to be specified, but partial success must never be hidden.
### Joplin representation
The API preserves native Joplin concepts and identifiers:
* a notebook is called `notebook` publicly, although Joplin calls it `folder` internally;
* Joplin 32-character IDs are exposed directly;
* note bodies contain normal Joplin Markdown;
* timestamps are Unix time in milliseconds;
* Joplin property names use `snake_case` in REST and GraphQL;
* successful mutations return the resulting Joplin object after the post-operation sync.
Internal and security-sensitive fields such as encrypted payloads, master keys, sync internals, and gateway configuration are never exposed.
The MVP neither accepts attachment/image uploads nor exposes HTML-specific note creation fields. Joplin internal note links such as `[Label](:/note-id)` remain unchanged inside Markdown bodies. Retrieving the linked target is a separate authorised operation and returns not found when the target is inaccessible.
### Pagination
REST collection endpoints accept:
* `page`, starting at `1`;
* `limit`, default `50`, maximum `100`;
* `order_by`, restricted to fields supported by that resource;
* `order_dir`, either `ASC` or `DESC`.
They return:
```json
{
"items": [],
"page": 1,
"limit": 50,
"has_more": false
}
```
The changes feed uses its own cursor instead of page numbers. GraphQL collections use connection objects with equivalent `items` and `page_info` fields; Relay compatibility is not required for the MVP.
### Errors and non-disclosure
REST errors use a stable machine-readable envelope:
```json
{
"error": {
"code": "NOTE_NOT_FOUND",
"message": "Note not found",
"request_id": "...",
"details": {}
}
}
```
GraphQL reports the same `code`, `request_id`, and optional `details` under `errors[].extensions`.
Expected HTTP meanings are:
| Status | Meaning |
| --- | --- |
| `400` | Invalid input, filter, or pagination request |
| `401` | Missing, invalid, expired, or disabled-client token |
| `403` | The object is visible, but the client lacks the required capability |
| `404` | The object is absent or must be hidden from this client |
| `409` | A request conflicts with current Joplin state |
| `422` | Semantically invalid Joplin content or relationship |
| `503` | The pre-operation synchronisation failed, so no content operation ran |
An inaccessible object returns the same `404` shape as a nonexistent object. Search, counts, pagination metadata, tag relationships, and changes must not reveal inaccessible notes.
## REST Endpoints
### Service and identity
| Method | Path | Purpose | Synchronises |
| --- | --- | --- | --- |
| `GET` | `/health/live` | Process liveness only; returns no Joplin or client data | No |
| `POST` | `/oauth/token` | Exchange configured client credentials for an access token | No |
| `GET` | `/api/v1/me` | Return the authenticated stable client ID and its effective gateway permissions | No |
`GET /api/v1/me` never returns secrets, Server credentials, or inaccessible notebook metadata.
### Notebooks
| Method | Path | Capability |
| --- | --- | --- |
| `GET` | `/api/v1/notebooks` | Return the visible notebook hierarchy |
| `GET` | `/api/v1/notebooks/{notebook_id}` | Get a visible notebook or an authorised path-only ancestor |
| `GET` | `/api/v1/notebooks/{notebook_id}/notes` | List visible notes directly inside a notebook |
| `POST` | `/api/v1/notebooks` | Create a top-level or descendant notebook |
`GET /notebooks` returns accessible notebooks and the minimum ancestor stubs needed to represent their paths. A path-only ancestor contains `id`, `title`, `parent_id`, `children`, and `access: "path_only"`; it contains no note counts or content-derived metadata.
Notebook creation body:
```json
{
"title": "Project notes",
"parent_id": "optional-parent-joplin-id"
}
```
Creating any notebook requires the global `create_notebooks` capability. If `parent_id` is present, the parent must also be visible to the client. Creating a child beneath a read-only hierarchy is permitted by the current product specification, but the inherited child remains read-only to that client. Creating a top-level notebook writes a durable automatic read/write grant to the gateway-managed state file.
Notebook update, rename, move, trash, and permanent deletion endpoints do not exist in the MVP.
### Notes
| Method | Path | Capability |
| --- | --- | --- |
| `GET` | `/api/v1/notes` | List notes visible to the client |
| `POST` | `/api/v1/notes` | Create a note in a writable notebook |
| `GET` | `/api/v1/notes/{note_id}` | Get one visible note |
| `PATCH` | `/api/v1/notes/{note_id}` | Edit a note or move it between writable notebook trees |
| `DELETE` | `/api/v1/notes/{note_id}` | Move a note to Joplin trash |
| `GET` | `/api/v1/notes/{note_id}/tags` | List the note's tags |
| `PUT` | `/api/v1/notes/{note_id}/tags/{tag_id}` | Apply an existing tag to a writable note |
| `DELETE` | `/api/v1/notes/{note_id}/tags/{tag_id}` | Remove a tag from a writable note |
`GET /notes` optionally accepts `notebook_id`. When omitted it searches all notebook trees visible to the client. `GET /notebooks/{id}/notes` lists direct members only; descendant notebooks are navigated explicitly.
Create note body:
```json
{
"parent_id": "required-writable-notebook-id",
"title": "Meeting notes",
"body": "Markdown content"
}
```
The gateway never relies on Joplin CLI's mutable “current notebook”; `parent_id` is required for deterministic multi-client behaviour.
Writable note fields confirmed for the MVP are:
* `parent_id`;
* `title`;
* `body`, containing Markdown/plain text.
The implemented writable note fields are exactly `parent_id`, `title`, and `body`. To-do fields, attachment/image fields, HTML-specific input, encrypted fields, sharing fields, sync internals, and other Joplin metadata are not writable in the MVP.
To-dos and Joplin conflict notes are entirely hidden from MVP retrieval, listing, search, trash, history, and change-feed endpoints. Managing either remains a human operation in Joplin clients until its gateway capability is introduced post-MVP.
Moving a note by changing `parent_id` requires write access to both its current and destination notebook hierarchies.
`PATCH /notes/{id}` may include `expected_updated_time`. After the mandatory pre-operation sync, a mismatch returns `409 NOTE_CHANGED` without applying the update. Omitting it permits normal unguarded Joplin write/conflict behaviour.
`DELETE /notes/{id}` uses normal reversible Joplin trash behaviour. Permanent deletion and deletion of note revisions are not available in the MVP.
### Trash
| Method | Path | Capability |
| --- | --- | --- |
| `GET` | `/api/v1/trash/notes` | List trashed notes from readable notebook trees |
| `GET` | `/api/v1/trash/notes/{note_id}` | Read one authorised trashed note |
| `POST` | `/api/v1/trash/notes/{note_id}/restore` | Restore a trashed note to its original notebook |
Joplin normally restores a trashed note to its original notebook. Reading a trashed note requires read access to that notebook; restoration requires write access. If the original notebook no longer exists, the gateway returns `409 RESTORE_DESTINATION_MISSING` without restoring it; a human must resolve the destination in a standard Joplin client.
### Note history
| Method | Path | Capability |
| --- | --- | --- |
| `GET` | `/api/v1/notes/{note_id}/revisions` | List the note's available historical versions |
| `GET` | `/api/v1/notes/{note_id}/revisions/{revision_id}` | Return a reconstructed historical note snapshot |
| `POST` | `/api/v1/notes/{note_id}/revisions/{revision_id}/restore` | Restore that version as a new note in an explicitly selected notebook |
Revision access follows current read permission on the note's notebook. Restoration requires a body containing `{ "parent_id": "existing-writable-notebook-id" }` and write permission on that destination. The gateway returns reconstructed note versions, not Joplin's internal diff records, and clients cannot create, edit, or delete revisions directly.
Revision restoration is non-destructive: it creates a new restored note in the selected notebook rather than replacing the current one. History availability and retention depend on Joplin's revision service; the API does not promise that every edit has a retained revision.
### Tags
| Method | Path | Capability |
| --- | --- | --- |
| `GET` | `/api/v1/tags` | List global tags |
| `GET` | `/api/v1/tags/{tag_id}` | Get a global tag |
| `GET` | `/api/v1/tags/{tag_id}/notes` | List only visible notes carrying the tag |
| `POST` | `/api/v1/tags` | Create a global tag |
Any authenticated client may list and view tags. `POST /tags` requires `create_tags` and accepts `{ "title": "tag-name" }`. Applying or removing a tag uses the note endpoints and requires write access to that note.
Tag rename and deletion are not part of the MVP. Tag note counts, if returned at all, count only notes visible to the requesting client.
### Search
| Method | Path | Capability |
| --- | --- | --- |
| `GET` | `/api/v1/search?query=...` | Search visible notes using Joplin search syntax |
The endpoint accepts the standard collection pagination and sorting parameters. It searches notes only in the MVP. Results are permission-filtered before they are counted or returned.
The gateway must ensure inaccessible Joplin search hits cannot affect exposed totals, snippets, ranking details, or pagination metadata.
### Recent changes
| Method | Path | Capability |
| --- | --- | --- |
| `GET` | `/api/v1/changes` | Establish a cursor at the current visible change position |
| `GET` | `/api/v1/changes?cursor=...&limit=...` | Poll visible note changes after a cursor |
The MVP feed reflects the Joplin Data API's current event capability: note changes only. Each item contains:
```json
{
"id": 123,
"item_type": "note",
"item_id": "joplin-note-id",
"type": "created",
"created_time": 1760000000000
}
```
The response contains `items`, `cursor`, and `has_more`. A request without a cursor establishes a baseline and does not return historical events. Cursors are opaque to clients.
The upstream Joplin Data API retains events for a limited period (currently documented as up to 90 days). An expired or invalid cursor returns `409 CHANGE_CURSOR_INVALID`, requiring the client to re-list the visible state and establish a new cursor.
Every poll applies the client's current permissions. Events for inaccessible, newly inaccessible, or otherwise non-disclosable notes are omitted without revealing their existence.
The event envelope and cursor are gateway contracts rather than WebSocket-specific concepts. This allows a post-MVP WebSocket or subscription service to reuse the same stream and let disconnected clients resume without making the persistent connection itself a delivery guarantee.
## GraphQL Surface
The MVP GraphQL endpoint is `POST /graphql`. It supports queries and mutations, but not subscriptions or schema mutation by clients.
Proposed root queries:
```graphql
type Query {
me: Client!
notebooks: [Notebook!]!
notebook(id: ID!): Notebook
notes(filter: NoteFilter, page: Int = 1, limit: Int = 50,
order_by: NoteOrderField, order_dir: OrderDirection): NotePage!
note(id: ID!): Note
tags(page: Int = 1, limit: Int = 50): TagPage!
tag(id: ID!): Tag
search(query: String!, page: Int = 1, limit: Int = 50,
order_by: NoteOrderField, order_dir: OrderDirection): NotePage!
changes(cursor: String, limit: Int = 50): ChangePage!
trashed_notes(page: Int = 1, limit: Int = 50): NotePage!
trashed_note(id: ID!): Note
note_revisions(note_id: ID!, page: Int = 1, limit: Int = 50): RevisionPage!
note_revision(note_id: ID!, revision_id: ID!): NoteRevision
}
```
Proposed root mutations:
```graphql
type Mutation {
create_notebook(input: CreateNotebookInput!): Notebook!
create_note(input: CreateNoteInput!): Note!
update_note(id: ID!, input: UpdateNoteInput!): Note!
delete_note(id: ID!): DeletedNote!
create_tag(input: CreateTagInput!): Tag!
add_tag_to_note(note_id: ID!, tag_id: ID!): Note!
remove_tag_from_note(note_id: ID!, tag_id: ID!): Note!
restore_trashed_note(id: ID!): Note!
restore_note_revision(note_id: ID!, revision_id: ID!, parent_id: ID!): Note!
}
```
Relationships such as `Notebook.notes`, `Note.tags`, and `Tag.notes` use the same permission filtering and pagination rules as REST. A nullable lookup returns `null` for both absent and inaccessible objects, accompanied by no metadata that distinguishes the cases.
## Permission Matrix
| Operation | Required permission |
| --- | --- |
| View a notebook path | Direct/inherited `read` or `write`, or path-only ancestor status |
| Read/list/search a note | Direct/inherited `read` or `write` on its notebook |
| Create a note | Direct/inherited `write` on destination notebook |
| Edit/delete a note | Direct/inherited `write` on current notebook |
| Move a note | Direct/inherited `write` on source and destination notebooks |
| Read a trashed note or its history | Direct/inherited `read` or `write` on its notebook |
| Restore a trashed note or revision | Direct/inherited `write` on the destination notebook |
| List/view tags | Any authenticated client |
| Apply/remove a tag | Direct/inherited `write` on the note's notebook |
| Create a tag | Global `create_tags` |
| Create a notebook | Global `create_notebooks`; visible parent if creating a child |
| Read recent changes | Current `read` or `write` access to the affected note |
| Any MVP operation | `full_access` bypasses the individual MVP checks |
All access is default deny.
## Explicitly Absent From MVP
There are no public endpoints for:
* arbitrary Joplin CLI or shell command execution;
* Joplin CLI configuration or profile access;
* explicit sync control, sync-target upgrades, or Joplin Server administration;
* resources, attachments, or resource file download;
* to-do creation, completion, or other to-do-specific operations;
* import or export;
* E2EE configuration, passwords, or master keys;
* notebook rename, update, move, trash, or deletion;
* tag rename or deletion;
* permission/client administration;
* GraphQL subscriptions;
* permanent note deletion.
## Remaining Decisions Before Contract Freeze
The mutation recovery and idempotency contract remains to be finalised. The implementation records pending post-sync failures and returns an operation ID, but it does not yet deduplicate retries by `Idempotency-Key` or expose an operation-status endpoint.
Historical revision restoration currently preserves the reconstructed title unchanged when creating the new note.