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
+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`);
}