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
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
coverage
.git
config/clients.json
state
*.log
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
coverage/
.env
config/clients.json
state/
*.log
+28
View File
@@ -0,0 +1,28 @@
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json tsconfig.build.json ./
COPY src ./src
RUN npm run build && npm prune --omit=dev
FROM node:22-bookworm-slim AS runtime
ARG JOPLIN_VERSION=3.6.2
RUN npm install --global "joplin@${JOPLIN_VERSION}" \
&& mkdir -p /profile /state /config \
&& chown -R node:node /profile /state /config
WORKDIR /app
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
ENV NODE_ENV=production \
JCG_CONFIG_PATH=/config/clients.json
VOLUME ["/profile", "/state"]
EXPOSE 8080
USER node
CMD ["node", "dist/main.js"]
+141
View File
@@ -0,0 +1,141 @@
![joplin-cli-gateway logo](./joplin-cli-gateway-logo.svg)
# joplin-cli-gateway
A permission-filtered REST and GraphQL API over one managed Joplin CLI profile. The gateway keeps the profile synchronized with an existing Joplin Server and allows multiple machine clients to use ordinary Joplin notes without installing Joplin themselves.
The product intent is in [`speccs.md`](speccs.md), the public API draft is in [`api-mvp.md`](api-mvp.md), and the stack decision is in [`language-framework-analysis.md`](language-framework-analysis.md).
## Implementation
The current implementation provides:
* TypeScript on Node.js 22;
* Fastify REST endpoints under `/api/v1`;
* Mercurius GraphQL at `/graphql`;
* OAuth-style client-credentials token issuance at `/oauth/token`;
* administrator-owned JSON client configuration;
* scrypt-hashed or development plaintext client secrets;
* durable JSON state for automatic grants and pending synchronization operations;
* inherited notebook read/write permissions and path-only ancestors;
* notes, notebooks, tags, trash, revision reconstruction/restoration, search, and change polling;
* complete hiding of to-dos, conflict notes, and inaccessible content;
* one serialized owner for synchronization and profile operations;
* hard failure when pre-operation sync fails;
* `202`/pending status when a local mutation succeeds but post-operation sync fails;
* one pre-sync and at most one post-sync for an entire GraphQL document.
The Joplin adapter deliberately uses a process boundary. It stops the local Data API server, runs `joplin sync`, starts the server for local operations, and stops it again before the next sync. The Data API process and a sync process therefore never access the profile concurrently.
Joplin labels its CLI Data API server experimental. Pin the Joplin version and repeat a real-profile compatibility check against that version before treating an upgrade as safe.
## Requirements
* Node.js 22 or later for source operation; or Docker;
* Joplin Terminal installed as `joplin`;
* a dedicated persistent Joplin CLI profile;
* that profile configured for Joplin Server sync target `9` (or Joplin Server SAML target `11`);
* an HTTPS Joplin Server URL.
The gateway refuses to start when the profile uses a non-Joplin-Server target or a non-HTTPS server URL.
## Configure a profile
Use a profile dedicated to the gateway. Do not open it from a separate Joplin process.
Typical Joplin Server settings are:
```sh
joplin --profile /path/to/gateway-profile config sync.target 9
joplin --profile /path/to/gateway-profile config sync.9.path https://joplin.example.com
joplin --profile /path/to/gateway-profile config sync.9.username you@example.com
joplin --profile /path/to/gateway-profile config sync.9.password 'server-password'
joplin --profile /path/to/gateway-profile sync
```
For Docker, run the same commands using the image and its `/profile` volume before starting the service.
## Configure clients
Copy `config/clients.example.json` to `config/clients.json`. Set the profile path, a random local Data API token, client identities, and permissions.
For production client credentials, generate an encoded secret:
```sh
npm run hash-secret -- 'a-long-random-client-secret'
```
Store the result as `client_secret_scrypt` and remove `client_secret`. Plaintext secrets remain supported for initial development. Restrict the configuration file to the service operator because it also contains the local Data API token.
Configuration is loaded once. Restart the process after changing clients or permissions. Tokens use a restart-scoped signing key, so every restart invalidates all previously issued tokens; disabling or removing a client therefore blocks its next request.
Top-level notebooks created through the gateway produce automatic write grants in the gateway state file. To revoke one, stop the gateway, remove the corresponding grant from `automatic_grants`, and restart it. The state file is gateway-managed while the process is running.
## Run from source
```sh
npm ci
cp config/clients.example.json config/clients.json
JCG_CONFIG_PATH=./config/clients.json npm run dev
```
Production build:
```sh
npm run check
npm test
npm run build
JCG_CONFIG_PATH=./config/clients.json npm start
```
## Run with Docker
```sh
cp config/clients.example.json config/clients.json
mkdir -p state
docker compose -f docker-compose.example.yml up --build
```
The example publishes the gateway only on loopback. Put it behind an HTTPS reverse proxy for remote clients. Set `server.trust_proxy` only when the proxy is trusted and strips untrusted forwarding headers.
## Obtain and use a token
```sh
curl -sS -X POST http://127.0.0.1:8080/oauth/token \
-H 'content-type: application/x-www-form-urlencoded' \
--data-urlencode grant_type=client_credentials \
--data-urlencode client_id=example-reader \
--data-urlencode client_secret='the-client-secret'
```
Then send the returned token:
```sh
curl -sS http://127.0.0.1:8080/api/v1/notebooks \
-H 'authorization: Bearer ACCESS_TOKEN'
```
The liveness endpoint is `GET /health/live`. It deliberately does not access or synchronize Joplin.
## Synchronization semantics
Every content operation is queued as one unit:
1. stop the local Data API owner if running;
2. run and await `joplin sync`;
3. start the local Data API server;
4. refresh the notebook hierarchy and permissions;
5. perform the local operation;
6. for a mutation, stop the Data API server and run `joplin sync` again.
A pre-sync failure returns `503 SYNC_UNAVAILABLE` without reading local content. A post-sync failure preserves the local mutation and returns a pending sync result with an operation ID. The idle synchronization timer retries delivery.
## Development checks
```sh
npm run check
npm test
npm run build
```
The automated suite uses an in-memory adapter and verifies authentication, non-disclosure, hierarchy permissions, mandatory sync behavior, degraded mutation reporting, GraphQL batching, and exclusive profile serialization. A real-profile compatibility suite remains necessary for each supported Joplin CLI version because the local Data API server is experimental.
+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.
+41
View File
@@ -0,0 +1,41 @@
{
"server": {
"host": "0.0.0.0",
"port": 8080,
"trust_proxy": false
},
"auth": {
"issuer": "joplin-cli-gateway",
"audience": "joplin-cli-gateway-api",
"token_ttl_seconds": 3600
},
"joplin": {
"executable": "joplin",
"profile_dir": "/profile",
"api_host": "127.0.0.1",
"api_port": 41184,
"api_token": "replace-with-a-random-32-character-token",
"command_timeout_ms": 300000,
"server_start_timeout_ms": 30000,
"periodic_sync_seconds": 60
},
"state_file": "/state/gateway-state.json",
"clients": [
{
"client_id": "example-reader",
"enabled": true,
"client_secret": "replace-this-development-secret",
"permissions": {
"full_access": false,
"create_notebooks": false,
"create_tags": false,
"notebooks": [
{
"notebook_id": "replace-with-a-joplin-notebook-id",
"access": "read"
}
]
}
}
]
}
+19
View File
@@ -0,0 +1,19 @@
services:
gateway:
build:
context: .
args:
JOPLIN_VERSION: 3.6.2
restart: unless-stopped
ports:
- "127.0.0.1:8080:8080"
environment:
JCG_CONFIG_PATH: /config/clients.json
LOG_LEVEL: info
volumes:
- ./config:/config:ro
- ./state:/state
- joplin-profile:/profile
volumes:
joplin-profile:
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB

