143 lines
5.5 KiB
TypeScript
143 lines
5.5 KiB
TypeScript
import {
|
|
createConnection,
|
|
deleteConnection,
|
|
devicePoll,
|
|
deviceStart,
|
|
getConnection,
|
|
listConnections,
|
|
listRuns,
|
|
updateConnection,
|
|
} from "@sttracker/core/connections";
|
|
import { ValidationError } from "@sttracker/core/errors";
|
|
import { enqueue } from "@sttracker/core/jobs";
|
|
import type { PluginRegistry } from "@sttracker/core/registry";
|
|
import { z } from "zod";
|
|
import { HttpError, json, noContent, route } from "../http.ts";
|
|
import { formFile } from "./form.ts";
|
|
import { qInt } from "./schemas.ts";
|
|
|
|
const ALREADY_RUNNING = "A sync for this source is already queued or running.";
|
|
|
|
function pluginOf(registry: PluginRegistry, pluginId: string) {
|
|
const plugin = registry.get(pluginId);
|
|
if (!plugin) throw new ValidationError("pluginId", "not_installed");
|
|
return plugin;
|
|
}
|
|
|
|
async function enqueueSync(db: Parameters<typeof enqueue>[0], connectionId: string, payload: Record<string, unknown>): Promise<Response> {
|
|
const jobId = await enqueue(db, "sync", { connectionId, ...payload }, { lockKey: `sync:${connectionId}` });
|
|
if (jobId === null) throw new HttpError(409, "conflict", ALREADY_RUNNING);
|
|
return json({ jobId }, 202);
|
|
}
|
|
|
|
const schedule = z.number().int().min(1).nullable();
|
|
const config = z.record(z.string(), z.unknown());
|
|
const creds = z.record(z.string(), z.string());
|
|
|
|
export const sourceRoutes = [
|
|
route({
|
|
method: "GET",
|
|
path: "plugins",
|
|
summary: "Installed source plugins (manifests, plus deviceAuth: whether sign-in uses a device code).",
|
|
auth: "any:read",
|
|
handler: async ({ deps }) => deps.registry.list().map((m) => ({ ...m, deviceAuth: !!deps.registry.get(m.id)?.deviceAuth })),
|
|
}),
|
|
route({
|
|
method: "GET",
|
|
path: "sources",
|
|
summary: "Your source connections (never includes secrets).",
|
|
auth: "any:read",
|
|
handler: async ({ deps, user }) => listConnections(deps.db, user.id),
|
|
}),
|
|
route({
|
|
method: "POST",
|
|
path: "sources",
|
|
summary: "Connect a source. Config fields typed secret are stored encrypted.",
|
|
auth: "session",
|
|
body: z
|
|
.object({ pluginId: z.string().min(1), name: z.string().min(1).max(200), config: config.optional(), credentials: creds.optional(), scheduleMinutes: schedule.optional() })
|
|
.strict(),
|
|
handler: async ({ deps, user, body }) => {
|
|
const plugin = pluginOf(deps.registry, body.pluginId);
|
|
const id = await createConnection(deps.db, deps.secret, user.id, { ...body, config: body.config ?? {} }, plugin.manifest);
|
|
return json(await getConnection(deps.db, user.id, id), 201);
|
|
},
|
|
}),
|
|
route({
|
|
method: "PATCH",
|
|
path: "sources/:id",
|
|
summary: "Edit a source connection. Omitted secrets are kept.",
|
|
auth: "session",
|
|
body: z
|
|
.object({ name: z.string().min(1).max(200), config, credentials: creds, scheduleMinutes: schedule, status: z.enum(["active", "paused"]) })
|
|
.partial()
|
|
.strict(),
|
|
handler: async ({ deps, user, params, body }) => {
|
|
const conn = await getConnection(deps.db, user.id, params.id!);
|
|
await updateConnection(deps.db, deps.secret, user.id, conn.id, body, pluginOf(deps.registry, conn.pluginId).manifest);
|
|
return getConnection(deps.db, user.id, conn.id);
|
|
},
|
|
}),
|
|
route({
|
|
method: "DELETE",
|
|
path: "sources/:id",
|
|
summary: "Disconnect a source: pauses it and wipes its credentials. Imported data stays.",
|
|
auth: "session",
|
|
handler: async ({ deps, user, params }) => {
|
|
await getConnection(deps.db, user.id, params.id!);
|
|
await deleteConnection(deps.db, user.id, params.id!);
|
|
return noContent();
|
|
},
|
|
}),
|
|
route({
|
|
method: "POST",
|
|
path: "sources/:id/run",
|
|
summary: "Queue a sync now. 409 if one is already queued or running.",
|
|
auth: "any:write",
|
|
body: z.object({ mode: z.enum(["full", "changes"]).optional() }).strict().optional(),
|
|
handler: async ({ deps, user, params, body }) => {
|
|
const conn = await getConnection(deps.db, user.id, params.id!);
|
|
return enqueueSync(deps.db, conn.id, body?.mode ? { mode: body.mode } : {});
|
|
},
|
|
}),
|
|
route({
|
|
method: "POST",
|
|
path: "sources/:id/import",
|
|
summary: "Upload an export file (multipart: file) and queue a file-mode sync.",
|
|
auth: "any:write",
|
|
multipart: true,
|
|
handler: async ({ deps, user, params, form }) => {
|
|
const conn = await getConnection(deps.db, user.id, params.id!);
|
|
if (!pluginOf(deps.registry, conn.pluginId).importFile) throw new ValidationError("pluginId", "cannot_import_files");
|
|
const file = formFile(form);
|
|
const base64 = Buffer.from(await file.arrayBuffer()).toString("base64");
|
|
return enqueueSync(deps.db, conn.id, { file: { name: file.name, base64 } });
|
|
},
|
|
}),
|
|
route({
|
|
method: "POST",
|
|
path: "sources/:id/device/start",
|
|
summary: "Start a device-code sign-in with the source.",
|
|
auth: "session",
|
|
handler: async ({ deps, user, params }) => deviceStart(deps.db, deps.secret, deps.registry, user.id, params.id!),
|
|
}),
|
|
route({
|
|
method: "POST",
|
|
path: "sources/:id/device/poll",
|
|
summary: "Poll a device-code sign-in: pending or connected.",
|
|
auth: "session",
|
|
body: z.object({ deviceCode: z.string().min(1).max(2000) }).strict(),
|
|
handler: async ({ deps, user, params, body }) => ({
|
|
status: await devicePoll(deps.db, deps.secret, deps.registry, user.id, params.id!, body.deviceCode),
|
|
}),
|
|
}),
|
|
route({
|
|
method: "GET",
|
|
path: "sources/:id/runs",
|
|
summary: "Recent sync runs of a source, newest first.",
|
|
auth: "any:read",
|
|
query: z.object({ limit: qInt.optional() }),
|
|
handler: async ({ deps, user, params, query }) => listRuns(deps.db, user.id, params.id!, query.limit),
|
|
}),
|
|
];
|