feat(core): API support helpers: token list, suggestions, attachment links, connection runs, device auth, file-mode sync
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7f3d0d5c44
commit
7b0a6738dd
5 files changed
+378
-12
No files matched your search
@@ -1,14 +1,14 @@
|
||||
import type { Db } from "@sttracker/db/db";
|
||||
import { newId, nowIso } from "@sttracker/db/ids";
|
||||
import { fromJson, toJson } from "@sttracker/db/json";
|
||||
import type { ConnectionStatus } from "@sttracker/db/schema";
|
||||
import type { ConnectionStatus, RunMode, RunStatus } from "@sttracker/db/schema";
|
||||
import { createHttpClient, type HttpClientOptions } from "@sttracker/plugin-sdk/http";
|
||||
import type { HttpClient, PluginContext, PluginManifest } from "@sttracker/plugin-sdk/types";
|
||||
import { decryptJson, encryptJson } from "./crypto.ts";
|
||||
import { CryptoError, NotFoundError, ValidationError } from "./errors.ts";
|
||||
import { DropJobError, NonRetryableError, type JobContext } from "./jobs.ts";
|
||||
import type { PluginRegistry } from "./registry.ts";
|
||||
import { runSync, type Connection, type RunSummary } from "./sync/run.ts";
|
||||
import { runSync, type Connection, type RunSummary, type SyncMode } from "./sync/run.ts";
|
||||
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -143,6 +143,104 @@ export async function listConnections(db: Db, userId: string): Promise<Connectio
|
||||
}));
|
||||
}
|
||||
|
||||
/** A deleted connection is paused with its credentials wiped (`deleteConnection`). */
|
||||
export function isDeletedConnection(c: Pick<ConnectionView, "status" | "hasCredentials">): boolean {
|
||||
return c.status === "paused" && !c.hasCredentials;
|
||||
}
|
||||
|
||||
async function ownedRow(db: Db, userId: string, id: string) {
|
||||
const r = await db.k.selectFrom("source_connections").selectAll().where("id", "=", id).where("user_id", "=", userId).executeTakeFirst();
|
||||
if (!r || (r.status === "paused" && r.credentials_enc === null)) throw new NotFoundError("connection");
|
||||
return r;
|
||||
}
|
||||
|
||||
/** One of the user's live connections, without secrets. Deleted or foreign connections throw `NotFoundError`. */
|
||||
export async function getConnection(db: Db, userId: string, id: string): Promise<ConnectionView> {
|
||||
const r = await ownedRow(db, userId, id);
|
||||
return {
|
||||
id: r.id,
|
||||
pluginId: r.plugin_id,
|
||||
name: r.name,
|
||||
config: fromJson<Record<string, unknown>>(r.config, {}),
|
||||
scheduleMinutes: r.schedule_minutes,
|
||||
status: r.status,
|
||||
lastRunAt: r.last_run_at,
|
||||
createdAt: r.created_at,
|
||||
hasCredentials: r.credentials_enc !== null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface RunView {
|
||||
id: string;
|
||||
mode: RunMode;
|
||||
status: RunStatus;
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
counts: Record<string, number>;
|
||||
log: string;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** Runs of one of the user's connections, newest first (at most `limit`, default 50). */
|
||||
export async function listRuns(db: Db, userId: string, connectionId: string, limit = 50): Promise<RunView[]> {
|
||||
await ownedRow(db, userId, connectionId);
|
||||
const rows = await db.k
|
||||
.selectFrom("source_runs")
|
||||
.selectAll()
|
||||
.where("connection_id", "=", connectionId)
|
||||
.orderBy("started_at", "desc")
|
||||
.orderBy("id", "desc")
|
||||
.limit(Math.min(Math.max(limit, 1), 200))
|
||||
.execute();
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
mode: r.mode,
|
||||
status: r.status,
|
||||
startedAt: r.started_at,
|
||||
finishedAt: r.finished_at,
|
||||
counts: fromJson<Record<string, number>>(r.counts, {}),
|
||||
log: r.log,
|
||||
error: r.error,
|
||||
}));
|
||||
}
|
||||
|
||||
async function deviceContext(db: Db, secret: string, registry: PluginRegistry, userId: string, id: string) {
|
||||
const r = await ownedRow(db, userId, id);
|
||||
const plugin = registry.get(r.plugin_id);
|
||||
if (!plugin) throw new ValidationError("pluginId", "not_installed");
|
||||
if (!plugin.deviceAuth) throw new ValidationError("pluginId", "no_device_auth");
|
||||
const conn: Connection = {
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
pluginId: r.plugin_id,
|
||||
name: r.name,
|
||||
config: fromJson<Record<string, unknown>>(r.config, {}),
|
||||
credentialsEnc: r.credentials_enc,
|
||||
scheduleMinutes: r.schedule_minutes,
|
||||
cursor: r.cursor,
|
||||
status: r.status,
|
||||
lastRunAt: r.last_run_at,
|
||||
createdAt: r.created_at,
|
||||
};
|
||||
const ctx = await makePluginContext(db, secret, conn, "device", plugin.manifest);
|
||||
return { deviceAuth: plugin.deviceAuth, ctx };
|
||||
}
|
||||
|
||||
/** Start a device-code sign-in for one of the user's connections. */
|
||||
export async function deviceStart(db: Db, secret: string, registry: PluginRegistry, userId: string, id: string) {
|
||||
const { deviceAuth, ctx } = await deviceContext(db, secret, registry, userId, id);
|
||||
return deviceAuth.start(ctx);
|
||||
}
|
||||
|
||||
/** Poll a device-code sign-in. On success the returned credentials are merged into the encrypted blob. */
|
||||
export async function devicePoll(db: Db, secret: string, registry: PluginRegistry, userId: string, id: string, deviceCode: string): Promise<"pending" | "connected"> {
|
||||
const { deviceAuth, ctx } = await deviceContext(db, secret, registry, userId, id);
|
||||
const res = await deviceAuth.poll(ctx, deviceCode);
|
||||
if (res === "pending") return "pending";
|
||||
await ctx.credentials.save(res);
|
||||
return "connected";
|
||||
}
|
||||
|
||||
export const USER_AGENT = "STTracker/0.1 (+self-hosted)";
|
||||
|
||||
export interface ContextOpts {
|
||||
@@ -215,7 +313,7 @@ export interface SyncHandlerDeps {
|
||||
const MSG_CRYPTO = "Cannot decrypt this source's credentials. Was STTRACKER_SECRET changed? Reconnect the source to fix it.";
|
||||
|
||||
/** Record a run that failed before `runSync` could start, flag the connection, and stop the job without retry. */
|
||||
async function failBeforeRun(db: Db, connectionId: string, mode: "full" | "changes", message: string): Promise<never> {
|
||||
async function failBeforeRun(db: Db, connectionId: string, mode: RunMode, message: string): Promise<never> {
|
||||
const now = nowIso();
|
||||
await db.k
|
||||
.insertInto("source_runs")
|
||||
@@ -232,16 +330,25 @@ async function failBeforeRun(db: Db, connectionId: string, mode: "full" | "chang
|
||||
* run, mark the connection `error` and fail the job without retry. A failed run is rethrown so the worker retries.
|
||||
* `ctx.log` output is appended to the run's `source_runs.log`.
|
||||
*/
|
||||
export function syncHandler(deps: SyncHandlerDeps): (payload: { connectionId: string; mode?: "full" | "changes" }, jc?: JobContext) => Promise<RunSummary | void> {
|
||||
export interface SyncPayload {
|
||||
connectionId: string;
|
||||
mode?: "full" | "changes";
|
||||
/** File mode: run the plugin's `importFile` over these bytes instead of pulling. */
|
||||
file?: { name: string; base64: string };
|
||||
}
|
||||
|
||||
export function syncHandler(deps: SyncHandlerDeps): (payload: SyncPayload, jc?: JobContext) => Promise<RunSummary | void> {
|
||||
return async (payload, jc) => {
|
||||
const { db } = deps;
|
||||
const full = await db.k.selectFrom("source_connections").selectAll().where("id", "=", payload.connectionId).executeTakeFirst();
|
||||
if (!full) throw new DropJobError("connection not found");
|
||||
if (full.status === "paused") throw new DropJobError("connection is paused");
|
||||
const plugin = deps.registry.get(full.plugin_id);
|
||||
if (!plugin) return failBeforeRun(db, full.id, payload.mode ?? "full", `Plugin '${full.plugin_id}' is not installed or failed to load.`);
|
||||
let mode: "full" | "changes" = "full";
|
||||
if (plugin.pullChanges && payload.mode !== "full") {
|
||||
if (!plugin) return failBeforeRun(db, full.id, payload.file ? "file" : (payload.mode ?? "full"), `Plugin '${full.plugin_id}' is not installed or failed to load.`);
|
||||
let mode: SyncMode = "full";
|
||||
if (payload.file) {
|
||||
mode = { file: { name: payload.file.name, bytes: new Uint8Array(Buffer.from(payload.file.base64, "base64")) } };
|
||||
} else if (plugin.pullChanges && payload.mode !== "full") {
|
||||
const cutoff = new Date(Date.now() - WEEK_MS).toISOString();
|
||||
const recentFull = await db.k
|
||||
.selectFrom("source_runs")
|
||||
@@ -270,7 +377,7 @@ export function syncHandler(deps: SyncHandlerDeps): (payload: { connectionId: st
|
||||
try {
|
||||
creds = await readCreds(deps.secret, conn.credentialsEnc);
|
||||
} catch (e) {
|
||||
if (e instanceof CryptoError) return failBeforeRun(db, full.id, mode, MSG_CRYPTO);
|
||||
if (e instanceof CryptoError) return failBeforeRun(db, full.id, typeof mode === "string" ? mode : "file", MSG_CRYPTO);
|
||||
throw e;
|
||||
}
|
||||
const logs: string[] = [];
|
||||
|
||||
Reference in new issue
Block a user