Initial implementation
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ClientIdentity } from '../domain/types.js';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
clientIdentity: ClientIdentity | null;
|
||||
}
|
||||
}
|
||||
+15
@@ -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 });
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
Reference in New Issue
Block a user