+473
View File
@@ -0,0 +1,473 @@
# Programming Language and Framework Analysis
Status: accepted implementation decision, 2026-08-19. TypeScript, Node.js, Fastify, and Mercurius were selected, subject to continued compatibility testing of the experimental Joplin CLI Data API boundary.
## Executive Conclusion
The recommended starting stack is:
* **TypeScript on a current Node.js LTS release**;
* **Fastify** for REST, request validation, lifecycle hooks, and HTTP concerns;
* **Mercurius** for GraphQL, with GraphQL Yoga as the leading alternative;
* an explicit single-consumer **profile actor/operation queue** shared by REST, GraphQL, background sync, and future event delivery;
* a replaceable `JoplinAdapter` boundary that initially treats Joplin CLI as an external program or local service;
* JSON repositories for MVP administrator configuration and generated state, behind interfaces designed for later SQLite repositories.
**Go is the strongest alternative.** If an integration spike proves that the gateway can rely entirely on documented CLI commands or the local Joplin Data API, with no need to load Joplin libraries, Go becomes nearly as attractive and may be preferable for process supervision and a small operational binary.
The most important technical work before final commitment is therefore not an HTTP prototype. It is a narrow Joplin adapter spike that proves how mandatory pre-operation sync, local data operations, post-operation sync, trash, revisions, and exclusive profile ownership can coexist.
## Constraints That Actually Drive the Choice
This is not primarily a high-throughput web service. Its defining constraints are:
1. One gateway deployment owns one Joplin CLI profile.
2. No two processes may access that profile concurrently.
3. Every content request enters one serial queue.
4. Reads require a successful pre-operation sync.
5. Mutations require a post-operation sync and can produce degraded local success.
6. REST is normative; GraphQL exposes the same application services.
7. Future WebSocket/subscription delivery must not bypass permissions or the durable event cursor.
8. Joplin operations and network sync will dominate latency; raw framework throughput is secondary.
9. The container already needs the Joplin CLI and therefore a compatible Node runtime, regardless of the gateway language.
10. The public API must remain stable if the internal Joplin adapter changes.
The official terminal documentation describes Joplin CLI's `server` command as experimental and warns that it must use a separate profile so concurrent CLI instances do not access the same profile. It also exposes `joplin sync` as a separate command. See [Joplin Terminal Application](https://joplinapp.org/help/apps/terminal/).
## The Critical Integration Unknown
There are four plausible ways to control Joplin. Language selection depends partly on which one proves viable.
### 1. One-shot CLI commands
The gateway serially executes `joplin --profile ... sync` and individual CLI commands.
Advantages:
* strongest process and licensing boundary;
* uses documented CLI behaviour;
* easy to enforce one-operation-at-a-time ownership;
* works from any gateway language.
Disadvantages:
* process startup on every step;
* CLI output is not uniformly designed as a stable machine protocol;
* not every required operation has convenient structured output;
* command parsing and error classification may be brittle;
* a request can require sync, one or more data commands, and another sync.
This approach is acceptable for an MVP only if the adapter spike demonstrates complete, deterministic coverage.
### 2. Joplin CLI's experimental local Data API server
The gateway starts `joplin server start` on a dedicated profile and calls its local REST Data API.
Advantages:
* JSON operations already exist for notes, folders, tags, search, revisions, and events;
* avoids parsing most human-oriented CLI output;
* supports richer queries than many shell commands;
* language-neutral HTTP boundary.
Disadvantages:
* Joplin explicitly labels this CLI server experimental;
* the Data API does not expose a documented “sync now and wait” endpoint;
* running a separate `joplin sync` process against the same profile would violate the exclusive-profile rule;
* periodic background sync alone does not satisfy this gateway's sync-before-every-operation contract;
* trash restoration and reconstructed revision restoration may still require functionality outside the raw Data API.
This is the most important spike. The project must establish whether the same long-running Joplin process can safely perform an explicit sync on demand, or whether it would have to be stopped and restarted around sync operations.
### 3. Embed Joplin packages in the gateway process
A TypeScript gateway imports Joplin application/library packages and invokes models and synchronisation services directly.
Advantages:
* potentially the cleanest access to sync, revisions, trash, models, and events;
* one process can own the profile and queue;
* avoids text parsing and local HTTP indirection;
* shares Joplin's native types and behaviour.
Disadvantages:
* these are application internals, not a documented stable gateway SDK;
* upgrades can break the gateway even across ordinary Joplin releases;
* Joplin's app CLI depends on a large, native-module-heavy package graph;
* initialization assumes Joplin application lifecycle and global registries;
* the CLI/package is AGPL-3.0-or-later, so embedding or tightly linking packages requires licensing review and appropriate compliance;
* this option effectively forces Node/TypeScript.
Joplin's current CLI source imports `@joplin/lib` and related internal packages, and its package metadata declares AGPL-3.0-or-later. See the official [`packages/app-cli/package.json`](https://github.com/laurent22/joplin/blob/dev/packages/app-cli/package.json).
This should be a last resort or a deliberate maintained integration, not an accidental shortcut.
### 4. A dedicated Joplin helper process
A small Node process owns the Joplin profile and presents a private structured protocol to the gateway. It may wrap Joplin internals or a maintained Joplin patch while the public gateway remains language-neutral.
Advantages:
* isolates Joplin coupling and crashes;
* gives the main gateway a stable private adapter protocol;
* permits Go, Rust, Python, or another language for the public service;
* can serialize all profile operations in exactly one owner.
Disadvantages:
* two components and a private protocol to maintain;
* still inherits Joplin internal compatibility and licensing concerns if it imports Joplin packages;
* more complex lifecycle, health, logging, and packaging.
This is a credible post-spike fallback if neither one-shot commands nor the experimental server can satisfy the contract cleanly.
## Evaluation Criteria
The following matrix uses a 15 score. It is a project-specific decision aid, not a general language ranking.
| Criterion | Weight | TypeScript | Go | Rust | C# | Kotlin/Java | Python | Elixir |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| Joplin/runtime alignment | 25% | 5.0 | 4.0 | 4.0 | 3.5 | 3.5 | 4.0 | 3.5 |
| Process ownership and concurrency | 20% | 4.0 | 5.0 | 5.0 | 4.5 | 4.0 | 4.0 | 4.5 |
| REST, GraphQL, realtime fit | 20% | 5.0 | 4.0 | 4.0 | 5.0 | 5.0 | 4.5 | 4.0 |
| Correctness and maintainability | 15% | 4.0 | 5.0 | 5.0 | 5.0 | 5.0 | 4.0 | 4.0 |
| Container/deployment simplicity | 10% | 4.5 | 4.0 | 4.5 | 3.0 | 3.0 | 3.5 | 3.0 |
| Relevant libraries and testing | 10% | 5.0 | 4.0 | 3.5 | 5.0 | 5.0 | 5.0 | 3.5 |
| **Weighted result** | | **4.60** | **4.35** | **4.35** | **4.33** | **4.23** | **4.15** | **3.83** |
The small gaps are intentional. Team experience can legitimately move the top four choices. The adapter result matters more than a few tenths in this matrix.
## Language Analysis
### TypeScript and Node.js — Recommended
#### Strengths
* Joplin CLI is itself a Node/TypeScript application, so the runtime already exists in the container.
* It preserves every adapter option, including a future helper or direct library experiment.
* Async subprocess control is mature. Node's `child_process.spawn()` runs asynchronously without blocking the event loop, while direct argument arrays avoid a shell. See [Node child processes](https://nodejs.org/api/child_process.html).
* A promise-based single-consumer queue maps naturally to exclusive profile ownership.
* The ecosystem is particularly strong for JSON APIs, JSON Schema, OpenAPI, GraphQL, WebSocket protocols, structured logging, and test doubles.
* Sharing DTOs, permission types, error codes, event envelopes, and domain services between REST and GraphQL is straightforward.
* Future SDK generation is natural from OpenAPI and GraphQL schemas.
#### Weaknesses
* The type system disappears at runtime, so every external boundary still requires validation.
* Package churn and transitive dependency volume need active control.
* Synchronous CPU or database work can block the event loop. This service is mostly I/O-bound, but event filtering and future SQLite work must still be bounded.
* It is easy to accidentally couple HTTP handlers directly to Joplin commands without a disciplined application layer.
* Runtime alignment may tempt the project to import undocumented Joplin internals prematurely.
#### Suitability
Very high. The workload is I/O-heavy, serialized at the Joplin boundary, and rich in JSON/protocol concerns. Node's single event loop is not a disadvantage when the design intentionally funnels Joplin work through one actor.
### Go — Strongest Alternative
#### Strengths
* Goroutines and channels are an excellent fit for a profile actor: one goroutine owns the Joplin queue and request handlers communicate with it through typed messages.
* `os/exec` executes programs directly without implicitly invoking a shell, which is a strong default for safely passing Joplin IDs and content paths. See [`os/exec`](https://pkg.go.dev/os/exec).
* Cancellation and timeouts flow cleanly through `context.Context`.
* Strong compile-time types, simple deployment, fast startup, and predictable memory behaviour.
* The standard library covers much of HTTP, crypto, process, testing, and SQL functionality.
* `gqlgen` produces strongly typed schema-first GraphQL resolvers and supports WebSocket/SSE subscriptions. See [gqlgen subscriptions](https://gqlgen.com/master/recipes/subscriptions/).
#### Weaknesses
* The container still needs Node and Joplin, so “single static binary” does not eliminate the second runtime.
* GraphQL development is more code-generation-heavy than TypeScript, especially while the schema is evolving.
* JSON unions, flexible GraphQL inputs, and schema iteration are more verbose.
* Any direct Joplin-library strategy would require a Node helper, splitting the system.
* OAuth authorization-server libraries and rich API middleware are available, but the selection is less obvious than in the JavaScript, JVM, or .NET ecosystems.
#### Suitability
Excellent if the Joplin boundary remains subprocess or HTTP. Go would be the preferred choice for a strict two-process architecture with a language-neutral Joplin adapter.
### Rust
Recommended stack: Tokio, Axum, async-graphql, SQLx or rusqlite, tracing.
#### Strengths
* Strongest compile-time guarantees and explicit ownership.
* Tokio tasks and channels model the profile actor well.
* Axum supports modular HTTP routing and the Tower middleware ecosystem; it also provides WebSocket and SSE support. See [Axum](https://docs.rs/axum/latest/axum/) and [Axum WebSockets](https://docs.rs/axum/latest/axum/extract/ws/).
* Excellent process control, resource limits, and low runtime overhead.
* SQLx has first-class SQLite support.
#### Weaknesses
* Highest implementation cost for an API whose performance is dominated by Joplin sync.
* GraphQL, OAuth-provider, and high-level API ecosystems are smaller and change more quickly than the TypeScript/.NET/JVM equivalents.
* More code is required for ordinary product changes and integration glue.
* No meaningful advantage for direct Joplin integration.
#### Suitability
Technically excellent, economically hard to justify unless Rust expertise or safety requirements are unusually strong.
### C# and ASP.NET Core
Recommended stack: ASP.NET Core Minimal APIs or controllers, Hot Chocolate GraphQL, BackgroundService, System.Threading.Channels, EF Core or a lightweight SQLite layer.
#### Strengths
* Excellent HTTP, authentication, dependency injection, background-service, configuration, and testing ecosystem.
* `Channel<T>` cleanly represents the single Joplin actor queue.
* Hot Chocolate has mature GraphQL and subscription support over WebSocket/SSE, with replaceable event providers. See [Hot Chocolate subscriptions](https://chillicream.com/docs/hotchocolate/defining-a-schema/subscriptions).
* Strong type system, cancellation, observability, and long-term maintainability.
#### Weaknesses
* Adds the .NET runtime beside Node/Joplin.
* No special Joplin integration advantage.
* More framework surface than this personal, single-profile gateway initially needs.
* Container size and memory are likely higher than Go or a Node-only stack.
#### Suitability
Very good if .NET is the operator's strongest ecosystem. Otherwise it adds operational weight without solving the key adapter uncertainty.
### Kotlin/Java
Recommended stack: Spring Boot with WebFlux or MVC, Spring for GraphQL, coroutines for Kotlin where appropriate, JDBC/JOOQ for future SQLite.
#### Strengths
* Deep authentication, authorization, validation, configuration, migration, observability, and test ecosystems.
* Spring for GraphQL officially supports HTTP, WebSocket, and SSE transports. See [Spring for GraphQL](https://docs.spring.io/spring-boot/reference/web/spring-graphql.html).
* Strong long-term compatibility and excellent structured application architecture.
#### Weaknesses
* Considerable startup, memory, framework, and build weight for a single-user gateway.
* Reactive/WebFlux abstractions add complexity around a fundamentally serialized Joplin actor.
* Adds a JVM beside Node/Joplin with no direct integration advantage.
#### Suitability
Robust but disproportionate unless JVM expertise or deployment conventions dominate.
### Python
Recommended stack: FastAPI, Pydantic, Strawberry or Ariadne GraphQL, asyncio, SQLAlchemy/SQLModel or sqlite3.
#### Strengths
* Fastest path to a readable prototype.
* FastAPI provides validation, generated OpenAPI, dependency injection, security helpers, and WebSockets. See [FastAPI features](https://fastapi.tiangolo.com/features/) and [FastAPI WebSockets](https://fastapi.tiangolo.com/advanced/websockets/).
* `asyncio.create_subprocess_exec()` provides asynchronous shell-free process execution. See [Python asyncio subprocesses](https://docs.python.org/3/library/asyncio-subprocess.html).
* Excellent testing and data-processing ecosystem.
#### Weaknesses
* Static typing is useful but less strongly enforced across the full application and dependency ecosystem.
* GraphQL frameworks are less consolidated; choosing one becomes a separate long-term commitment.
* Multi-worker ASGI deployment is dangerous here because workers would not share the in-memory profile queue. This must be explicitly disabled or externally coordinated.
* Adds Python beside Node/Joplin.
* Long-lived connection and task cancellation behaviour requires discipline to avoid orphan work.
#### Suitability
Good for a prototype, acceptable for production, but not the strongest long-term fit when TypeScript offers similar iteration speed with closer Joplin alignment.
### Elixir
Recommended stack: Phoenix, Absinthe GraphQL, Ecto/SQLite or PostgreSQL.
#### Strengths
* Outstanding supervision, actor-style process ownership, and persistent-connection support.
* One GenServer owning the Joplin profile is conceptually ideal.
* Phoenix channels and fault isolation are strong foundations for future subscriptions.
#### Weaknesses
* Smaller ecosystem for this specific CLI/GraphQL/OAuth combination.
* Adds BEAM plus Node/Joplin.
* SQLite and external-process integration are less conventional than in the leading choices.
* GraphQL and REST DTO sharing is less direct.
#### Suitability
Attractive for a realtime-first system, but realtime is post-MVP and should rest on a durable event journal anyway. It does not outweigh the integration cost here.
### Languages Not Recommended for the MVP
* **Plain JavaScript:** loses compile-time guarantees precisely where permissions and state transitions need them.
* **Bun or Deno as the primary runtime:** Joplin is distributed for Node, so alternative runtimes add compatibility risk without removing Node from the container.
* **C/C++:** disproportionate memory-safety and development cost.
* **PHP/Ruby:** capable web stacks, but weaker fit for supervised long-running CLI ownership and future subscription connections than the finalists.
## TypeScript Framework Analysis
### Fastify — Recommended HTTP Foundation
Fastify is small enough that the architecture remains explicit while providing schema-driven validation, serialization, hooks, plugins, and structured logging. Its TypeScript support includes type providers for JSON Schema-based validation. See [Fastify TypeScript](https://fastify.dev/docs/latest/Reference/TypeScript/).
Why it fits:
* REST is the normative contract and maps naturally to route schemas;
* JSON Schema can generate OpenAPI and validate both request and response shapes;
* plugins provide clean composition for authentication, request IDs, error mapping, rate limits, and health;
* it does not prescribe a database or background-job model;
* GraphQL can be mounted without moving business logic into resolvers;
* low framework overhead makes the profile actor visible and testable.
### Mercurius — Recommended GraphQL Layer
Mercurius is Fastify's GraphQL adapter and supports TypeScript, loaders, batched queries, persisted queries, and subscriptions. See [Mercurius](https://mercurius.dev/).
Why it fits:
* one Fastify lifecycle and authentication context;
* GraphQL resolvers remain thin projections over the same application services used by REST;
* subscriptions provide a future path without adding a second HTTP server;
* loaders can prevent repeated adapter reads within a GraphQL document;
* GraphQL batching can perform one pre-operation sync for a complete document.
Risk: Mercurius is closely tied to Fastify. That is acceptable if Fastify is deliberately selected.
### GraphQL Yoga — Leading Alternative
Yoga is a strong choice if transport portability and standards-based subscription options are valued over tight Fastify integration. It supports GraphQL subscriptions and SSE, with WebSocket support through `graphql-ws`. See [Yoga subscriptions](https://the-guild.dev/graphql/yoga-server/docs/features/subscriptions).
Choose Yoga instead of Mercurius if:
* GraphQL might later be deployed separately;
* GraphQL-over-SSE is a likely first realtime transport;
* framework-neutral request handling matters more than a unified Fastify plugin model.
### NestJS — Reasonable, Not Recommended Initially
NestJS offers modules, dependency injection, guards, queues, scheduling, REST, GraphQL, and Fastify integration. Its official GraphQL support works with Apollo or Mercurius. See [NestJS GraphQL](https://docs.nestjs.com/graphql/quick-start).
It is a sound choice for a large team or a broad administration platform. For this gateway it initially adds decorators, module lifecycle, dependency-injection conventions, and adapter layers around a domain that is small but operationally unusual. The custom profile actor remains necessary regardless of Nest.
### Express and Apollo Server
Both are mature, but neither is the best combined foundation here:
* Express lacks Fastify's schema-first validation/serialization model.
* Apollo is GraphQL-focused while REST is normative.
* Apollo subscriptions require additional transport integration.
* using separate REST and GraphQL stacks makes shared authentication, error semantics, and lifecycle easier to drift.
## Recommended TypeScript Component Choices
These are starting directions, not dependencies to install blindly:
| Concern | Recommendation | Reason |
| --- | --- | --- |
| Runtime | Current Node LTS | Same runtime family as Joplin CLI |
| Language | TypeScript, strict mode | Permission and state-machine correctness |
| Package format | ESM unless Joplin adapter proves CommonJS necessary | Modern ecosystem; adapter can be isolated |
| REST | Fastify | Schema validation, lifecycle, low ceremony |
| REST schemas | TypeBox or authored JSON Schema | Runtime validation plus static inference |
| GraphQL | Mercurius, schema-first SDL | Explicit public contract and subscriptions path |
| OpenAPI | Generated from Fastify route schemas | REST remains normative |
| CLI execution | `spawn`/`execFile` with argument arrays, never a shell | Injection resistance, cancellation, streaming logs |
| Queue | Small explicit single-consumer actor | One owner for sync and profile operations |
| Logging | Fastify/Pino structured logs | Client ID, request ID, operation ID, sync phase |
| Tokens | Isolated auth service with replaceable token repository | Token format remains an open decision |
| MVP state | Validated JSON repository with atomic file replacement | Matches current product decision |
| Later state | SQLite repository behind the same interfaces | Grants, operations, cursors, subscriptions |
| Testing | Built-in Node test runner or Vitest; adapter contract tests | Fast unit tests plus real CLI integration suite |
Node's built-in SQLite module is currently documented as release-candidate stability rather than fully stable, so the project should not commit to it yet. The storage interface allows later selection among `node:sqlite`, a mature SQLite binding, or a query builder when SQLite work begins. See [Node SQLite](https://nodejs.org/api/sqlite.html).
## Architecture Rules Independent of Language
### One process owns the profile
The service must run as one application instance with one profile actor. Do not enable:
* Node cluster mode;
* multiple Uvicorn/Gunicorn workers;
* multiple container replicas;
* multiple JVM/.NET web workers that each believe they own the profile.
HTTP concurrency is allowed; Joplin profile concurrency is not. Requests queue at the application-service boundary.
### REST and GraphQL contain no Joplin orchestration
Both transports call shared use cases such as:
* `ListVisibleNotes`;
* `CreateNote`;
* `RestoreRevision`;
* `PollVisibleChanges`.
Those use cases perform authorization and submit one operation to the profile actor. HTTP handlers and GraphQL resolvers must never invoke Joplin directly.
### The profile actor owns the complete operation
One queued operation contains:
1. pre-sync;
2. hierarchy refresh and authorization;
3. local read or mutation;
4. event/operation recording;
5. post-sync for mutations;
6. final or degraded result.
The lock must not be released between these phases.
### Events use an outbox boundary
Future WebSocket delivery should consume recorded gateway events rather than receiving ad hoc resolver notifications. Even if the MVP event repository is minimal, event IDs and envelopes should be created in the application layer so delivery transports can change later.
### Configuration and generated state use separate repositories
The administrator JSON repository is read-only after process startup. The generated-state repository owns automatic notebook grants and later operation/event state. Code must not assume both are one file merely because both use JSON in MVP.
### Joplin is never accessed through its SQLite database directly
Reading or writing Joplin's database would couple the gateway to private schema, bypass application invariants, and conflict with profile ownership. The gateway's future SQLite database is separate gateway state.
## Required Technical Spike
Before creating the full service, build a disposable adapter experiment with a fresh test profile and non-production sync target.
### Questions to answer
1. Can the CLI local API server and explicit sync operate in one process/profile without concurrent access?
2. Does the server process perform recurring sync in headless command mode, and can completion be observed reliably?
3. Can the gateway trigger and await sync without stopping the Data API server?
4. If stop/sync/start is required, what are the latency and failure characteristics?
5. Can all MVP note, notebook, tag, trash, search, revision, and event operations be implemented through structured APIs?
6. Can a historical revision be reconstructed and restored to an explicitly chosen notebook without direct database access?
7. What outputs and exit codes distinguish authentication, network, conflict, validation, and profile-lock failures?
8. What happens if the Joplin process is killed during each sync or mutation phase?
9. How does E2EE initialization/unlocking behave in an unattended profile?
10. Which behaviours change across the oldest and newest Joplin CLI versions the gateway intends to support?
### Spike exit criteria
The spike should produce:
* a short adapter decision record;
* executable tests for the required flows;
* captured structured outputs and error cases;
* measured cold-start, sync, and request latency;
* a minimum supported Joplin CLI version;
* a clear selection among one-shot commands, local Data API, direct libraries, or helper process.
Only then should the language recommendation be frozen.
## Final Recommendation
Proceed with a **TypeScript adapter spike** because it can test every integration route, including direct Joplin packages if necessary. Keep the spike behind a `JoplinAdapter` interface and do not build HTTP endpoints yet.
If documented CLI/local-HTTP integration proves complete, retain TypeScript unless the project has a strong preference for Go. TypeScript still provides the simplest container and fastest REST/GraphQL delivery.
If direct Joplin internals are required, TypeScript becomes the practical choice, but the project must consciously accept version coupling and resolve AGPL compliance.
If a separate Joplin helper becomes necessary, reassess TypeScript versus Go for the public gateway. In that architecture, **Go plus gqlgen** becomes a particularly strong option because Joplin coupling has moved behind a private process boundary.
Rust, C#, Kotlin/Java, Python, and Elixir can all implement the system correctly. None currently offers enough project-specific advantage to displace the TypeScript/Go shortlist.
+3146
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "joplin-cli-gateway",
"version": "0.1.0",
"private": true,
"description": "Permission-filtered REST and GraphQL gateway for a managed Joplin CLI profile",
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"check": "tsc --noEmit",
"dev": "tsx watch src/main.ts",
"start": "node dist/main.js",
"test": "vitest run",
"test:watch": "vitest",
"hash-secret": "tsx src/tools/hash-secret.ts"
},
"dependencies": {
"@fastify/formbody": "^9.0.0",
"@sinclair/typebox": "^0.34.52",
"diff-match-patch": "^1.0.5",
"fastify": "^5.12.1",
"mercurius": "^16.10.0"
},
"devDependencies": {
"@types/diff-match-patch": "^1.0.36",
"@types/node": "^24.10.0",
"tsx": "^4.23.12",
"typescript": "^5.9.3",
"vitest": "^4.1.11"
}
}
+487
View File
@@ -0,0 +1,487 @@
# joplin-cli-gateway
## Purpose
`joplin-cli-gateway` provides a stable, application-facing interface for using Joplin as a knowledge base from other software.
The gateway is backed by a managed Joplin CLI profile. It exposes authorised application operations over the functionality and local data model provided by Joplin CLI, so those applications do not need to install, configure, or directly automate Joplin themselves.
The project is intended primarily for personal use, providing a reusable integration layer so that multiple independent projects can use the same Joplin data without each project implementing its own Joplin integration.
Companion components may eventually include:
* an SDK for application integration;
* an AI skill for integration by AI-based projects and agents.
These components are outside the MVP and may evolve independently over time.
## Scope
The gateway exposes Joplin capabilities to authorised external clients.
Joplin concepts remain visible as Joplin concepts. The gateway does not introduce a generic knowledge-base abstraction over notebooks, notes, tags, resources, or other Joplin entities.
The gateway must remain independent of the purpose, domain, or behaviour of consuming applications.
## Shared Knowledge Base
Multiple independent client projects are expected to use the same Joplin data.
Each `joplin-cli-gateway` instance manages one dedicated Joplin CLI profile and provides authorised clients with access to the shared Joplin data represented by that profile.
In the MVP, one gateway deployment represents exactly one Joplin account, one dedicated CLI profile, and one shared knowledge base. Multi-account deployments and active-active gateway replicas are outside the MVP.
Joplin applications are offline-first clients: each keeps a local profile containing its data and synchronises that data with a sync target. Joplin Desktop and the gateway-managed Joplin CLI profile are separate peers of the same Joplin Server account.
Joplin Server is the existing central synchronisation service between the gateway-managed Joplin CLI profile and other Joplin clients. It continues to serve this normal Joplin role. This project does not recreate, replace, proxy, administer, or directly access the database of Joplin Server.
The gateway uses the Joplin CLI rather than Joplin Server as its integration layer because the CLI exposes capabilities and behaviour that Joplin Server does not directly provide as an application API.
The gateway invokes the CLI's normal synchronisation operation on an ongoing basis. This exchanges changes with Joplin Server, allowing applications using the gateway and humans using standard Joplin clients to work with the same knowledge base.
The gateway is the sole process permitted to access its managed CLI profile. It must serialise CLI/profile operations so two CLI processes never access that profile concurrently.
### Synchronisation Freshness
Freshness is prioritised over avoiding synchronisation work. The gateway must synchronise its managed CLI profile immediately before processing an application operation and immediately after any operation that changes Joplin content. It may additionally synchronise periodically while idle.
All synchronisation and CLI operations are serialised through the gateway's single managed profile. Thus a request observes changes that were available from Joplin Server at its pre-operation synchronisation point, and a successful content-changing request is synchronised to Joplin Server before the gateway reports it as complete.
In the MVP, a failed pre-operation synchronisation causes the associated operation to fail without reading or modifying Joplin content. The gateway must not answer a read from a potentially stale local profile when the required pre-operation synchronisation fails.
If a local mutation succeeds but its post-operation synchronisation fails, the gateway reports a degraded result: the operation succeeded in the gateway's local Joplin profile but has not yet been confirmed on Joplin Server. The response must distinguish this state from both complete success and complete failure. The gateway retains the local change and retries synchronisation periodically.
While such a change is pending, gateway clients share the same local profile and can therefore observe the same local state after a subsequent successful pre-operation synchronisation. Human Joplin clients cannot observe the change until it reaches Joplin Server, and may independently change the same content. A later sync can consequently produce normal Joplin conflict behaviour.
A separate post-MVP stale-read mode may allow the gateway to answer reads from its last known local state when the pre-operation synchronisation is unavailable. Any such response must explicitly warn the client that it may be stale. Stale reads remain outside the MVP.
### Transport and End-to-End Encryption
Communication between the gateway-managed CLI profile and Joplin Server must use HTTPS.
HTTPS protects data in transit. It is distinct from Joplin end-to-end encryption (E2EE), which encrypts synced Joplin items and requires the relevant Joplin client profile to hold and unlock its encryption keys.
Gateway-specific E2EE key handling is deferred beyond the MVP. External gateway clients never receive Joplin Server credentials or Joplin E2EE keys.
The same data is also accessed directly by humans through standard Joplin clients, including Joplin Desktop.
Content managed through the gateway remains ordinary Joplin content.
All clients are peers. They are considered different software representations of the same operator rather than independent owners of data.
Content has no client-level ownership.
A client with sufficient permissions may read, modify, move or delete content regardless of which client or human originally created it.
## Gateway Interface
The gateway exposes both REST and GraphQL interfaces. These are stable application APIs over the functionality provided by the managed Joplin CLI.
Clients interact only with the gateway API. They do not require a local Joplin installation, a Joplin CLI profile, Joplin Server credentials, or direct access to Joplin Server.
REST is the normative public contract. GraphQL exposes an equivalent projection over the same capability and authorisation model. Their exact schemas and compatibility rules are specified separately.
The proposed MVP surface and interaction semantics are documented in [`api-mvp.md`](api-mvp.md).
Programming language and framework options are evaluated in [`language-framework-analysis.md`](language-framework-analysis.md). The accepted implementation stack is TypeScript on Node.js with Fastify and Mercurius. Joplin remains isolated behind a replaceable adapter and a single-owner profile queue.
## Client Identity
Each client has a stable identity representing a project or application.
Client identity is not intended to represent:
* individual human users;
* individual sessions;
* individual requests.
Permissions and operational logging are associated with this stable client identity.
Clients authenticate to the gateway using OAuth-style access tokens associated with their stable identity. In the MVP, an operator manually creates each client identity, login credentials, and permissions in JSON configuration, then restarts the gateway container to apply the configuration.
The intended machine-to-machine interaction is a client-credentials token exchange. There are no human gateway logins, self-registration, or interactive authorisation flows in the MVP. Removing or disabling a client in configuration and restarting the container must cause every token for that client to be rejected on its next request.
## Access Model
The access-control model is **default deny**.
Anything not explicitly permitted is denied.
A client has no access or capability merely because it exists, authenticated successfully, created content previously, or has access to related content.
Permissions must support restrictions at notebook level together with independent global capabilities.
At minimum:
* full access;
* read-only access to specific notebooks;
* read/write access to specific notebooks;
* global notebook creation permission;
* global tag creation permission.
Global capabilities are independent of notebook permissions.
For example, a client may have read/write access to an existing notebook hierarchy without permission to create notebooks.
There is no note-level access control.
The permission model should remain deliberately small for the MVP.
### Permission Administration
Permissions are centrally administered.
Clients cannot modify their own identity or permissions in the MVP.
For the MVP, client identities and administrator-assigned permissions are maintained through manually managed configuration.
For the MVP, this configuration is stored in JSON. SQLite is the intended future persistence mechanism, without changing the client identity or permission model.
Administrator-owned configuration and gateway-generated state are separate. The administrator configuration is read-only to the gateway. Durable state created by gateway operations, including automatic grants for client-created top-level notebooks, is stored in a separate gateway-managed JSON state file in the MVP. Both are replaced by SQLite later without changing their semantics.
A future administration frontend may provide management of:
* clients;
* client identities;
* notebook permissions;
* global capabilities.
The API required by such an administration interface may later also be exposed to suitably authorised clients.
The permission model governing administrative operations is TBD and outside the MVP.
### Revocation
Permission revocation takes effect by removing the corresponding capability.
Revoked permissions confer no residual rights based on previous access, authorship, or modification of content.
If a client should retain read access while losing write access, its permissions are changed accordingly.
A fully revoked client has no further access.
### Visibility and Information Disclosure
A client must not be able to access the contents of notebooks for which it has no permission.
Unauthorised notebook contents behave as though they do not exist from that client's perspective.
This applies to:
* note retrieval;
* search;
* notebook contents;
* recent-change information.
Requests involving inaccessible objects should not disclose whether the object exists.
Authentication and authorisation behaviour should follow standard information-disclosure-minimisation practices.
A client may nevertheless be aware of otherwise inaccessible notebooks where necessary to represent the complete hierarchical path to a notebook it is authorised to access.
Knowledge of that path does not imply access to the parent notebook's contents.
### Notebook Hierarchy
Notebook access inherits downward through the Joplin notebook hierarchy in the MVP.
Access granted to a notebook includes its descendant notebooks.
A notebook subsequently created beneath an accessible notebook becomes visible and accessible to clients with access to that hierarchy.
Exceptions to inherited access are outside the MVP.
A client granted access directly to a descendant notebook may be shown the complete notebook path even when it has no content access to one or more ancestors.
### Read/Write Access
Read/write access includes normal note-management operations within the permitted notebook hierarchy:
* creating notes;
* editing notes;
* deleting notes;
* moving notes within the accessible hierarchy;
* applying and removing tags.
If a client has read/write access to multiple notebook trees, it may move notes between those trees.
There is no special ownership or source-tree restriction on such moves.
Further subdivision of note permissions is outside the MVP.
### Notebook Creation
Notebook creation is controlled by an independent global permission.
A client with this permission may create:
* top-level notebooks;
* descendant notebooks beneath accessible notebooks.
A client automatically gains access to a top-level notebook that it creates.
This automatic access is a durable read/write grant stored in gateway-managed state. It survives gateway restarts and remains until an administrator revokes it.
A descendant notebook follows normal hierarchy inheritance rules.
### Notebook Structural Changes
Operations that substantially restructure or remove notebooks are outside the MVP.
The MVP does **not** permit clients to:
* delete notebooks;
* move notebooks;
* rename or otherwise modify notebook properties.
These capabilities may be introduced post-MVP under a permissions model to be defined later.
### Deletion
Note deletion follows normal reversible Joplin behaviour.
The gateway does not introduce a separate destructive-operation permission for note deletion in the MVP.
Authorised clients may list and read trashed notes and restore a trashed note. Restoration follows normal Joplin behaviour by returning the note to its original notebook. The client must have write access to the restoration destination. If the original notebook no longer exists, the gateway rejects restoration with a conflict response; a human must resolve the destination in a standard Joplin client.
Permanent note deletion is outside the MVP.
## Tags
Tags are global.
All clients may:
* list existing tags;
* view tags;
* apply existing tags to notes they may modify;
* remove tags from notes they may modify.
Creating new tags requires a global tag-creation permission.
A tag may be visible even when some of the notes using it are inaccessible.
Tag operations must never expose inaccessible notes.
## Note Content
The MVP accepts and returns Markdown/plain-text note bodies. It does not accept images, attachments, binary resources, or HTML-specific input fields.
Clients may use normal Joplin Markdown content, including structures and links normally supported by Joplin.
The gateway must not introduce application-specific schemas, validation, or content conventions.
Joplin internal note links use the target note's native ID in Markdown. The gateway preserves these links as note content and does not rewrite them. Possessing an ID or encountering a link does not grant access to its target; any attempt to retrieve or resolve an inaccessible target behaves as though it does not exist.
Because the gateway does not reinterpret or redact authorised note bodies, text deliberately written into an accessible note—including a link label or target ID—is visible as part of that note's content.
To-dos are outside the MVP. Notes whose `is_todo` property is set may exist in Joplin, but they are hidden from MVP clients and excluded from listing, retrieval, search, recent changes, trash, and note-history results.
Joplin conflict notes are hidden from MVP clients and excluded from listing, retrieval, search, and recent-change results. Conflict inspection and resolution are outside the MVP.
The exact safe set of client-writable note metadata beyond notebook, title, and body is TBD.
## Trash and Note History
The MVP exposes authorised access to trashed notes and normal Joplin note history.
Clients with read access to a note's notebook may inspect its available historical versions. Restoring a historical version follows Joplin's normal non-destructive behaviour: it creates a new restored note rather than overwriting the current note.
Restoring a trashed note or historical version requires write access to the destination notebook. When restoring a historical version, the client must explicitly select an existing writable destination notebook. The restored version is created there as a new note and does not overwrite the current note. The gateway does not promise that a particular historical version exists; availability and retention follow the Joplin revision service and its configured retention behaviour.
Clients cannot create, edit, or delete raw revision records directly.
## Search
Search operates only over content visible to the requesting client.
Inaccessible content must not:
* appear in results;
* expose its contents;
* contribute inaccessible result information.
## Identifiers
Native Joplin identifiers may be exposed directly to clients.
The gateway does not require a separate identifier namespace or attempt to hide Joplin object identity.
Client applications may use Joplin IDs as canonical references where appropriate.
## Metadata and Logging
Normal Joplin metadata is sufficient for content provenance.
The gateway does not add client identity, ownership, or gateway-specific provenance to Joplin content.
Operational logging should identify the client responsible for gateway operations for debugging and troubleshooting purposes.
## Concurrent Access
Multiple software clients and human Joplin users may concurrently modify the shared knowledge base.
The gateway exposes existing capabilities that allow authorised clients to inspect recent changes across content visible to them.
Use of this capability is optional.
Clients remain responsible for deciding whether checking for recent changes is appropriate before modifying content.
An MVP write may optionally include the note's expected `updated_time`. If the current note has a different value after the mandatory pre-operation sync, the gateway rejects the write as a conflict instead of overwriting it. Omitting this guard accepts normal Joplin last-write/conflict behaviour.
Documentation should encourage clients to inspect recent changes before making modifications where concurrent activity may matter.
The gateway does not provide application-level merge semantics or automatic conflict resolution.
## Functional Scope
### MVP
The MVP focuses on:
* stable client identities;
* default-deny access control;
* centrally administered permissions;
* notebook-level read and read/write permissions;
* independent global notebook creation permission;
* independent global tag creation permission;
* create notes;
* read notes;
* edit notes;
* delete notes;
* list, read and restore trashed notes;
* inspect available note history and restore a historical version as a new note;
* list and navigate permitted notebooks;
* create top-level or descendant notebooks where permitted;
* move notes between authorised notebook trees;
* list and use tags;
* create tags where permitted;
* search permitted content;
* expose existing recent-change information within permitted content;
* expose normal Joplin metadata and identifiers;
* operational logging identifying the responsible client.
### Post-MVP
Explicitly deferred:
* attachments and resources;
* to-do-specific operations;
* notebook deletion;
* notebook movement;
* notebook renaming and other property changes;
* exceptions to inherited notebook permissions;
* finer-grained structural permissions;
* administrative API;
* administrative frontend;
* client-accessible permission management;
* SDK;
* AI skill;
* broader Joplin feature coverage.
### Post-MVP Realtime Events and Subscriptions
Clients may maintain persistent WebSocket connections to receive updates after they occur.
Clients may create subscriptions filtered by one or more conditions, including:
* change type, such as note creation or modification;
* one or more authorised notebook hierarchies;
* tag membership;
* keywords or other supported note-search conditions;
* combinations of these conditions.
Realtime delivery must be based on a transport-independent event stream rather than treating a WebSocket connection as the event history. A disconnected client must be able to resume from a cursor and reconcile from the normal API if its cursor is no longer valid.
Events and subscription matches are evaluated only after applying the subscriber's current permissions. Revocation must stop further delivery and close or invalidate affected persistent connections. Events must not disclose inaccessible objects, counts, filter matches, or previous values.
The future subscription model must distinguish an object event from a match transition. For example, “a note was created while matching this filter” differs from “an existing note began matching because its text or tags changed.” Exact subscription and delivery guarantees are TBD post-MVP.
These future requirements influence the MVP only by requiring stable event identifiers, resumable cursors, a versioned event envelope, and separation between event semantics and delivery transport. WebSockets, saved subscriptions, and guaranteed delivery are not part of the MVP.
## MVP Acceptance Criteria
The MVP must demonstrate that:
* a note created in Joplin Desktop becomes readable by an authorised gateway client after synchronisation;
* a note created through the gateway becomes visible in Joplin Desktop after synchronisation;
* an unauthorised client cannot infer that an inaccessible note exists;
* client revocation takes effect on the next request after configuration is applied by container restart;
* failure of the required pre-operation sync prevents stale reads;
* no two processes can concurrently access and corrupt the gateway-managed CLI profile;
* search and recent changes do not leak inaccessible content;
* the only exception to notebook non-disclosure is the minimum ancestor path needed to represent an authorised nested notebook.
## Design Principles
### Joplin Remains Joplin
The gateway exposes Joplin's own concepts, identifiers, content and behaviour.
### Human and Software Coexistence
Humans using standard Joplin applications and software using the gateway operate on the same knowledge base.
Neither mode of access should make the data unsuitable for the other.
### Peer Clients
Clients do not own content.
Any authorised client may operate on any content within its permitted notebook hierarchy regardless of origin.
### Stable Client Identity
Projects and applications are persistent actors with stable identities and permissions.
### Default Deny
No access or capability exists unless explicitly granted.
### Central Authority
Clients operate only within permissions granted externally to them.
A client cannot expand its own authority.
### No Note-Level ACL
Access control applies to notebook hierarchies and global capabilities, not individual notes.
### Independent Capabilities
Notebook access and global operations such as notebook or tag creation are independently authorised.
### Minimal Information Disclosure
Unauthorised access must not reveal unnecessary information about inaccessible objects.
### Client-Project Agnostic
The gateway must make no assumptions about a client's domain, workflow, semantics or purpose.
### Minimal Policy
The gateway enforces permissions but otherwise avoids imposing application-specific policy.
### Shared Use
Multiple independent projects may use and modify the same Joplin knowledge base according to their permissions.
### Client-Managed Concurrency
The gateway exposes available state and change information but does not require clients to use it.
Clients remain responsible for avoiding unintended conflicting changes.
### Simple Permissions First
The MVP uses a deliberately coarse permission model.
Additional granularity should only be introduced when actual use cases justify it.
### Progressive Coverage
The MVP provides the minimum useful knowledge-base functionality.
Additional Joplin capabilities may be progressively exposed without changing the gateway into an application-specific service.
+35
View File
@@ -0,0 +1,35 @@
import type { ChangePage, Note, Notebook, Pagination, RevisionRecord, Tag } from '../domain/types.js';
export interface NoteQuery extends Pagination {
parent_id?: string;
include_deleted?: boolean;
query?: string;
}
export interface JoplinAdapter {
prepare(): Promise<void>;
close(): Promise<void>;
sync(): Promise<void>;
listNotebooks(): Promise<Notebook[]>;
getNotebook(id: string): Promise<Notebook | null>;
createNotebook(input: { title: string; parent_id?: string }): Promise<Notebook>;
listNotes(query: NoteQuery): Promise<Note[]>;
getNote(id: string, includeDeleted?: boolean): Promise<Note | null>;
createNote(input: { parent_id: string; title: string; body: string }): Promise<Note>;
updateNote(id: string, input: Partial<Pick<Note, 'parent_id' | 'title' | 'body' | 'deleted_time'>>): Promise<Note>;
deleteNote(id: string): Promise<void>;
listTags(): Promise<Tag[]>;
getTag(id: string): Promise<Tag | null>;
createTag(input: { title: string }): Promise<Tag>;
listNoteTags(noteId: string): Promise<Tag[]>;
listTagNotes(tagId: string): Promise<Note[]>;
addTagToNote(noteId: string, tagId: string): Promise<void>;
removeTagFromNote(noteId: string, tagId: string): Promise<void>;
search(query: string, pagination: Pagination): Promise<Note[]>;
listRevisions(noteId: string): Promise<RevisionRecord[]>;
listChanges(cursor: string | undefined, limit: number): Promise<ChangePage>;
}
+306
View File
@@ -0,0 +1,306 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { setTimeout as delay } from 'node:timers/promises';
import { GatewayError, asError } from '../domain/errors.js';
import type { ChangePage, Note, Notebook, Pagination, RevisionRecord, Tag } from '../domain/types.js';
import type { JoplinAdapter, NoteQuery } from './joplin-adapter.js';
interface AdapterConfig {
executable: string;
profile_dir: string;
api_host: string;
api_port: number;
api_token: string;
command_timeout_ms: number;
server_start_timeout_ms: number;
}
interface ApiPage<T> {
items: T[];
has_more: boolean;
}
const NOTE_FIELDS = [
'id', 'parent_id', 'title', 'body', 'created_time', 'updated_time', 'user_created_time',
'user_updated_time', 'deleted_time', 'is_todo', 'is_conflict',
].join(',');
const NOTE_LIST_FIELDS = NOTE_FIELDS;
const NOTEBOOK_FIELDS = 'id,parent_id,title,created_time,updated_time,deleted_time';
const TAG_FIELDS = 'id,title,created_time,updated_time';
const REVISION_FIELDS = [
'id', 'parent_id', 'item_id', 'item_type', 'item_updated_time', 'title_diff', 'body_diff',
'metadata_diff', 'encryption_applied', 'created_time', 'updated_time',
].join(',');
export class JoplinDataApiAdapter implements JoplinAdapter {
private serverProcess: ChildProcessWithoutNullStreams | undefined;
private closing = false;
public constructor(private readonly config: AdapterConfig) {}
public async prepare(): Promise<void> {
await this.runCli(['server', 'stop'], true);
await delay(250);
const syncTarget = parseConfigValue(await this.runCli(['config', 'sync.target']), 'sync.target').match(/^\d+/)?.[0] ?? '';
if (syncTarget !== '9' && syncTarget !== '11') {
throw new Error(`The managed profile must use Joplin Server sync target 9 or 11; found ${syncTarget || 'none'}`);
}
const serverUrl = parseConfigValue(await this.runCli(['config', `sync.${syncTarget}.path`]), `sync.${syncTarget}.path`);
let protocol = '';
try {
protocol = new URL(serverUrl).protocol;
} catch {
throw new Error('The managed profile has an invalid Joplin Server URL');
}
if (protocol !== 'https:') throw new Error('The managed Joplin CLI profile must connect to Joplin Server over HTTPS');
await this.runCli(['config', 'api.port', String(this.config.api_port)]);
await this.runCli(['config', 'api.token', this.config.api_token]);
}
public async close(): Promise<void> {
this.closing = true;
await this.stopServer();
}
public async sync(): Promise<void> {
await this.stopServer();
await this.runCli(['sync']);
}
public async listNotebooks(): Promise<Notebook[]> {
const items = await this.fetchAll<Notebook>('folders', { fields: NOTEBOOK_FIELDS, include_deleted: '1' });
return flattenNotebooks(items);
}
public async getNotebook(id: string): Promise<Notebook | null> {
return this.getOrNull<Notebook>(`folders/${id}`, { fields: NOTEBOOK_FIELDS, include_deleted: '1' });
}
public createNotebook(input: { title: string; parent_id?: string }): Promise<Notebook> {
return this.request<Notebook>('folders', { method: 'POST', body: input });
}
public async listNotes(query: NoteQuery): Promise<Note[]> {
const params: Record<string, string> = {
fields: NOTE_LIST_FIELDS,
include_deleted: query.include_deleted ? '1' : '0',
include_conflicts: '1',
order_by: query.order_by ?? 'updated_time',
order_dir: query.order_dir ?? 'DESC',
};
const path = query.parent_id ? `folders/${query.parent_id}/notes` : 'notes';
return this.fetchAll<Note>(path, params);
}
public async getNote(id: string, includeDeleted = false): Promise<Note | null> {
return this.getOrNull<Note>(`notes/${id}`, {
fields: NOTE_FIELDS,
include_deleted: includeDeleted ? '1' : '0',
include_conflicts: '1',
});
}
public createNote(input: { parent_id: string; title: string; body: string }): Promise<Note> {
return this.request<Note>('notes', { method: 'POST', body: input });
}
public updateNote(id: string, input: Partial<Pick<Note, 'parent_id' | 'title' | 'body' | 'deleted_time'>>): Promise<Note> {
return this.request<Note>(`notes/${id}`, { method: 'PUT', body: input });
}
public async deleteNote(id: string): Promise<void> {
await this.request(`notes/${id}`, { method: 'DELETE' });
}
public listTags(): Promise<Tag[]> {
return this.fetchAll<Tag>('tags', { fields: TAG_FIELDS, order_by: 'title', order_dir: 'ASC' });
}
public getTag(id: string): Promise<Tag | null> {
return this.getOrNull<Tag>(`tags/${id}`, { fields: TAG_FIELDS });
}
public createTag(input: { title: string }): Promise<Tag> {
return this.request<Tag>('tags', { method: 'POST', body: input });
}
public listNoteTags(noteId: string): Promise<Tag[]> {
return this.fetchAll<Tag>(`notes/${noteId}/tags`, { fields: TAG_FIELDS });
}
public listTagNotes(tagId: string): Promise<Note[]> {
return this.fetchAll<Note>(`tags/${tagId}/notes`, { fields: NOTE_LIST_FIELDS });
}
public async addTagToNote(noteId: string, tagId: string): Promise<void> {
await this.request(`tags/${tagId}/notes`, { method: 'POST', body: { id: noteId } });
}
public async removeTagFromNote(noteId: string, tagId: string): Promise<void> {
await this.request(`tags/${tagId}/notes/${noteId}`, { method: 'DELETE' });
}
public search(query: string, pagination: Pagination): Promise<Note[]> {
return this.fetchAll<Note>('search', {
query,
type: 'note',
fields: NOTE_LIST_FIELDS,
...(pagination.order_by ? { order_by: pagination.order_by, order_dir: pagination.order_dir ?? 'ASC' } : {}),
});
}
public async listRevisions(noteId: string): Promise<RevisionRecord[]> {
const revisions = await this.fetchAll<RevisionRecord>('revisions', { fields: REVISION_FIELDS, order_by: 'item_updated_time', order_dir: 'ASC' });
return revisions.filter(revision => revision.item_id === noteId).sort((a, b) => a.item_updated_time - b.item_updated_time);
}
public listChanges(cursor: string | undefined, limit: number): Promise<ChangePage> {
return this.request<ChangePage>('events', { query: { ...(cursor === undefined ? {} : { cursor }), limit: String(limit) } }).catch(error => {
if (error instanceof GatewayError && error.details.upstream_status === 400) {
throw new GatewayError(409, 'CHANGE_CURSOR_INVALID', 'Change cursor is invalid or has expired');
}
throw error;
});
}
private async fetchAll<T>(path: string, query: Record<string, string>): Promise<T[]> {
const output: T[] = [];
let page = 1;
while (true) {
const result = await this.request<ApiPage<T>>(path, { query: { ...query, page: String(page), limit: '100' } });
output.push(...result.items);
if (!result.has_more) return output;
page += 1;
if (page > 10000) throw new Error(`Joplin pagination did not terminate for ${path}`);
}
}
private async getOrNull<T>(path: string, query: Record<string, string>): Promise<T | null> {
try {
return await this.request<T>(path, { query });
} catch (error) {
if (error instanceof GatewayError && error.statusCode === 404) return null;
throw error;
}
}
private async request<T = unknown>(
path: string,
options: { method?: string; query?: Record<string, string>; body?: unknown } = {},
): Promise<T> {
await this.ensureServer();
const url = new URL(`http://${this.config.api_host}:${this.config.api_port}/${path}`);
url.searchParams.set('token', this.config.api_token);
for (const [key, value] of Object.entries(options.query ?? {})) url.searchParams.set(key, value);
const init: RequestInit = {
method: options.method ?? 'GET',
signal: AbortSignal.timeout(this.config.command_timeout_ms),
...(options.body === undefined ? {} : {
headers: { 'content-type': 'application/json' },
body: JSON.stringify(options.body),
}),
};
const response = await fetch(url, init);
if (!response.ok) {
const message = (await response.text()).slice(0, 2000) || `Joplin Data API returned ${response.status}`;
throw new GatewayError(
response.status === 404 ? 404 : 502,
response.status === 404 ? 'JOPLIN_NOT_FOUND' : 'JOPLIN_API_ERROR',
message,
{ upstream_status: response.status },
);
}
if (response.status === 204 || response.headers.get('content-length') === '0') return undefined as T;
const text = await response.text();
return (text ? JSON.parse(text) : undefined) as T;
}
private async ensureServer(): Promise<void> {
if (this.closing) throw new Error('Joplin adapter is closing');
if (this.serverProcess && this.serverProcess.exitCode === null && await this.ping()) return;
this.serverProcess = spawn(
this.config.executable,
['--profile', this.config.profile_dir, 'server', 'start', '--quiet'],
{ stdio: ['pipe', 'pipe', 'pipe'], shell: false },
);
this.serverProcess.stdin.end();
let stderr = '';
let startError: Error | undefined;
this.serverProcess.stdout.on('data', () => undefined);
this.serverProcess.stderr.on('data', chunk => { stderr = `${stderr}${String(chunk)}`.slice(-8000); });
this.serverProcess.once('error', error => { startError = error; });
const deadline = Date.now() + this.config.server_start_timeout_ms;
while (Date.now() < deadline) {
if (await this.ping()) return;
if (startError) throw new Error(`Could not start Joplin API server: ${startError.message}`);
if (this.serverProcess.exitCode !== null) throw new Error(`Joplin API server exited early: ${stderr}`);
await delay(100);
}
await this.stopServer();
throw new Error(`Joplin API server did not start within ${this.config.server_start_timeout_ms}ms`);
}
private async ping(): Promise<boolean> {
try {
const response = await fetch(`http://${this.config.api_host}:${this.config.api_port}/ping`, { signal: AbortSignal.timeout(500) });
return response.ok;
} catch {
return false;
}
}
private async stopServer(): Promise<void> {
const child = this.serverProcess;
this.serverProcess = undefined;
if (!child || child.exitCode !== null) return;
const exited = new Promise<void>(resolve => child.once('exit', () => resolve()));
child.kill('SIGTERM');
const timedOut = await Promise.race([exited.then(() => false), delay(5000).then(() => true)]);
if (timedOut && child.exitCode === null) {
child.kill('SIGKILL');
await exited;
}
}
private runCli(args: string[], tolerateFailure = false): Promise<string> {
return new Promise<string>((resolve, reject) => {
const child = spawn(this.config.executable, ['--profile', this.config.profile_dir, ...args], {
stdio: ['pipe', 'pipe', 'pipe'],
shell: false,
});
child.stdin.end();
let stdout = '';
let stderr = '';
child.stdout.on('data', chunk => { stdout = `${stdout}${String(chunk)}`.slice(-32000); });
child.stderr.on('data', chunk => { stderr = `${stderr}${String(chunk)}`.slice(-32000); });
const timer = setTimeout(() => child.kill('SIGKILL'), this.config.command_timeout_ms);
child.once('error', error => {
clearTimeout(timer);
reject(error);
});
child.once('exit', (code, signal) => {
clearTimeout(timer);
if (code === 0 || tolerateFailure) resolve(stdout);
else reject(new Error(`Joplin CLI ${args[0] ?? 'command'} failed (${code ?? signal}): ${stderr || stdout}`));
});
}).catch(error => {
throw new Error(`Unable to execute Joplin CLI: ${asError(error).message}`);
});
}
}
function flattenNotebooks(items: Notebook[]): Notebook[] {
const output: Notebook[] = [];
const visit = (item: Notebook & { children?: Notebook[] }) => {
const { children, ...notebook } = item;
output.push(notebook);
for (const child of children ?? []) visit(child);
};
for (const item of items as Array<Notebook & { children?: Notebook[] }>) visit(item);
return output;
}
function parseConfigValue(output: string, key: string): string {
const trimmed = output.trim();
const prefix = `${key} = `;
return trimmed.startsWith(prefix) ? trimmed.slice(prefix.length).trim() : trimmed;
}
+333
View File
@@ -0,0 +1,333 @@
import type { JoplinAdapter } from '../adapter/joplin-adapter.js';
import { AuthorizationView } from '../auth/authorization.js';
import { badRequest, conflict, forbidden, notFound } from '../domain/errors.js';
import { reconstructRevision } from '../domain/revisions.js';
import type {
Change, ChangePage, ClientIdentity, Note, NoteRevision, Notebook, NotebookView, OperationResult, Page, Pagination, RevisionRecord, Tag,
} from '../domain/types.js';
import type { ProfileActor } from '../profile/profile-actor.js';
import type { ProfileSession } from '../profile/profile-actor.js';
import type { StateRepository } from '../state/state-repository.js';
interface Context {
adapter: JoplinAdapter;
auth: AuthorizationView;
}
export class GatewayService {
private readonly batches = new WeakMap<ClientIdentity, {
session: ProfileSession;
context: Context;
mutated: boolean;
tail: Promise<void>;
}>();
public constructor(private readonly actor: ProfileActor, private readonly state: StateRepository) {}
public async beginBatch(client: ClientIdentity): Promise<void> {
if (this.batches.has(client)) throw new Error('Client already has an active GraphQL batch');
const session = await this.actor.begin();
try {
const auth = new AuthorizationView(client, await this.actor.adapter.listNotebooks(), this.state);
this.batches.set(client, { session, context: { adapter: this.actor.adapter, auth }, mutated: false, tail: Promise.resolve() });
} catch (error) {
session.abort();
throw error;
}
}
public async finishBatch(client: ClientIdentity): Promise<OperationResult<null>['sync'] | null> {
const batch = this.batches.get(client);
if (!batch) return null;
this.batches.delete(client);
await batch.tail.catch(() => undefined);
return (await batch.session.finish(null, batch.mutated, client.client_id)).sync;
}
public me(client: ClientIdentity): ClientIdentity {
const grants = new Map(client.permissions.notebooks.map(grant => [grant.notebook_id, grant.access]));
for (const grant of this.state.automaticGrants(client.client_id)) {
if (grant.access === 'write' || !grants.has(grant.notebook_id)) grants.set(grant.notebook_id, grant.access);
}
return {
...client,
permissions: {
...client.permissions,
notebooks: [...grants].map(([notebook_id, access]) => ({ notebook_id, access })),
},
};
}
public listNotebooks(client: ClientIdentity): Promise<OperationResult<NotebookView[]>> {
return this.run(client, false, async ({ auth }) => auth.visibleTree());
}
public getNotebook(client: ClientIdentity, id: string): Promise<OperationResult<NotebookView>> {
return this.run(client, false, async ({ auth }) => {
const notebook = auth.visibleNotebook(id);
const access = auth.access(id);
if (!notebook || !access) throw notFound('NOTEBOOK_NOT_FOUND', 'Notebook not found');
const found = findNotebook(auth.visibleTree(), id);
if (!found) throw notFound('NOTEBOOK_NOT_FOUND', 'Notebook not found');
return found;
});
}
public createNotebook(client: ClientIdentity, input: { title: string; parent_id?: string }): Promise<OperationResult<Notebook>> {
return this.run(client, true, async ({ adapter, auth }) => {
if (!client.permissions.full_access && !client.permissions.create_notebooks) {
throw forbidden('NOTEBOOK_CREATE_FORBIDDEN', 'Notebook creation is not permitted');
}
if (input.parent_id && !auth.canRead(input.parent_id)) {
throw notFound('NOTEBOOK_NOT_FOUND', 'Parent notebook not found');
}
const created = await adapter.createNotebook(input);
if (!input.parent_id) await this.state.addAutomaticWriteGrant(client.client_id, created.id);
return created;
});
}
public listNotes(client: ClientIdentity, pagination: Pagination, parentId?: string): Promise<OperationResult<Page<Note>>> {
return this.run(client, false, async ({ adapter, auth }) => {
if (parentId && !auth.canRead(parentId)) throw notFound('NOTEBOOK_NOT_FOUND', 'Notebook not found');
const notes = await adapter.listNotes({ ...pagination, ...(parentId ? { parent_id: parentId } : {}) });
return page(notes.filter(note => auth.noteIsVisible(note)), pagination);
});
}
public getNote(client: ClientIdentity, id: string): Promise<OperationResult<Note>> {
return this.run(client, false, async context => this.requireVisibleNote(context, id));
}
public createNote(client: ClientIdentity, input: { parent_id: string; title: string; body: string }): Promise<OperationResult<Note>> {
return this.run(client, true, async ({ adapter, auth }) => {
if (!auth.notebookExists(input.parent_id)) throw notFound('NOTEBOOK_NOT_FOUND', 'Notebook not found');
if (!auth.canWrite(input.parent_id)) throw forbidden('NOTE_WRITE_FORBIDDEN', 'Notebook is not writable');
return adapter.createNote(input);
});
}
public updateNote(
client: ClientIdentity,
id: string,
input: { parent_id?: string; title?: string; body?: string; expected_updated_time?: number },
): Promise<OperationResult<Note>> {
return this.run(client, true, async context => {
const current = await this.requireVisibleNote(context, id);
if (!context.auth.canWrite(current.parent_id)) throw forbidden('NOTE_WRITE_FORBIDDEN', 'Note is not writable');
if (input.expected_updated_time !== undefined && input.expected_updated_time !== current.updated_time) {
throw conflict('NOTE_CHANGED', 'Note has changed since the expected version');
}
if (input.parent_id !== undefined) {
if (!context.auth.notebookExists(input.parent_id)) throw notFound('NOTEBOOK_NOT_FOUND', 'Destination notebook not found');
if (!context.auth.canWrite(input.parent_id)) throw forbidden('NOTE_MOVE_FORBIDDEN', 'Destination notebook is not writable');
}
const { expected_updated_time: _expected, ...changes } = input;
if (Object.keys(changes).length === 0) throw badRequest('NOTE_UPDATE_EMPTY', 'At least one writable field is required');
return context.adapter.updateNote(id, changes);
});
}
public deleteNote(client: ClientIdentity, id: string): Promise<OperationResult<{ id: string; deleted: true }>> {
return this.run(client, true, async context => {
const note = await this.requireVisibleNote(context, id);
if (!context.auth.canWrite(note.parent_id)) throw forbidden('NOTE_WRITE_FORBIDDEN', 'Note is not writable');
await context.adapter.deleteNote(id);
return { id, deleted: true };
});
}
public listTrash(client: ClientIdentity, pagination: Pagination): Promise<OperationResult<Page<Note>>> {
return this.run(client, false, async ({ adapter, auth }) => {
const notes = await adapter.listNotes({ ...pagination, include_deleted: true });
return page(notes.filter(note => auth.trashedNoteIsVisible(note)), pagination);
});
}
public getTrashedNote(client: ClientIdentity, id: string): Promise<OperationResult<Note>> {
return this.run(client, false, async ({ adapter, auth }) => {
const note = await adapter.getNote(id, true);
if (!note || !auth.trashedNoteIsVisible(note)) throw notFound('TRASHED_NOTE_NOT_FOUND', 'Trashed note not found');
return note;
});
}
public restoreTrashedNote(client: ClientIdentity, id: string): Promise<OperationResult<Note>> {
return this.run(client, true, async ({ adapter, auth }) => {
const note = await adapter.getNote(id, true);
if (!note || !auth.trashedNoteIsVisible(note)) throw notFound('TRASHED_NOTE_NOT_FOUND', 'Trashed note not found');
if (!auth.notebookExists(note.parent_id)) {
throw conflict('RESTORE_DESTINATION_MISSING', 'The original notebook no longer exists');
}
if (!auth.canWrite(note.parent_id)) throw forbidden('NOTE_RESTORE_FORBIDDEN', 'Original notebook is not writable');
return adapter.updateNote(id, { deleted_time: 0 });
});
}
public listTags(client: ClientIdentity, pagination: Pagination): Promise<OperationResult<Page<Tag>>> {
return this.run(client, false, async ({ adapter }) => page(await adapter.listTags(), pagination));
}
public getTag(client: ClientIdentity, id: string): Promise<OperationResult<Tag>> {
return this.run(client, false, async ({ adapter }) => {
const tag = await adapter.getTag(id);
if (!tag) throw notFound('TAG_NOT_FOUND', 'Tag not found');
return tag;
});
}
public createTag(client: ClientIdentity, title: string): Promise<OperationResult<Tag>> {
return this.run(client, true, async ({ adapter }) => {
if (!client.permissions.full_access && !client.permissions.create_tags) {
throw forbidden('TAG_CREATE_FORBIDDEN', 'Tag creation is not permitted');
}
return adapter.createTag({ title });
});
}
public listNoteTags(client: ClientIdentity, noteId: string): Promise<OperationResult<Tag[]>> {
return this.run(client, false, async context => {
await this.requireVisibleNote(context, noteId);
return context.adapter.listNoteTags(noteId);
});
}
public listTagNotes(client: ClientIdentity, tagId: string, pagination: Pagination): Promise<OperationResult<Page<Note>>> {
return this.run(client, false, async ({ adapter, auth }) => {
if (!await adapter.getTag(tagId)) throw notFound('TAG_NOT_FOUND', 'Tag not found');
return page((await adapter.listTagNotes(tagId)).filter(note => auth.noteIsVisible(note)), pagination);
});
}
public setNoteTag(client: ClientIdentity, noteId: string, tagId: string, add: boolean): Promise<OperationResult<Note>> {
return this.run(client, true, async context => {
const note = await this.requireVisibleNote(context, noteId);
if (!context.auth.canWrite(note.parent_id)) throw forbidden('NOTE_WRITE_FORBIDDEN', 'Note is not writable');
if (!await context.adapter.getTag(tagId)) throw notFound('TAG_NOT_FOUND', 'Tag not found');
if (add) await context.adapter.addTagToNote(noteId, tagId);
else await context.adapter.removeTagFromNote(noteId, tagId);
return (await context.adapter.getNote(noteId)) ?? note;
});
}
public search(client: ClientIdentity, query: string, pagination: Pagination): Promise<OperationResult<Page<Note>>> {
return this.run(client, false, async ({ adapter, auth }) => {
const results = await adapter.search(query, pagination);
return page(results.filter(note => auth.noteIsVisible(note)), pagination);
});
}
public listRevisions(client: ClientIdentity, noteId: string, pagination: Pagination): Promise<OperationResult<Page<NoteRevision>>> {
return this.run(client, false, async context => {
await this.requireVisibleNote(context, noteId);
const records = await context.adapter.listRevisions(noteId);
const snapshots = records.map(record => reconstructRevision(records, record.id)).filter((value): value is NoteRevision => value !== null);
return page(snapshots, pagination);
});
}
public getRevision(client: ClientIdentity, noteId: string, revisionId: string): Promise<OperationResult<NoteRevision>> {
return this.run(client, false, async context => {
await this.requireVisibleNote(context, noteId);
return this.requireRevision(await context.adapter.listRevisions(noteId), revisionId);
});
}
public restoreRevision(client: ClientIdentity, noteId: string, revisionId: string, parentId: string): Promise<OperationResult<Note>> {
return this.run(client, true, async context => {
await this.requireVisibleNote(context, noteId);
if (!context.auth.notebookExists(parentId)) throw notFound('NOTEBOOK_NOT_FOUND', 'Destination notebook not found');
if (!context.auth.canWrite(parentId)) throw forbidden('NOTE_RESTORE_FORBIDDEN', 'Destination notebook is not writable');
const snapshot = this.requireRevision(await context.adapter.listRevisions(noteId), revisionId);
return context.adapter.createNote({ parent_id: parentId, title: snapshot.title, body: snapshot.body });
});
}
public changes(client: ClientIdentity, cursor: string | undefined, limit: number): Promise<OperationResult<ChangePage>> {
return this.run(client, false, async ({ adapter, auth }) => {
const result = await adapter.listChanges(cursor, limit);
if (cursor === undefined) return result;
const visible: Change[] = [];
let outputCursor = cursor;
let stoppedEarly = false;
for (const change of result.items) {
outputCursor = String(change.id);
if (!isNoteChange(change)) continue;
const note = await adapter.getNote(change.item_id, true);
if (note && !note.is_todo && !note.is_conflict && auth.canRead(note.parent_id)) {
visible.push({ ...change, item_type: 'note', type: changeType(change.type) });
if (visible.length >= limit) {
stoppedEarly = true;
break;
}
}
}
return { items: visible, cursor: outputCursor, has_more: stoppedEarly || result.has_more };
});
}
private run<T>(client: ClientIdentity, mutation: boolean, operation: (context: Context) => Promise<T>): Promise<OperationResult<T>> {
const batch = this.batches.get(client);
if (batch) {
let resolveTurn!: () => void;
const turn = new Promise<void>(resolve => { resolveTurn = resolve; });
const previous = batch.tail;
batch.tail = previous.catch(() => undefined).then(() => turn);
return previous.catch(() => undefined).then(async () => {
try {
const data = await operation(batch.context);
if (mutation) batch.mutated = true;
return { data, sync: { status: 'synced', local_change_applied: mutation } };
} finally {
resolveTurn();
}
});
}
return this.actor.run(client.client_id, mutation, async adapter => {
const auth = new AuthorizationView(client, await adapter.listNotebooks(), this.state);
return operation({ adapter, auth });
});
}
private async requireVisibleNote(context: Context, id: string): Promise<Note> {
const note = await context.adapter.getNote(id);
if (!note || !context.auth.noteIsVisible(note)) throw notFound('NOTE_NOT_FOUND', 'Note not found');
return note;
}
private requireRevision(records: RevisionRecord[], id: string): NoteRevision {
const revision = reconstructRevision(records, id);
if (!revision) throw notFound('REVISION_NOT_FOUND', 'Note revision not found');
return revision;
}
}
function page<T>(items: T[], pagination: Pagination): Page<T> {
const start = (pagination.page - 1) * pagination.limit;
return {
items: items.slice(start, start + pagination.limit),
page: pagination.page,
limit: pagination.limit,
has_more: start + pagination.limit < items.length,
};
}
function findNotebook(items: NotebookView[], id: string): NotebookView | null {
for (const item of items) {
if (item.id === id) return item;
const child = findNotebook(item.children, id);
if (child) return child;
}
return null;
}
function isNoteChange(change: Change): boolean {
return change.item_type === 1 || change.item_type === 'note';
}
function changeType(value: number | string): string {
if (value === 1) return 'created';
if (value === 2) return 'updated';
if (value === 3) return 'deleted';
return String(value);
}
+102
View File
@@ -0,0 +1,102 @@
import type { StateRepository } from '../state/state-repository.js';
import type { AccessLevel, ClientIdentity, Note, Notebook, NotebookGrant, NotebookView } from '../domain/types.js';
const stronger = (left: AccessLevel | null, right: AccessLevel): AccessLevel =>
left === 'write' || right === 'write' ? 'write' : 'read';
export class AuthorizationView {
private readonly byId = new Map<string, Notebook>();
private readonly children = new Map<string, Notebook[]>();
private readonly effective = new Map<string, AccessLevel>();
private readonly pathOnly = new Set<string>();
public constructor(
public readonly client: ClientIdentity,
notebooks: Notebook[],
state: StateRepository,
) {
for (const notebook of notebooks) {
this.byId.set(notebook.id, notebook);
const siblings = this.children.get(notebook.parent_id) ?? [];
siblings.push(notebook);
this.children.set(notebook.parent_id, siblings);
}
if (client.permissions.full_access) {
for (const id of this.byId.keys()) this.effective.set(id, 'write');
} else {
const grants = [...client.permissions.notebooks, ...state.automaticGrants(client.client_id)];
for (const grant of grants) this.applyGrant(grant);
}
for (const accessibleId of this.effective.keys()) {
let current = this.byId.get(accessibleId);
const visited = new Set<string>();
while (current?.parent_id && !visited.has(current.parent_id)) {
visited.add(current.parent_id);
if (!this.effective.has(current.parent_id)) this.pathOnly.add(current.parent_id);
current = this.byId.get(current.parent_id);
}
}
}
public access(notebookId: string): AccessLevel | 'path_only' | null {
return this.effective.get(notebookId) ?? (this.pathOnly.has(notebookId) ? 'path_only' : null);
}
public canRead(notebookId: string): boolean {
return this.effective.has(notebookId);
}
public canWrite(notebookId: string): boolean {
return this.effective.get(notebookId) === 'write';
}
public visibleNotebook(notebookId: string): Notebook | null {
const notebook = this.byId.get(notebookId);
return this.access(notebookId) && notebook && !notebook.deleted_time ? notebook : null;
}
public notebookExists(notebookId: string): boolean {
const notebook = this.byId.get(notebookId);
return !!notebook && !notebook.deleted_time;
}
public visibleTree(): NotebookView[] {
const build = (notebook: Notebook): NotebookView | null => {
if (notebook.deleted_time) return null;
const access = this.access(notebook.id);
if (!access) return null;
const children = (this.children.get(notebook.id) ?? [])
.map(build)
.filter((value): value is NotebookView => value !== null)
.sort((a, b) => a.title.localeCompare(b.title));
const base = { id: notebook.id, parent_id: notebook.parent_id, title: notebook.title, access, children };
if (access === 'path_only') return base;
return {
...base,
...(notebook.created_time === undefined ? {} : { created_time: notebook.created_time }),
...(notebook.updated_time === undefined ? {} : { updated_time: notebook.updated_time }),
};
};
return (this.children.get('') ?? [])
.map(build)
.filter((value): value is NotebookView => value !== null)
.sort((a, b) => a.title.localeCompare(b.title));
}
public noteIsVisible(note: Note): boolean {
return !note.deleted_time && !note.is_todo && !note.is_conflict && this.canRead(note.parent_id);
}
public trashedNoteIsVisible(note: Note): boolean {
return !!note.deleted_time && !note.is_todo && !note.is_conflict && this.canRead(note.parent_id);
}
private applyGrant(grant: NotebookGrant): void {
if (!this.byId.has(grant.notebook_id)) return;
const visit = (id: string) => {
this.effective.set(id, stronger(this.effective.get(id) ?? null, grant.access));
for (const child of this.children.get(id) ?? []) visit(child.id);
};
visit(grant.notebook_id);
}
}
+24
View File
@@ -0,0 +1,24 @@
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
const scrypt = promisify(scryptCallback);
export async function hashSecret(secret: string): Promise<string> {
const salt = randomBytes(16);
const derived = (await scrypt(secret, salt, 32)) as Buffer;
return `scrypt$${salt.toString('base64url')}$${derived.toString('base64url')}`;
}
export async function verifySecret(secret: string, plain: string | undefined, encoded: string | undefined): Promise<boolean> {
if (encoded) {
const [algorithm, saltText, hashText] = encoded.split('$');
if (algorithm !== 'scrypt' || !saltText || !hashText) return false;
const expected = Buffer.from(hashText, 'base64url');
const actual = (await scrypt(secret, Buffer.from(saltText, 'base64url'), expected.length)) as Buffer;
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
if (!plain) return false;
const expected = Buffer.from(plain);
const actual = Buffer.from(secret);
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
+67
View File
@@ -0,0 +1,67 @@
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import type { ClientConfig, GatewayConfig } from '../config.js';
import type { ClientIdentity } from '../domain/types.js';
import { verifySecret } from './secrets.js';
interface TokenPayload {
iss: string;
aud: string;
sub: string;
iat: number;
exp: number;
jti: string;
}
const encode = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url');
export class TokenService {
private readonly signingKey = randomBytes(32);
private readonly clients: Map<string, ClientConfig>;
public constructor(private readonly config: GatewayConfig) {
this.clients = new Map(config.clients.map(client => [client.client_id, client]));
}
public async issue(clientId: string, secret: string): Promise<{ access_token: string; token_type: 'Bearer'; expires_in: number }> {
const client = this.clients.get(clientId);
if (!client?.enabled || !(await verifySecret(secret, client.client_secret, client.client_secret_scrypt))) {
throw new Error('invalid_client');
}
const now = Math.floor(Date.now() / 1000);
const payload: TokenPayload = {
iss: this.config.auth.issuer,
aud: this.config.auth.audience,
sub: client.client_id,
iat: now,
exp: now + this.config.auth.token_ttl_seconds,
jti: randomBytes(16).toString('hex'),
};
const header = encode({ alg: 'HS256', typ: 'JWT' });
const body = encode(payload);
const signature = this.sign(`${header}.${body}`);
return { access_token: `${header}.${body}.${signature}`, token_type: 'Bearer', expires_in: this.config.auth.token_ttl_seconds };
}
public verify(token: string): ClientIdentity | null {
const parts = token.split('.');
if (parts.length !== 3) return null;
const [header, body, signature] = parts as [string, string, string];
const expected = Buffer.from(this.sign(`${header}.${body}`));
const actual = Buffer.from(signature);
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) return null;
try {
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as TokenPayload;
const now = Math.floor(Date.now() / 1000);
if (payload.iss !== this.config.auth.issuer || payload.aud !== this.config.auth.audience || payload.exp <= now) return null;
const client = this.clients.get(payload.sub);
if (!client?.enabled) return null;
return { client_id: client.client_id, permissions: client.permissions };
} catch {
return null;
}
}
private sign(value: string): string {
return createHmac('sha256', this.signingKey).update(value).digest('base64url');
}
}
+142
View File
@@ -0,0 +1,142 @@
import { readFile } from 'node:fs/promises';
import { isAbsolute, resolve } from 'node:path';
import { badRequest } from './domain/errors.js';
import type { ClientPermissions } from './domain/types.js';
export interface ClientConfig {
client_id: string;
enabled: boolean;
client_secret?: string;
client_secret_scrypt?: string;
permissions: ClientPermissions;
}
export interface GatewayConfig {
server: {
host: string;
port: number;
trust_proxy: boolean;
};
auth: {
issuer: string;
audience: string;
token_ttl_seconds: number;
};
joplin: {
executable: string;
profile_dir: string;
api_host: string;
api_port: number;
api_token: string;
command_timeout_ms: number;
server_start_timeout_ms: number;
periodic_sync_seconds: number;
};
state_file: string;
clients: ClientConfig[];
}
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
function requireString(object: Record<string, unknown>, key: string): string {
const value = object[key];
if (typeof value !== 'string' || value.length === 0) throw badRequest('CONFIG_INVALID', `${key} must be a non-empty string`);
return value;
}
function optionalBoolean(object: Record<string, unknown>, key: string, fallback: boolean): boolean {
const value = object[key];
if (value === undefined) return fallback;
if (typeof value !== 'boolean') throw badRequest('CONFIG_INVALID', `${key} must be a boolean`);
return value;
}
function optionalInteger(object: Record<string, unknown>, key: string, fallback: number, min: number, max: number): number {
const value = object[key] ?? fallback;
if (!Number.isInteger(value) || (value as number) < min || (value as number) > max) {
throw badRequest('CONFIG_INVALID', `${key} must be an integer from ${min} to ${max}`);
}
return value as number;
}
function parsePermissions(value: unknown): ClientPermissions {
if (!isObject(value)) throw badRequest('CONFIG_INVALID', 'permissions must be an object');
const notebooksValue = value.notebooks ?? [];
if (!Array.isArray(notebooksValue)) throw badRequest('CONFIG_INVALID', 'permissions.notebooks must be an array');
const notebooks = notebooksValue.map((grant, index) => {
if (!isObject(grant)) throw badRequest('CONFIG_INVALID', `permissions.notebooks[${index}] must be an object`);
const notebook_id = requireString(grant, 'notebook_id');
if (grant.access !== 'read' && grant.access !== 'write') {
throw badRequest('CONFIG_INVALID', `permissions.notebooks[${index}].access must be read or write`);
}
return { notebook_id, access: grant.access as 'read' | 'write' };
});
return {
full_access: optionalBoolean(value, 'full_access', false),
create_notebooks: optionalBoolean(value, 'create_notebooks', false),
create_tags: optionalBoolean(value, 'create_tags', false),
notebooks,
};
}
export async function loadConfig(inputPath = process.env.JCG_CONFIG_PATH ?? './config/clients.json'): Promise<GatewayConfig> {
const configPath = resolve(inputPath);
const raw = JSON.parse(await readFile(configPath, 'utf8')) as unknown;
if (!isObject(raw)) throw badRequest('CONFIG_INVALID', 'Configuration root must be an object');
const server = isObject(raw.server) ? raw.server : {};
const auth = isObject(raw.auth) ? raw.auth : {};
const joplin = isObject(raw.joplin) ? raw.joplin : {};
if (!Array.isArray(raw.clients)) throw badRequest('CONFIG_INVALID', 'clients must be an array');
const profileDir = requireString(joplin, 'profile_dir');
if (!isAbsolute(profileDir)) throw badRequest('CONFIG_INVALID', 'joplin.profile_dir must be an absolute path');
const apiHost = typeof joplin.api_host === 'string' ? joplin.api_host : '127.0.0.1';
if (!['127.0.0.1', 'localhost', '::1'].includes(apiHost)) {
throw badRequest('CONFIG_INVALID', 'joplin.api_host must be a loopback address');
}
const ids = new Set<string>();
const clients = raw.clients.map((value, index): ClientConfig => {
if (!isObject(value)) throw badRequest('CONFIG_INVALID', `clients[${index}] must be an object`);
const client_id = requireString(value, 'client_id');
if (ids.has(client_id)) throw badRequest('CONFIG_INVALID', `Duplicate client_id: ${client_id}`);
ids.add(client_id);
const client_secret = typeof value.client_secret === 'string' ? value.client_secret : undefined;
const client_secret_scrypt = typeof value.client_secret_scrypt === 'string' ? value.client_secret_scrypt : undefined;
if (!client_secret && !client_secret_scrypt) {
throw badRequest('CONFIG_INVALID', `Client ${client_id} requires client_secret or client_secret_scrypt`);
}
return {
client_id,
enabled: optionalBoolean(value, 'enabled', true),
...(client_secret ? { client_secret } : {}),
...(client_secret_scrypt ? { client_secret_scrypt } : {}),
permissions: parsePermissions(value.permissions),
};
});
return {
server: {
host: typeof server.host === 'string' ? server.host : '127.0.0.1',
port: optionalInteger(server, 'port', 8080, 1, 65535),
trust_proxy: optionalBoolean(server, 'trust_proxy', false),
},
auth: {
issuer: typeof auth.issuer === 'string' ? auth.issuer : 'joplin-cli-gateway',
audience: typeof auth.audience === 'string' ? auth.audience : 'joplin-cli-gateway-api',
token_ttl_seconds: optionalInteger(auth, 'token_ttl_seconds', 3600, 60, 86400),
},
joplin: {
executable: typeof joplin.executable === 'string' ? joplin.executable : 'joplin',
profile_dir: profileDir,
api_host: apiHost,
api_port: optionalInteger(joplin, 'api_port', 41184, 1, 65535),
api_token: requireString(joplin, 'api_token'),
command_timeout_ms: optionalInteger(joplin, 'command_timeout_ms', 300000, 1000, 3600000),
server_start_timeout_ms: optionalInteger(joplin, 'server_start_timeout_ms', 30000, 1000, 300000),
periodic_sync_seconds: optionalInteger(joplin, 'periodic_sync_seconds', 60, 5, 86400),
},
state_file: resolve(typeof raw.state_file === 'string' ? raw.state_file : './state/gateway-state.json'),
clients,
};
}
+23
View File
@@ -0,0 +1,23 @@
export class GatewayError extends Error {
public constructor(
public readonly statusCode: number,
public readonly code: string,
message: string,
public readonly details: Record<string, unknown> = {},
) {
super(message);
this.name = 'GatewayError';
}
}
export const badRequest = (code: string, message: string, details: Record<string, unknown> = {}) =>
new GatewayError(400, code, message, details);
export const forbidden = (code: string, message: string) => new GatewayError(403, code, message);
export const notFound = (code: string, message: string) => new GatewayError(404, code, message);
export const conflict = (code: string, message: string) => new GatewayError(409, code, message);
export const unprocessable = (code: string, message: string) => new GatewayError(422, code, message);
export function asError(value: unknown): Error {
return value instanceof Error ? value : new Error(String(value));
}
+57
View File
@@ -0,0 +1,57 @@
import DiffMatchPatch from 'diff-match-patch';
import { unprocessable } from './errors.js';
import type { NoteRevision, RevisionRecord } from './types.js';
const dmp = new DiffMatchPatch();
function applyTextPatch(text: string, patch: string): string {
const parsed = patch.startsWith('@@') ? dmp.patch_fromText(patch) : JSON.parse(patch || '[]');
const [result, applied] = dmp.patch_apply(parsed, text) as [string, boolean[]];
if (applied.some(value => !value)) throw unprocessable('REVISION_INVALID', 'A revision patch could not be reconstructed');
return result;
}
function applyObjectPatch(object: Record<string, unknown>, patch: string): Record<string, unknown> {
const parsed = JSON.parse((patch || '{"new":{},"deleted":[]}').replace(/[\n\r]/g, '')) as {
new: Record<string, unknown>;
deleted: string[];
};
const output = { ...object, ...parsed.new };
for (const key of parsed.deleted) delete output[key];
return output;
}
export function reconstructRevision(records: RevisionRecord[], revisionId: string): NoteRevision | null {
const target = records.find(record => record.id === revisionId);
if (!target) return null;
const byId = new Map(records.map(record => [record.id, record]));
const chain: RevisionRecord[] = [];
const visited = new Set<string>();
let current: RevisionRecord | undefined = target;
while (current) {
if (visited.has(current.id)) throw unprocessable('REVISION_INVALID', 'Revision history contains a cycle');
visited.add(current.id);
chain.push(current);
current = current.parent_id ? byId.get(current.parent_id) : undefined;
}
chain.reverse();
let title = '';
let body = '';
let metadata: Record<string, unknown> = {};
for (const revision of chain) {
if (revision.encryption_applied) throw unprocessable('REVISION_ENCRYPTED', 'Revision has not been decrypted by Joplin');
title = applyTextPatch(title, revision.title_diff);
body = applyTextPatch(body, revision.body_diff);
metadata = applyObjectPatch(metadata, revision.metadata_diff);
}
return {
id: target.id,
note_id: target.item_id,
item_updated_time: target.item_updated_time,
title,
body,
parent_id: typeof metadata.parent_id === 'string' ? metadata.parent_id : '',
...(typeof metadata.user_created_time === 'number' ? { user_created_time: metadata.user_created_time } : {}),
...(typeof metadata.user_updated_time === 'number' ? { user_updated_time: metadata.user_updated_time } : {}),
};
}
+120
View File
@@ -0,0 +1,120 @@
export type AccessLevel = 'read' | 'write';
export interface NotebookGrant {
notebook_id: string;
access: AccessLevel;
}
export interface ClientPermissions {
full_access: boolean;
create_notebooks: boolean;
create_tags: boolean;
notebooks: NotebookGrant[];
}
export interface ClientIdentity {
client_id: string;
permissions: ClientPermissions;
}
export interface Notebook {
id: string;
parent_id: string;
title: string;
created_time?: number;
updated_time?: number;
deleted_time?: number;
}
export type NotebookView = Notebook & {
access: AccessLevel | 'path_only';
children: NotebookView[];
};
export interface Note {
id: string;
parent_id: string;
title: string;
body: string;
created_time: number;
updated_time: number;
user_created_time?: number;
user_updated_time?: number;
deleted_time: number;
is_todo: number;
is_conflict: number;
}
export interface Tag {
id: string;
title: string;
created_time?: number;
updated_time?: number;
}
export interface RevisionRecord {
id: string;
parent_id: string;
item_id: string;
item_type: number;
item_updated_time: number;
title_diff: string;
body_diff: string;
metadata_diff: string;
encryption_applied: number;
created_time?: number;
updated_time?: number;
}
export interface NoteRevision {
id: string;
note_id: string;
item_updated_time: number;
title: string;
body: string;
parent_id: string;
user_created_time?: number;
user_updated_time?: number;
}
export interface Change {
id: number;
item_type: number | string;
item_id: string;
type: number | string;
created_time: number;
}
export interface Page<T> {
items: T[];
page: number;
limit: number;
has_more: boolean;
}
export interface ChangePage {
items: Change[];
cursor: string;
has_more: boolean;
}
export interface Pagination {
page: number;
limit: number;
order_by?: string;
order_dir?: 'ASC' | 'DESC';
}
export type SyncStatus =
| { status: 'synced'; local_change_applied: boolean }
| {
status: 'pending';
local_change_applied: true;
operation_id: string;
warning: string;
};
export interface OperationResult<T> {
data: T;
sync: SyncStatus;
}
+305
View File
@@ -0,0 +1,305 @@
import type { FastifyInstance, FastifyPluginAsync } from 'fastify';
import mercurius, { type MercuriusOptions } from 'mercurius';
import { Kind, type DocumentNode, type SelectionSetNode } from 'graphql';
import type { GatewayService } from '../application/gateway-service.js';
import { GatewayError } from '../domain/errors.js';
import type { ClientIdentity, OperationResult, Pagination } from '../domain/types.js';
interface GraphqlContext {
client: ClientIdentity;
gatewayBatch?: boolean;
}
declare module 'mercurius' {
interface MercuriusContext {
client: ClientIdentity;
gatewayBatch?: boolean;
}
}
const schema = /* GraphQL */ `
enum AccessLevel { read write path_only }
enum OrderDirection { ASC DESC }
enum NoteOrderField { created_time updated_time user_created_time user_updated_time title }
type SyncStatus {
status: String!
local_change_applied: Boolean!
operation_id: ID
warning: String
}
type Notebook {
id: ID!
parent_id: ID!
title: String!
created_time: Float
updated_time: Float
access: AccessLevel
children: [Notebook!]!
notes(page: Int = 1, limit: Int = 50): NotePage!
sync: SyncStatus
}
type Note {
id: ID!
parent_id: ID!
title: String!
body: String!
created_time: Float!
updated_time: Float!
user_created_time: Float
user_updated_time: Float
deleted_time: Float!
tags: [Tag!]!
sync: SyncStatus
}
type Tag {
id: ID!
title: String!
created_time: Float
updated_time: Float
notes(page: Int = 1, limit: Int = 50): NotePage!
sync: SyncStatus
}
type ClientPermissions {
full_access: Boolean!
create_notebooks: Boolean!
create_tags: Boolean!
notebooks: [NotebookGrant!]!
}
type NotebookGrant { notebook_id: ID!, access: AccessLevel! }
type Client { client_id: ID!, permissions: ClientPermissions! }
type PageInfo { page: Int!, limit: Int!, has_more: Boolean! }
type NotePage { items: [Note!]!, page_info: PageInfo! }
type TagPage { items: [Tag!]!, page_info: PageInfo! }
type NoteRevision {
id: ID!
note_id: ID!
item_updated_time: Float!
title: String!
body: String!
parent_id: ID!
user_created_time: Float
user_updated_time: Float
}
type RevisionPage { items: [NoteRevision!]!, page_info: PageInfo! }
type Change {
id: ID!
item_type: String!
item_id: ID!
type: String!
created_time: Float!
}
type ChangePage { items: [Change!]!, cursor: String!, has_more: Boolean! }
type DeletedNote { id: ID!, deleted: Boolean!, sync: SyncStatus! }
input NoteFilter { notebook_id: ID }
input CreateNotebookInput { title: String!, parent_id: ID }
input CreateNoteInput { parent_id: ID!, title: String!, body: String! }
input UpdateNoteInput { parent_id: ID, title: String, body: String, expected_updated_time: Float }
input CreateTagInput { title: String! }
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
}
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!
}
`;
export async function registerGraphql(app: FastifyInstance, service: GatewayService): Promise<void> {
await app.register(mercurius as unknown as FastifyPluginAsync<MercuriusOptions>, {
schema,
path: '/graphql',
graphiql: false,
errorFormatter: (execution, context) => ({
statusCode: 200,
response: {
...(execution.data === undefined ? {} : { data: execution.data }),
errors: execution.errors.map(error => {
const original = error.originalError;
const gatewayError = original instanceof GatewayError ? original : null;
const internal = !gatewayError && !error.extensions.code;
return {
message: internal ? 'Internal server error' : error.message,
...(error.locations ? { locations: error.locations } : {}),
...(error.path ? { path: error.path } : {}),
extensions: {
...error.extensions,
code: gatewayError?.code ?? error.extensions.code ?? 'INTERNAL_ERROR',
request_id: context.reply.request.id,
...(gatewayError && Object.keys(gatewayError.details).length > 0 ? { details: gatewayError.details } : {}),
},
};
}),
},
}),
context: request => {
if (!request.clientIdentity) throw new Error('Unauthenticated GraphQL request');
return { client: request.clientIdentity } satisfies GraphqlContext;
},
resolvers: {
NotePage: { page_info: (root: { page: number; limit: number; has_more: boolean }) => root },
TagPage: { page_info: (root: { page: number; limit: number; has_more: boolean }) => root },
RevisionPage: { page_info: (root: { page: number; limit: number; has_more: boolean }) => root },
Notebook: {
notes: async (root: { id: string; access?: string }, args: PageArgs, context: GraphqlContext) => {
const selected = pagination(args);
if (root.access !== 'read' && root.access !== 'write') return { items: [], page: selected.page, limit: selected.limit, has_more: false };
return (await service.listNotes(context.client, selected, root.id)).data;
},
},
Note: {
tags: async (root: { id: string }, _args: unknown, context: GraphqlContext) =>
(await service.listNoteTags(context.client, root.id)).data,
},
Tag: {
notes: async (root: { id: string }, args: PageArgs, context: GraphqlContext) =>
(await service.listTagNotes(context.client, root.id, pagination(args))).data,
},
Change: {
item_type: (root: { item_type: string | number }) => String(root.item_type),
type: (root: { type: string | number }) => String(root.type),
},
Query: {
me: (_root: unknown, _args: unknown, context: GraphqlContext) => service.me(context.client),
notebooks: async (_root: unknown, _args: unknown, context: GraphqlContext) => (await service.listNotebooks(context.client)).data,
notebook: async (_root: unknown, args: { id: string }, context: GraphqlContext) => nullable(() => service.getNotebook(context.client, args.id)),
notes: async (_root: unknown, args: PageArgs & { filter?: { notebook_id?: string } }, context: GraphqlContext) =>
(await service.listNotes(context.client, pagination(args), args.filter?.notebook_id)).data,
note: async (_root: unknown, args: { id: string }, context: GraphqlContext) => nullable(() => service.getNote(context.client, args.id)),
tags: async (_root: unknown, args: PageArgs, context: GraphqlContext) => (await service.listTags(context.client, pagination(args))).data,
tag: async (_root: unknown, args: { id: string }, context: GraphqlContext) => nullable(() => service.getTag(context.client, args.id)),
search: async (_root: unknown, args: PageArgs & { query: string }, context: GraphqlContext) =>
(await service.search(context.client, args.query, pagination(args))).data,
changes: async (_root: unknown, args: { cursor?: string; limit?: number }, context: GraphqlContext) =>
(await service.changes(context.client, args.cursor, bounded(args.limit ?? 50, 1, 100))).data,
trashed_notes: async (_root: unknown, args: PageArgs, context: GraphqlContext) =>
(await service.listTrash(context.client, pagination(args))).data,
trashed_note: async (_root: unknown, args: { id: string }, context: GraphqlContext) => nullable(() => service.getTrashedNote(context.client, args.id)),
note_revisions: async (_root: unknown, args: PageArgs & { note_id: string }, context: GraphqlContext) =>
(await service.listRevisions(context.client, args.note_id, pagination(args))).data,
note_revision: async (_root: unknown, args: { note_id: string; revision_id: string }, context: GraphqlContext) =>
nullable(() => service.getRevision(context.client, args.note_id, args.revision_id)),
},
Mutation: {
create_notebook: async (_root: unknown, args: { input: { title: string; parent_id?: string } }, context: GraphqlContext) =>
withSync(await service.createNotebook(context.client, args.input)),
create_note: async (_root: unknown, args: { input: { parent_id: string; title: string; body: string } }, context: GraphqlContext) =>
withSync(await service.createNote(context.client, args.input)),
update_note: async (_root: unknown, args: { id: string; input: { parent_id?: string; title?: string; body?: string; expected_updated_time?: number } }, context: GraphqlContext) =>
withSync(await service.updateNote(context.client, args.id, args.input)),
delete_note: async (_root: unknown, args: { id: string }, context: GraphqlContext) =>
withSync(await service.deleteNote(context.client, args.id)),
create_tag: async (_root: unknown, args: { input: { title: string } }, context: GraphqlContext) =>
withSync(await service.createTag(context.client, args.input.title)),
add_tag_to_note: async (_root: unknown, args: { note_id: string; tag_id: string }, context: GraphqlContext) =>
withSync(await service.setNoteTag(context.client, args.note_id, args.tag_id, true)),
remove_tag_from_note: async (_root: unknown, args: { note_id: string; tag_id: string }, context: GraphqlContext) =>
withSync(await service.setNoteTag(context.client, args.note_id, args.tag_id, false)),
restore_trashed_note: async (_root: unknown, args: { id: string }, context: GraphqlContext) =>
withSync(await service.restoreTrashedNote(context.client, args.id)),
restore_note_revision: async (_root: unknown, args: { note_id: string; revision_id: string; parent_id: string }, context: GraphqlContext) =>
withSync(await service.restoreRevision(context.client, args.note_id, args.revision_id, args.parent_id)),
},
},
});
app.graphql.addHook<GraphqlContext>('preExecution', async (_schema, document, context) => {
if (!documentNeedsJoplin(document)) return;
await service.beginBatch(context.client);
context.gatewayBatch = true;
});
app.graphql.addHook<Record<string, unknown>, GraphqlContext>('onResolution', async (execution, context) => {
if (!context.gatewayBatch) return;
context.gatewayBatch = false;
const sync = await service.finishBatch(context.client);
if (sync && execution.data) replaceSyncStatus(execution.data, sync);
});
}
interface PageArgs {
page?: number;
limit?: number;
order_by?: string;
order_dir?: 'ASC' | 'DESC';
}
function pagination(args: PageArgs): Pagination {
return {
page: bounded(args.page ?? 1, 1, 1_000_000),
limit: bounded(args.limit ?? 50, 1, 100),
...(args.order_by ? { order_by: args.order_by } : {}),
...(args.order_dir ? { order_dir: args.order_dir } : {}),
};
}
function bounded(value: number, minimum: number, maximum: number): number {
if (!Number.isInteger(value) || value < minimum || value > maximum) {
throw new GatewayError(400, 'PAGINATION_INVALID', `Value must be from ${minimum} to ${maximum}`);
}
return value;
}
function withSync<T>(result: OperationResult<T>): T & { sync: OperationResult<T>['sync'] } {
return { ...(result.data as T & object), sync: result.sync } as T & { sync: OperationResult<T>['sync'] };
}
async function nullable<T>(operation: () => Promise<OperationResult<T>>): Promise<T | null> {
try {
return (await operation()).data;
} catch (error) {
if (typeof error === 'object' && error !== null && 'statusCode' in error && error.statusCode === 404) return null;
throw error;
}
}
function documentNeedsJoplin(document: DocumentNode): boolean {
const fragments = new Map(document.definitions
.filter(definition => definition.kind === Kind.FRAGMENT_DEFINITION)
.map(definition => [definition.name.value, definition.selectionSet]));
const selectionNeedsData = (selectionSet: SelectionSetNode): boolean => selectionSet.selections.some(selection => {
if (selection.kind === Kind.FIELD) return selection.name.value !== 'me' && selection.name.value !== '__typename';
if (selection.kind === Kind.INLINE_FRAGMENT) return selectionNeedsData(selection.selectionSet);
return fragments.has(selection.name.value) && selectionNeedsData(fragments.get(selection.name.value)!);
});
return document.definitions.some(definition => definition.kind === Kind.OPERATION_DEFINITION && selectionNeedsData(definition.selectionSet));
}
function replaceSyncStatus(value: unknown, sync: OperationResult<null>['sync']): void {
if (Array.isArray(value)) {
for (const item of value) replaceSyncStatus(item, sync);
return;
}
if (typeof value !== 'object' || value === null) return;
const record = value as Record<string, unknown>;
if ('sync' in record && record.sync !== null) record.sync = sync;
for (const child of Object.values(record)) replaceSyncStatus(child, sync);
}
+131
View File
@@ -0,0 +1,131 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { Type } from '@sinclair/typebox';
import type { GatewayService } from '../application/gateway-service.js';
import { badRequest } from '../domain/errors.js';
import type { OperationResult, Pagination } from '../domain/types.js';
const IdParams = Type.Object({ id: Type.String({ minLength: 1 }) }, { additionalProperties: false });
const NoteTagParams = Type.Object({ note_id: Type.String({ minLength: 1 }), tag_id: Type.String({ minLength: 1 }) }, { additionalProperties: false });
const RevisionParams = Type.Object({ note_id: Type.String({ minLength: 1 }), revision_id: Type.String({ minLength: 1 }) }, { additionalProperties: false });
const CreateNotebookBody = Type.Object({ title: Type.String({ minLength: 1 }), parent_id: Type.Optional(Type.String({ minLength: 1 })) }, { additionalProperties: false });
const CreateNoteBody = Type.Object({ parent_id: Type.String({ minLength: 1 }), title: Type.String(), body: Type.String() }, { additionalProperties: false });
const UpdateNoteBody = Type.Partial(Type.Object({
parent_id: Type.String({ minLength: 1 }), title: Type.String(), body: Type.String(), expected_updated_time: Type.Integer({ minimum: 0 }),
}, { additionalProperties: false }));
const CreateTagBody = Type.Object({ title: Type.String({ minLength: 1 }) }, { additionalProperties: false });
const RestoreRevisionBody = Type.Object({ parent_id: Type.String({ minLength: 1 }) }, { additionalProperties: false });
export async function registerRest(app: FastifyInstance, service: GatewayService): Promise<void> {
app.get('/api/v1/me', async request => service.me(requireClient(request)));
app.get('/api/v1/notebooks', async request => (await service.listNotebooks(requireClient(request))).data);
app.get<{ Params: { id: string } }>('/api/v1/notebooks/:id', { schema: { params: IdParams } }, async request =>
(await service.getNotebook(requireClient(request), request.params.id)).data);
app.get<{ Params: { id: string }; Querystring: Record<string, string> }>('/api/v1/notebooks/:id/notes', { schema: { params: IdParams } }, async request =>
(await service.listNotes(requireClient(request), pagination(request.query), request.params.id)).data);
app.post<{ Body: { title: string; parent_id?: string } }>('/api/v1/notebooks', { schema: { body: CreateNotebookBody } }, async (request, reply) =>
sendMutation(reply, await service.createNotebook(requireClient(request), request.body), 201));
app.get<{ Querystring: Record<string, string> }>('/api/v1/notes', async request =>
(await service.listNotes(requireClient(request), pagination(request.query), optionalString(request.query.notebook_id))).data);
app.post<{ Body: { parent_id: string; title: string; body: string } }>('/api/v1/notes', { schema: { body: CreateNoteBody } }, async (request, reply) =>
sendMutation(reply, await service.createNote(requireClient(request), request.body), 201));
app.get<{ Params: { id: string } }>('/api/v1/notes/:id', { schema: { params: IdParams } }, async request =>
(await service.getNote(requireClient(request), request.params.id)).data);
app.patch<{ Params: { id: string }; Body: { parent_id?: string; title?: string; body?: string; expected_updated_time?: number } }>(
'/api/v1/notes/:id', { schema: { params: IdParams, body: UpdateNoteBody } }, async (request, reply) =>
sendMutation(reply, await service.updateNote(requireClient(request), request.params.id, request.body)),
);
app.delete<{ Params: { id: string } }>('/api/v1/notes/:id', { schema: { params: IdParams } }, async (request, reply) =>
sendMutation(reply, await service.deleteNote(requireClient(request), request.params.id)));
app.get<{ Params: { id: string } }>('/api/v1/notes/:id/tags', { schema: { params: IdParams } }, async request =>
(await service.listNoteTags(requireClient(request), request.params.id)).data);
app.put<{ Params: { note_id: string; tag_id: string } }>('/api/v1/notes/:note_id/tags/:tag_id', { schema: { params: NoteTagParams } }, async (request, reply) =>
sendMutation(reply, await service.setNoteTag(requireClient(request), request.params.note_id, request.params.tag_id, true)));
app.delete<{ Params: { note_id: string; tag_id: string } }>('/api/v1/notes/:note_id/tags/:tag_id', { schema: { params: NoteTagParams } }, async (request, reply) =>
sendMutation(reply, await service.setNoteTag(requireClient(request), request.params.note_id, request.params.tag_id, false)));
app.get<{ Querystring: Record<string, string> }>('/api/v1/trash/notes', async request =>
(await service.listTrash(requireClient(request), pagination(request.query))).data);
app.get<{ Params: { id: string } }>('/api/v1/trash/notes/:id', { schema: { params: IdParams } }, async request =>
(await service.getTrashedNote(requireClient(request), request.params.id)).data);
app.post<{ Params: { id: string } }>('/api/v1/trash/notes/:id/restore', { schema: { params: IdParams } }, async (request, reply) =>
sendMutation(reply, await service.restoreTrashedNote(requireClient(request), request.params.id)));
app.get<{ Params: { id: string }; Querystring: Record<string, string> }>('/api/v1/notes/:id/revisions', { schema: { params: IdParams } }, async request =>
(await service.listRevisions(requireClient(request), request.params.id, pagination(request.query))).data);
app.get<{ Params: { note_id: string; revision_id: string } }>('/api/v1/notes/:note_id/revisions/:revision_id', { schema: { params: RevisionParams } }, async request =>
(await service.getRevision(requireClient(request), request.params.note_id, request.params.revision_id)).data);
app.post<{ Params: { note_id: string; revision_id: string }; Body: { parent_id: string } }>(
'/api/v1/notes/:note_id/revisions/:revision_id/restore',
{ schema: { params: RevisionParams, body: RestoreRevisionBody } },
async (request, reply) => sendMutation(
reply,
await service.restoreRevision(requireClient(request), request.params.note_id, request.params.revision_id, request.body.parent_id),
201,
),
);
app.get<{ Querystring: Record<string, string> }>('/api/v1/tags', async request =>
(await service.listTags(requireClient(request), pagination(request.query))).data);
app.get<{ Params: { id: string } }>('/api/v1/tags/:id', { schema: { params: IdParams } }, async request =>
(await service.getTag(requireClient(request), request.params.id)).data);
app.get<{ Params: { id: string }; Querystring: Record<string, string> }>('/api/v1/tags/:id/notes', { schema: { params: IdParams } }, async request =>
(await service.listTagNotes(requireClient(request), request.params.id, pagination(request.query))).data);
app.post<{ Body: { title: string } }>('/api/v1/tags', { schema: { body: CreateTagBody } }, async (request, reply) =>
sendMutation(reply, await service.createTag(requireClient(request), request.body.title), 201));
app.get<{ Querystring: Record<string, string> }>('/api/v1/search', async request => {
const query = optionalString(request.query.query);
if (!query) throw badRequest('SEARCH_QUERY_REQUIRED', 'Search query is required');
return (await service.search(requireClient(request), query, pagination(request.query))).data;
});
app.get<{ Querystring: Record<string, string> }>('/api/v1/changes', async request => {
const limit = integer(request.query.limit, 50, 1, 100, 'limit');
return (await service.changes(requireClient(request), optionalString(request.query.cursor), limit)).data;
});
}
function requireClient(request: FastifyRequest) {
if (!request.clientIdentity) throw new Error('Authenticated route did not receive a client identity');
return request.clientIdentity;
}
function pagination(query: Record<string, string>): Pagination {
const orderDir = query.order_dir?.toUpperCase();
if (orderDir !== undefined && orderDir !== 'ASC' && orderDir !== 'DESC') {
throw badRequest('PAGINATION_INVALID', 'order_dir must be ASC or DESC');
}
const allowedOrderFields = new Set(['title', 'created_time', 'updated_time', 'user_created_time', 'user_updated_time']);
if (query.order_by && !allowedOrderFields.has(query.order_by)) {
throw badRequest('PAGINATION_INVALID', 'order_by is not supported');
}
return {
page: integer(query.page, 1, 1, 1_000_000, 'page'),
limit: integer(query.limit, 50, 1, 100, 'limit'),
...(query.order_by ? { order_by: query.order_by } : {}),
...(orderDir ? { order_dir: orderDir } : {}),
};
}
function integer(value: string | undefined, fallback: number, min: number, max: number, name: string): number {
if (value === undefined) return fallback;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
throw badRequest('PAGINATION_INVALID', `${name} must be an integer from ${min} to ${max}`);
}
return parsed;
}
function optionalString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
function sendMutation<T>(reply: FastifyReply, result: OperationResult<T>, successStatus = 200): unknown {
reply.code(result.sync.status === 'pending' ? 202 : successStatus);
return typeof result.data === 'object' && result.data !== null
? { ...result.data, sync: result.sync }
: { data: result.data, sync: result.sync };
}
+83
View File
@@ -0,0 +1,83 @@
import Fastify, { type FastifyError, type FastifyInstance } from 'fastify';
import formbody from '@fastify/formbody';
import type { GatewayConfig } from '../config.js';
import type { JoplinAdapter } from '../adapter/joplin-adapter.js';
import { JoplinDataApiAdapter } from '../adapter/joplin-data-api-adapter.js';
import { TokenService } from '../auth/token-service.js';
import { GatewayError } from '../domain/errors.js';
import { StateRepository } from '../state/state-repository.js';
import { ProfileActor } from '../profile/profile-actor.js';
import { GatewayService } from '../application/gateway-service.js';
import { registerRest } from './rest.js';
import { registerGraphql } from './graphql.js';
import './types.js';
export async function buildServer(config: GatewayConfig, adapter?: JoplinAdapter): Promise<FastifyInstance> {
const app = Fastify({
logger: process.env.NODE_ENV === 'test' ? false : { level: process.env.LOG_LEVEL ?? 'info' },
trustProxy: config.server.trust_proxy,
genReqId: request => request.headers['x-request-id']?.toString() ?? crypto.randomUUID(),
});
await app.register(formbody);
app.decorateRequest('clientIdentity', null);
const state = new StateRepository(config.state_file);
await state.load();
const joplin = adapter ?? new JoplinDataApiAdapter(config.joplin);
const actor = new ProfileActor(joplin, state, config.joplin.periodic_sync_seconds);
const tokens = new TokenService(config);
const service = new GatewayService(actor, state);
app.addHook('onRequest', async request => {
if (request.url === '/health/live' || request.url === '/oauth/token') return;
if (!request.url.startsWith('/api/v1/') && !request.url.startsWith('/graphql')) return;
const header = request.headers.authorization;
if (!header?.startsWith('Bearer ')) throw new GatewayError(401, 'UNAUTHORIZED', 'A valid bearer token is required');
const identity = tokens.verify(header.slice('Bearer '.length));
if (!identity) throw new GatewayError(401, 'UNAUTHORIZED', 'A valid bearer token is required');
request.clientIdentity = identity;
});
app.get('/health/live', async () => ({ status: 'ok' }));
app.post<{ Body: { grant_type?: string; client_id?: string; client_secret?: string } }>('/oauth/token', async (request, reply) => {
const { grant_type, client_id, client_secret } = request.body ?? {};
if (grant_type !== 'client_credentials') {
reply.code(400);
return { error: 'unsupported_grant_type' };
}
if (!client_id || !client_secret) {
reply.code(400);
return { error: 'invalid_request' };
}
try {
return await tokens.issue(client_id, client_secret);
} catch {
reply.header('WWW-Authenticate', 'Basic realm="joplin-cli-gateway"');
reply.code(401);
return { error: 'invalid_client' };
}
});
app.setErrorHandler((error: FastifyError, request, reply) => {
const gatewayError = error instanceof GatewayError ? error : null;
const validation = 'validation' in error && error.validation;
const statusCode = gatewayError?.statusCode ?? (validation ? 400 : (error.statusCode && error.statusCode >= 400 ? error.statusCode : 500));
const code = gatewayError?.code ?? (validation ? 'REQUEST_INVALID' : statusCode === 401 ? 'UNAUTHORIZED' : 'INTERNAL_ERROR');
if (statusCode >= 500) request.log.error({ err: error }, 'Request failed');
reply.code(statusCode).send({
error: {
code,
message: statusCode >= 500 && !gatewayError ? 'Internal server error' : error.message,
request_id: request.id,
details: gatewayError?.details ?? (validation ? { validation: error.validation } : {}),
},
});
});
await registerRest(app, service);
await registerGraphql(app, service);
app.addHook('onClose', async () => actor.stop());
await actor.start();
return app;
}
+7
View File
@@ -0,0 +1,7 @@
import type { ClientIdentity } from '../domain/types.js';
declare module 'fastify' {
interface FastifyRequest {
clientIdentity: ClientIdentity | null;
}
}
+15
View File
@@ -0,0 +1,15 @@
import { loadConfig } from './config.js';
import { buildServer } from './http/server.js';
const config = await loadConfig();
const app = await buildServer(config);
const shutdown = async (signal: string) => {
app.log.info({ signal }, 'Shutting down');
await app.close();
};
process.once('SIGINT', () => { void shutdown('SIGINT'); });
process.once('SIGTERM', () => { void shutdown('SIGTERM'); });
await app.listen({ host: config.server.host, port: config.server.port });
+126
View File
@@ -0,0 +1,126 @@
import { GatewayError, asError } from '../domain/errors.js';
import { randomUUID } from 'node:crypto';
import type { OperationResult } from '../domain/types.js';
import type { JoplinAdapter } from '../adapter/joplin-adapter.js';
import type { StateRepository } from '../state/state-repository.js';
export interface ProfileSession {
finish<T>(value: T, mutated: boolean, clientId: string): Promise<OperationResult<T>>;
abort(): void;
}
export class ProfileActor {
private tail: Promise<void> = Promise.resolve();
private stopped = false;
private periodicTimer?: NodeJS.Timeout;
public constructor(
public readonly adapter: JoplinAdapter,
private readonly state: StateRepository,
private readonly periodicSyncSeconds: number,
) {}
public async start(): Promise<void> {
await this.adapter.prepare();
this.periodicTimer = setInterval(() => {
void this.syncWhenIdle();
}, this.periodicSyncSeconds * 1000);
this.periodicTimer.unref();
}
public async stop(): Promise<void> {
this.stopped = true;
if (this.periodicTimer) clearInterval(this.periodicTimer);
await this.tail.catch(() => undefined);
await this.adapter.close();
}
public async run<T>(clientId: string, mutation: boolean, operation: (adapter: JoplinAdapter) => Promise<T>): Promise<OperationResult<T>> {
const session = await this.begin();
try {
const value = await operation(this.adapter);
return await session.finish(value, mutation, clientId);
} catch (error) {
session.abort();
throw error;
}
}
public async begin(): Promise<ProfileSession> {
if (this.stopped) throw new GatewayError(503, 'SERVICE_STOPPING', 'Gateway is stopping');
let release!: () => void;
const turn = new Promise<void>(resolve => { release = resolve; });
const previous = this.tail;
this.tail = previous.catch(() => undefined).then(() => turn);
await previous.catch(() => undefined);
try {
await this.adapter.sync();
await this.state.clearPending().catch(() => undefined);
} catch (error) {
release();
throw new GatewayError(503, 'SYNC_UNAVAILABLE', 'Joplin synchronization is unavailable', {
cause: asError(error).message,
});
}
let finished = false;
const finish = async <T>(value: T, mutated: boolean, clientId: string): Promise<OperationResult<T>> => {
if (finished) throw new Error('Profile session already finished');
finished = true;
if (!mutated) {
release();
return { data: value, sync: { status: 'synced', local_change_applied: false } };
}
try {
await this.adapter.sync();
await this.state.clearPending().catch(() => undefined);
release();
return { data: value, sync: { status: 'synced', local_change_applied: true } };
} catch (error) {
let operation_id: string = randomUUID();
try {
operation_id = await this.state.recordPending(clientId, asError(error).message);
} catch {
// The degraded response remains truthful even if its recovery record could not be persisted.
} finally {
release();
}
return {
data: value,
sync: {
status: 'pending',
local_change_applied: true,
operation_id,
warning: 'The change is local and has not been confirmed on Joplin Server',
},
};
}
};
return {
finish,
abort: () => {
if (!finished) {
finished = true;
release();
}
},
};
}
private async syncWhenIdle(): Promise<void> {
if (this.stopped) return;
let release!: () => void;
const turn = new Promise<void>(resolve => { release = resolve; });
const previous = this.tail;
this.tail = previous.catch(() => undefined).then(() => turn);
await previous.catch(() => undefined);
try {
await this.adapter.sync();
await this.state.clearPending();
} catch {
// A request will surface the failure. Periodic retry remains deliberately quiet.
} finally {
release();
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import { hashSecret } from '../auth/secrets.js';
const secret = process.argv[2];
if (!secret) {
process.stderr.write('Usage: npm run hash-secret -- <secret>\n');
process.exitCode = 1;
} else {
process.stdout.write(`${await hashSecret(secret)}\n`);
}
+118
View File
@@ -0,0 +1,118 @@
import { randomBytes } from 'node:crypto';
import type { JoplinAdapter, NoteQuery } from '../src/adapter/joplin-adapter.js';
import type { ChangePage, Note, Notebook, Pagination, RevisionRecord, Tag } from '../src/domain/types.js';
const id = () => randomBytes(16).toString('hex');
export class FakeAdapter implements JoplinAdapter {
public notebooks: Notebook[] = [];
public notes: Note[] = [];
public tags: Tag[] = [];
public revisions: RevisionRecord[] = [];
public syncCalls = 0;
public dataCalls = 0;
public failSyncCalls = new Set<number>();
public delayMs = 0;
public concurrentCalls = 0;
public maxConcurrentCalls = 0;
public async prepare(): Promise<void> {}
public async close(): Promise<void> {}
public async sync(): Promise<void> {
await this.track(async () => {
this.syncCalls += 1;
if (this.failSyncCalls.has(this.syncCalls)) throw new Error('sync offline');
});
}
public async listNotebooks(): Promise<Notebook[]> {
return this.data(() => structuredClone(this.notebooks));
}
public async getNotebook(notebookId: string): Promise<Notebook | null> {
return this.data(() => structuredClone(this.notebooks.find(item => item.id === notebookId) ?? null));
}
public async createNotebook(input: { title: string; parent_id?: string }): Promise<Notebook> {
return this.data(() => {
const notebook: Notebook = { id: id(), parent_id: input.parent_id ?? '', title: input.title, created_time: Date.now(), updated_time: Date.now() };
this.notebooks.push(notebook);
return structuredClone(notebook);
});
}
public async listNotes(query: NoteQuery): Promise<Note[]> {
return this.data(() => structuredClone(this.notes.filter(note =>
(query.parent_id === undefined || note.parent_id === query.parent_id) && (query.include_deleted || !note.deleted_time))));
}
public async getNote(noteId: string, includeDeleted = false): Promise<Note | null> {
return this.data(() => {
const note = this.notes.find(item => item.id === noteId && (includeDeleted || !item.deleted_time));
return structuredClone(note ?? null);
});
}
public async createNote(input: { parent_id: string; title: string; body: string }): Promise<Note> {
return this.data(() => {
const now = Date.now();
const note: Note = { id: id(), ...input, created_time: now, updated_time: now, deleted_time: 0, is_todo: 0, is_conflict: 0 };
this.notes.push(note);
return structuredClone(note);
});
}
public async updateNote(noteId: string, input: Partial<Pick<Note, 'parent_id' | 'title' | 'body' | 'deleted_time'>>): Promise<Note> {
return this.data(() => {
const note = this.notes.find(item => item.id === noteId);
if (!note) throw new Error('not found');
Object.assign(note, input, { updated_time: Date.now() });
return structuredClone(note);
});
}
public async deleteNote(noteId: string): Promise<void> {
await this.data(() => {
const note = this.notes.find(item => item.id === noteId);
if (note) note.deleted_time = Date.now();
});
}
public async listTags(): Promise<Tag[]> { return this.data(() => structuredClone(this.tags)); }
public async getTag(tagId: string): Promise<Tag | null> { return this.data(() => structuredClone(this.tags.find(tag => tag.id === tagId) ?? null)); }
public async createTag(input: { title: string }): Promise<Tag> {
return this.data(() => {
const tag = { id: id(), title: input.title };
this.tags.push(tag);
return structuredClone(tag);
});
}
public async listNoteTags(): Promise<Tag[]> { return this.data(() => []); }
public async listTagNotes(): Promise<Note[]> { return this.data(() => []); }
public async addTagToNote(): Promise<void> { await this.data(() => undefined); }
public async removeTagFromNote(): Promise<void> { await this.data(() => undefined); }
public async search(_query: string, _pagination: Pagination): Promise<Note[]> { return this.data(() => structuredClone(this.notes)); }
public async listRevisions(noteId: string): Promise<RevisionRecord[]> {
return this.data(() => structuredClone(this.revisions.filter(revision => revision.item_id === noteId)));
}
public async listChanges(cursor: string | undefined): Promise<ChangePage> {
return this.data(() => ({ items: [], cursor: cursor ?? '0', has_more: false }));
}
private async data<T>(operation: () => T): Promise<T> {
this.dataCalls += 1;
return this.track(operation);
}
private async track<T>(operation: () => T): Promise<T> {
this.concurrentCalls += 1;
this.maxConcurrentCalls = Math.max(this.maxConcurrentCalls, this.concurrentCalls);
try {
if (this.delayMs) await new Promise(resolve => setTimeout(resolve, this.delayMs));
return operation();
} finally {
this.concurrentCalls -= 1;
}
}
}
+28
View File
@@ -0,0 +1,28 @@
import DiffMatchPatch from 'diff-match-patch';
import { describe, expect, test } from 'vitest';
import { reconstructRevision } from '../src/domain/revisions.js';
import type { RevisionRecord } from '../src/domain/types.js';
const dmp = new DiffMatchPatch();
const patch = (before: string, after: string) => JSON.stringify(dmp.patch_make(before, after));
describe('revision reconstruction', () => {
test('follows Joplin parent revisions and applies text and metadata patches', () => {
const first: RevisionRecord = {
id: 'r1', parent_id: '', item_id: 'note', item_type: 1, item_updated_time: 10,
title_diff: patch('', 'Title one'), body_diff: patch('', 'First body'),
metadata_diff: JSON.stringify({ new: { parent_id: 'folder', user_created_time: 1, user_updated_time: 10 }, deleted: [] }),
encryption_applied: 0,
};
const second: RevisionRecord = {
id: 'r2', parent_id: 'r1', item_id: 'note', item_type: 1, item_updated_time: 20,
title_diff: patch('Title one', 'Title two'), body_diff: patch('First body', 'Second body'),
metadata_diff: JSON.stringify({ new: { user_updated_time: 20 }, deleted: [] }),
encryption_applied: 0,
};
expect(reconstructRevision([first, second], 'r2')).toEqual({
id: 'r2', note_id: 'note', item_updated_time: 20, title: 'Title two', body: 'Second body',
parent_id: 'folder', user_created_time: 1, user_updated_time: 20,
});
});
});
+217
View File
@@ -0,0 +1,217 @@
import { mkdtemp } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { afterEach, describe, expect, test } from 'vitest';
import type { FastifyInstance } from 'fastify';
import type { GatewayConfig } from '../src/config.js';
import { buildServer } from '../src/http/server.js';
import type { Note, Notebook, NotebookGrant } from '../src/domain/types.js';
import { FakeAdapter } from './fake-adapter.js';
const ROOT = '11111111111111111111111111111111';
const CHILD = '22222222222222222222222222222222';
const PRIVATE = '33333333333333333333333333333333';
const NOTE = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const PRIVATE_NOTE = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
const notebooks: Notebook[] = [
{ id: ROOT, parent_id: '', title: 'Root' },
{ id: CHILD, parent_id: ROOT, title: 'Allowed child' },
{ id: PRIVATE, parent_id: '', title: 'Private' },
];
const note = (noteId: string, parentId: string, title: string): Note => ({
id: noteId, parent_id: parentId, title, body: `${title} body`, created_time: 1, updated_time: 2,
deleted_time: 0, is_todo: 0, is_conflict: 0,
});
describe('gateway HTTP API', () => {
let app: FastifyInstance | undefined;
afterEach(async () => {
if (app) await app.close();
app = undefined;
});
test('authenticates a client and filters inaccessible notes and search results', async () => {
const fake = new FakeAdapter();
fake.notebooks = structuredClone(notebooks);
fake.notes = [note(NOTE, CHILD, 'visible'), note(PRIVATE_NOTE, PRIVATE, 'secret')];
app = await buildServer(await config(), fake);
const token = await issueToken(app);
const list = await app.inject({ method: 'GET', url: '/api/v1/notes', headers: { authorization: `Bearer ${token}` } });
expect(list.statusCode).toBe(200);
expect(list.json().items.map((item: Note) => item.id)).toEqual([NOTE]);
const hidden = await app.inject({ method: 'GET', url: `/api/v1/notes/${PRIVATE_NOTE}`, headers: { authorization: `Bearer ${token}` } });
const absent = await app.inject({ method: 'GET', url: '/api/v1/notes/does-not-exist', headers: { authorization: `Bearer ${token}` } });
expect(hidden.statusCode).toBe(404);
expect(hidden.json().error.code).toBe('NOTE_NOT_FOUND');
expect(absent.json().error.code).toBe('NOTE_NOT_FOUND');
const search = await app.inject({ method: 'GET', url: '/api/v1/search?query=body', headers: { authorization: `Bearer ${token}` } });
expect(search.json().items.map((item: Note) => item.id)).toEqual([NOTE]);
});
test('returns only path ancestors when access starts at a nested notebook', async () => {
const fake = new FakeAdapter();
fake.notebooks = structuredClone(notebooks);
app = await buildServer(await config([{ notebook_id: CHILD, access: 'read' }]), fake);
const token = await issueToken(app);
const response = await app.inject({ method: 'GET', url: '/api/v1/notebooks', headers: { authorization: `Bearer ${token}` } });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual([
expect.objectContaining({ id: ROOT, access: 'path_only', children: [expect.objectContaining({ id: CHILD, access: 'read' })] }),
]);
expect(JSON.stringify(response.json())).not.toContain('Private');
});
test('does not access local data when pre-operation sync fails', async () => {
const fake = new FakeAdapter();
fake.notebooks = structuredClone(notebooks);
fake.failSyncCalls.add(1);
app = await buildServer(await config(), fake);
const token = await issueToken(app);
const response = await app.inject({ method: 'GET', url: '/api/v1/notes', headers: { authorization: `Bearer ${token}` } });
expect(response.statusCode).toBe(503);
expect(response.json().error.code).toBe('SYNC_UNAVAILABLE');
expect(fake.dataCalls).toBe(0);
});
test('reports degraded local success after a post-mutation sync failure', async () => {
const fake = new FakeAdapter();
fake.notebooks = structuredClone(notebooks);
fake.failSyncCalls.add(2);
app = await buildServer(await config(), fake);
const token = await issueToken(app);
const response = await app.inject({
method: 'POST', url: '/api/v1/notes', headers: { authorization: `Bearer ${token}` },
payload: { parent_id: CHILD, title: 'new', body: 'local body' },
});
expect(response.statusCode).toBe(202);
expect(response.json()).toEqual(expect.objectContaining({ title: 'new', sync: expect.objectContaining({ status: 'pending', local_change_applied: true }) }));
expect(fake.notes.some(item => item.title === 'new')).toBe(true);
});
test('persists and reports the automatic write grant for a created top-level notebook', async () => {
const fake = new FakeAdapter();
fake.notebooks = structuredClone(notebooks);
app = await buildServer(await config([]), fake);
const token = await issueToken(app);
const created = await app.inject({
method: 'POST', url: '/api/v1/notebooks', headers: { authorization: `Bearer ${token}` }, payload: { title: 'Client root' },
});
expect(created.statusCode).toBe(201);
const createdId = created.json().id as string;
const me = await app.inject({ method: 'GET', url: '/api/v1/me', headers: { authorization: `Bearer ${token}` } });
expect(me.statusCode).toBe(200);
expect(me.json().permissions.notebooks).toContainEqual({ notebook_id: createdId, access: 'write' });
});
test('allows reading trash under a deleted original notebook but rejects restoration', async () => {
const fake = new FakeAdapter();
const deletedFolder = { id: CHILD, parent_id: ROOT, title: 'Deleted', deleted_time: 10 };
fake.notebooks = [notebooks[0]!, deletedFolder];
fake.notes = [{ ...note(NOTE, CHILD, 'trashed'), deleted_time: 20 }];
app = await buildServer(await config(), fake);
const token = await issueToken(app);
const read = await app.inject({ method: 'GET', url: `/api/v1/trash/notes/${NOTE}`, headers: { authorization: `Bearer ${token}` } });
expect(read.statusCode).toBe(200);
const restore = await app.inject({ method: 'POST', url: `/api/v1/trash/notes/${NOTE}/restore`, headers: { authorization: `Bearer ${token}` } });
expect(restore.statusCode).toBe(409);
expect(restore.json().error.code).toBe('RESTORE_DESTINATION_MISSING');
});
test('serializes concurrent requests through one profile owner', async () => {
const fake = new FakeAdapter();
fake.notebooks = structuredClone(notebooks);
fake.notes = [note(NOTE, CHILD, 'visible')];
fake.delayMs = 5;
app = await buildServer(await config(), fake);
const token = await issueToken(app);
await Promise.all([
app.inject({ method: 'GET', url: '/api/v1/notes', headers: { authorization: `Bearer ${token}` } }),
app.inject({ method: 'GET', url: `/api/v1/notes/${NOTE}`, headers: { authorization: `Bearer ${token}` } }),
]);
expect(fake.maxConcurrentCalls).toBe(1);
});
test('exposes the same permission-filtered data through GraphQL', async () => {
const fake = new FakeAdapter();
fake.notebooks = structuredClone(notebooks);
fake.notes = [note(NOTE, CHILD, 'visible'), note(PRIVATE_NOTE, PRIVATE, 'secret')];
app = await buildServer(await config(), fake);
const token = await issueToken(app);
const response = await app.inject({
method: 'POST', url: '/graphql', headers: { authorization: `Bearer ${token}` },
payload: { query: '{ notes { items { id title } page_info { page has_more } } notebooks { id } }' },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ data: {
notes: { items: [{ id: NOTE, title: 'visible' }], page_info: { page: 1, has_more: false } },
notebooks: expect.any(Array),
} });
expect(fake.syncCalls).toBe(1);
});
test('performs one post-sync for a GraphQL mutation document and exposes degraded status', async () => {
const fake = new FakeAdapter();
fake.notebooks = structuredClone(notebooks);
fake.failSyncCalls.add(2);
app = await buildServer(await config(), fake);
const token = await issueToken(app);
const response = await app.inject({
method: 'POST', url: '/graphql', headers: { authorization: `Bearer ${token}` },
payload: {
query: 'mutation { create_note(input: { parent_id: "22222222222222222222222222222222", title: "new", body: "body" }) { id sync { status local_change_applied operation_id } } }',
},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.create_note.sync).toEqual(expect.objectContaining({ status: 'pending', local_change_applied: true }));
expect(response.json().data.create_note.sync.operation_id).toEqual(expect.any(String));
expect(fake.syncCalls).toBe(2);
});
test('returns stable GraphQL error extensions', async () => {
const fake = new FakeAdapter();
fake.notebooks = structuredClone(notebooks);
app = await buildServer(await config(), fake);
const token = await issueToken(app);
const response = await app.inject({
method: 'POST', url: '/graphql', headers: { authorization: `Bearer ${token}` },
payload: { query: '{ notes(page: 0) { items { id } } }' },
});
expect(response.statusCode).toBe(200);
expect(response.json().errors[0].extensions).toEqual(expect.objectContaining({
code: 'PAGINATION_INVALID', request_id: expect.any(String),
}));
});
});
async function config(grants: NotebookGrant[] = [{ notebook_id: ROOT, access: 'write' }]): Promise<GatewayConfig> {
const directory = await mkdtemp(join(tmpdir(), 'jcg-test-'));
return {
server: { host: '127.0.0.1', port: 8080, trust_proxy: false },
auth: { issuer: 'test', audience: 'test-api', token_ttl_seconds: 3600 },
joplin: {
executable: 'joplin', profile_dir: '/test/profile', api_host: '127.0.0.1', api_port: 41184,
api_token: 'test', command_timeout_ms: 1000, server_start_timeout_ms: 1000, periodic_sync_seconds: 86400,
},
state_file: join(directory, 'state.json'),
clients: [{
client_id: 'client-a', enabled: true, client_secret: 'secret-a',
permissions: { full_access: false, create_notebooks: true, create_tags: true, notebooks: grants },
}],
};
}
async function issueToken(server: FastifyInstance): Promise<string> {
const response = await server.inject({
method: 'POST', url: '/oauth/token',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
payload: 'grant_type=client_credentials&client_id=client-a&client_secret=secret-a',
});
expect(response.statusCode).toBe(200);
return response.json().access_token as string;
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"sourceMap": true,
"declaration": true
},
"include": ["src/**/*.ts"],
"exclude": ["test/**/*.ts"]
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"useUnknownInCatchVariables": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}