Files
fusion/plugins/fusion-plugin-cli-printing-press/src/store/cli-press-store.ts
Fusion 1e76f247e4 feat(FN-3766): add cli press sqlite store and credential helpers
Implements SQLite-based storage and credential management for the CLI printing press plugin, including type definitions, a persistent `cli-press-store`, and credential helpers, with updated routes, tests, and documentation.

Fusion-Task-Id: FN-3766
2026-05-10 19:13:05 -07:00

428 lines
16 KiB
TypeScript

import { randomUUID } from "node:crypto";
import type { Database } from "@fusion/core";
import {
InvalidCredentialPlacementError,
OAuthNotSupportedError,
type CliArtifact,
type CliArtifactCreateInput,
type CliArtifactUpdateInput,
type CliSpec,
type CliSpecCreateInput,
type CliSpecUpdateInput,
type Credential,
type CredentialCreateInput,
type CredentialUpdateInput,
type Service,
type ServiceCreateInput,
type ServiceSetting,
type ServiceSettingCreateInput,
type ServiceUpdateInput,
} from "./cli-press-types.js";
interface ServiceRow {
id: string;
slug: string;
displayName: string;
description: string | null;
baseUrl: string;
sourceKind: Service["sourceKind"];
sourceRef: string | null;
createdAt: string;
updatedAt: string;
}
interface CliSpecRow {
id: string;
serviceId: string;
name: string;
version: string;
generatorVersion: string;
specJson: string;
generatedAt: string | null;
status: CliSpec["status"];
lastGenerationError: string | null;
createdAt: string;
updatedAt: string;
}
interface CliArtifactRow {
id: string;
cliSpecId: string;
kind: CliArtifact["kind"];
path: string;
executable: number;
checksum: string | null;
sizeBytes: number | null;
createdAt: string;
updatedAt: string;
}
interface CredentialRow {
id: string;
serviceId: string;
name: string;
kind: Credential["kind"];
value: string;
placement: string;
createdAt: string;
updatedAt: string;
}
interface ServiceSettingRow {
id: string;
serviceId: string;
key: string;
value: string;
scope: ServiceSetting["scope"];
createdAt: string;
updatedAt: string;
}
const OAUTH_KINDS = new Set(["oauth", "oauth2"]);
function parseJson<T>(value: string): T {
return JSON.parse(value) as T;
}
function nowIso(): string {
return new Date().toISOString();
}
function createId(prefix: "svc" | "cli" | "art" | "cred" | "set"): string {
return `${prefix}_${randomUUID()}`;
}
function assertCredentialSupported(kind: string): void {
if (OAUTH_KINDS.has(kind)) {
throw new OAuthNotSupportedError(kind);
}
}
function assertPlacementConsistency(
kind: Credential["kind"] | string,
placement: Credential["placement"] | { kind?: string; header?: string; queryParam?: string },
): void {
const placementKind = (placement as { kind?: string }).kind;
if (placementKind !== kind) {
throw new InvalidCredentialPlacementError({ credentialKind: kind, placementKind: String(placementKind) });
}
if (kind === "api_key") {
const candidate = placement as { kind?: string; header?: string; queryParam?: string };
const hasHeader = typeof candidate.header === "string" && candidate.header.trim().length > 0;
const hasQuery = typeof candidate.queryParam === "string" && candidate.queryParam.trim().length > 0;
if ((hasHeader ? 1 : 0) + (hasQuery ? 1 : 0) !== 1) {
throw new InvalidCredentialPlacementError({ credentialKind: kind, placementKind: String(placementKind) });
}
}
}
export function ensureCliPressSchema(db: Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS cli_press_services (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
displayName TEXT NOT NULL,
description TEXT,
baseUrl TEXT NOT NULL,
sourceKind TEXT NOT NULL,
sourceRef TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS cli_press_cli_specs (
id TEXT PRIMARY KEY,
serviceId TEXT NOT NULL,
name TEXT NOT NULL,
version TEXT NOT NULL,
generatorVersion TEXT NOT NULL,
specJson TEXT NOT NULL,
generatedAt TEXT,
status TEXT NOT NULL,
lastGenerationError TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (serviceId) REFERENCES cli_press_services(id) ON DELETE CASCADE,
UNIQUE(serviceId, name)
);
CREATE TABLE IF NOT EXISTS cli_press_artifacts (
id TEXT PRIMARY KEY,
cliSpecId TEXT NOT NULL,
kind TEXT NOT NULL,
path TEXT NOT NULL,
executable INTEGER NOT NULL,
checksum TEXT,
sizeBytes INTEGER,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (cliSpecId) REFERENCES cli_press_cli_specs(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS cli_press_credentials (
id TEXT PRIMARY KEY,
serviceId TEXT NOT NULL,
name TEXT NOT NULL,
kind TEXT NOT NULL,
value TEXT NOT NULL,
placement TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (serviceId) REFERENCES cli_press_services(id) ON DELETE CASCADE,
UNIQUE(serviceId, name)
);
CREATE TABLE IF NOT EXISTS cli_press_service_settings (
id TEXT PRIMARY KEY,
serviceId TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
scope TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (serviceId) REFERENCES cli_press_services(id) ON DELETE CASCADE,
UNIQUE(serviceId, key, scope)
);
CREATE INDEX IF NOT EXISTS idx_cli_press_specs_service ON cli_press_cli_specs(serviceId);
CREATE INDEX IF NOT EXISTS idx_cli_press_artifacts_spec ON cli_press_artifacts(cliSpecId);
CREATE INDEX IF NOT EXISTS idx_cli_press_credentials_service ON cli_press_credentials(serviceId);
CREATE INDEX IF NOT EXISTS idx_cli_press_settings_service ON cli_press_service_settings(serviceId);
`);
}
export function createCliPressStore(db: Database) {
ensureCliPressSchema(db);
const mapService = (row: ServiceRow): Service => ({
id: row.id,
slug: row.slug,
displayName: row.displayName,
description: row.description ?? undefined,
baseUrl: row.baseUrl,
sourceKind: row.sourceKind,
sourceRef: row.sourceRef ?? undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
});
const mapSpec = (row: CliSpecRow): CliSpec => ({
id: row.id,
serviceId: row.serviceId,
name: row.name,
version: row.version,
generatorVersion: row.generatorVersion,
specJson: row.specJson,
generatedAt: row.generatedAt ?? undefined,
status: row.status,
lastGenerationError: row.lastGenerationError ?? undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
});
const mapArtifact = (row: CliArtifactRow): CliArtifact => ({
id: row.id,
cliSpecId: row.cliSpecId,
kind: row.kind,
path: row.path,
executable: Boolean(row.executable),
checksum: row.checksum ?? undefined,
sizeBytes: row.sizeBytes ?? undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
});
const mapCredential = (row: CredentialRow): Credential => ({
id: row.id,
serviceId: row.serviceId,
name: row.name,
kind: row.kind,
value: parseJson(row.value),
placement: parseJson(row.placement),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
});
const mapSetting = (row: ServiceSettingRow): ServiceSetting => ({
id: row.id,
serviceId: row.serviceId,
key: row.key,
value: row.value,
scope: row.scope,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
});
return {
listServices(): Service[] {
const rows = db.prepare("SELECT * FROM cli_press_services ORDER BY createdAt DESC").all() as unknown as ServiceRow[];
return rows.map(mapService);
},
getService(id: string): Service | undefined {
const row = db.prepare("SELECT * FROM cli_press_services WHERE id = ?").get(id) as unknown as ServiceRow | undefined;
return row ? mapService(row) : undefined;
},
createService(input: ServiceCreateInput): Service {
const service: Service = { id: createId("svc"), ...input, createdAt: nowIso(), updatedAt: nowIso() };
db.prepare(`INSERT INTO cli_press_services (id, slug, displayName, description, baseUrl, sourceKind, sourceRef, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(service.id, service.slug, service.displayName, service.description ?? null, service.baseUrl, service.sourceKind, service.sourceRef ?? null, service.createdAt, service.updatedAt);
db.bumpLastModified();
return service;
},
updateService(id: string, updates: ServiceUpdateInput): Service {
const existing = this.getService(id);
if (!existing) throw new Error(`Service ${id} not found`);
const updated: Service = { ...existing, ...updates, id: existing.id, slug: existing.slug, createdAt: existing.createdAt, updatedAt: nowIso() };
db.prepare(`UPDATE cli_press_services SET displayName = ?, description = ?, baseUrl = ?, sourceKind = ?, sourceRef = ?, updatedAt = ? WHERE id = ?`)
.run(updated.displayName, updated.description ?? null, updated.baseUrl, updated.sourceKind, updated.sourceRef ?? null, updated.updatedAt, id);
db.bumpLastModified();
return updated;
},
deleteService(id: string): void {
db.transaction(() => {
db.prepare("DELETE FROM cli_press_services WHERE id = ?").run(id);
});
db.bumpLastModified();
},
listSpecs(serviceId: string): CliSpec[] {
const rows = db.prepare("SELECT * FROM cli_press_cli_specs WHERE serviceId = ? ORDER BY createdAt DESC").all(serviceId) as unknown as CliSpecRow[];
return rows.map(mapSpec);
},
getSpec(id: string): CliSpec | undefined {
const row = db.prepare("SELECT * FROM cli_press_cli_specs WHERE id = ?").get(id) as unknown as CliSpecRow | undefined;
return row ? mapSpec(row) : undefined;
},
createSpec(input: CliSpecCreateInput): CliSpec {
const spec: CliSpec = { id: createId("cli"), ...input, createdAt: nowIso(), updatedAt: nowIso() };
db.prepare(`INSERT INTO cli_press_cli_specs (id, serviceId, name, version, generatorVersion, specJson, generatedAt, status, lastGenerationError, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(spec.id, spec.serviceId, spec.name, spec.version, spec.generatorVersion, spec.specJson, spec.generatedAt ?? null, spec.status, spec.lastGenerationError ?? null, spec.createdAt, spec.updatedAt);
db.bumpLastModified();
return spec;
},
updateSpec(id: string, updates: CliSpecUpdateInput): CliSpec {
const existing = this.getSpec(id);
if (!existing) throw new Error(`Spec ${id} not found`);
const updated: CliSpec = { ...existing, ...updates, id: existing.id, serviceId: existing.serviceId, createdAt: existing.createdAt, updatedAt: nowIso() };
db.prepare(`UPDATE cli_press_cli_specs SET name=?, version=?, generatorVersion=?, specJson=?, generatedAt=?, status=?, lastGenerationError=?, updatedAt=? WHERE id=?`)
.run(updated.name, updated.version, updated.generatorVersion, updated.specJson, updated.generatedAt ?? null, updated.status, updated.lastGenerationError ?? null, updated.updatedAt, id);
db.bumpLastModified();
return updated;
},
deleteSpec(id: string): void {
db.prepare("DELETE FROM cli_press_cli_specs WHERE id = ?").run(id);
db.bumpLastModified();
},
listArtifacts(specId: string): CliArtifact[] {
const rows = db.prepare("SELECT * FROM cli_press_artifacts WHERE cliSpecId = ? ORDER BY createdAt DESC").all(specId) as unknown as CliArtifactRow[];
return rows.map(mapArtifact);
},
createArtifact(input: CliArtifactCreateInput): CliArtifact {
const artifact: CliArtifact = { id: createId("art"), ...input, createdAt: nowIso(), updatedAt: nowIso() };
db.prepare(`INSERT INTO cli_press_artifacts (id, cliSpecId, kind, path, executable, checksum, sizeBytes, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(artifact.id, artifact.cliSpecId, artifact.kind, artifact.path, artifact.executable ? 1 : 0, artifact.checksum ?? null, artifact.sizeBytes ?? null, artifact.createdAt, artifact.updatedAt);
db.bumpLastModified();
return artifact;
},
updateArtifact(id: string, updates: CliArtifactUpdateInput): CliArtifact {
const existing = db.prepare("SELECT * FROM cli_press_artifacts WHERE id = ?").get(id) as unknown as CliArtifactRow | undefined;
if (!existing) throw new Error(`Artifact ${id} not found`);
const updated = { ...mapArtifact(existing), ...updates, id: existing.id, cliSpecId: existing.cliSpecId, createdAt: existing.createdAt, updatedAt: nowIso() };
db.prepare("UPDATE cli_press_artifacts SET path=?, executable=?, checksum=?, sizeBytes=?, updatedAt=? WHERE id=?")
.run(updated.path, updated.executable ? 1 : 0, updated.checksum ?? null, updated.sizeBytes ?? null, updated.updatedAt, id);
db.bumpLastModified();
return updated;
},
deleteArtifact(id: string): void {
db.prepare("DELETE FROM cli_press_artifacts WHERE id = ?").run(id);
db.bumpLastModified();
},
listCredentials(serviceId: string): Credential[] {
const rows = db.prepare("SELECT * FROM cli_press_credentials WHERE serviceId = ? ORDER BY createdAt DESC").all(serviceId) as unknown as CredentialRow[];
return rows.map(mapCredential);
},
createCredential(input: CredentialCreateInput): Credential {
assertCredentialSupported(input.kind);
assertPlacementConsistency(input.kind, input.placement);
const cred: Credential = { id: createId("cred"), ...input, createdAt: nowIso(), updatedAt: nowIso() };
db.prepare(`INSERT INTO cli_press_credentials (id, serviceId, name, kind, value, placement, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
.run(cred.id, cred.serviceId, cred.name, cred.kind, JSON.stringify(cred.value), JSON.stringify(cred.placement), cred.createdAt, cred.updatedAt);
db.bumpLastModified();
return cred;
},
updateCredential(id: string, updates: CredentialUpdateInput): Credential {
const existing = db.prepare("SELECT * FROM cli_press_credentials WHERE id = ?").get(id) as unknown as CredentialRow | undefined;
if (!existing) throw new Error(`Credential ${id} not found`);
const mapped = mapCredential(existing);
const updated: Credential = {
...mapped,
...updates,
id: mapped.id,
serviceId: mapped.serviceId,
kind: mapped.kind,
createdAt: mapped.createdAt,
updatedAt: nowIso(),
};
assertCredentialSupported(updated.kind);
assertPlacementConsistency(updated.kind, updated.placement);
db.prepare("UPDATE cli_press_credentials SET name=?, value=?, placement=?, updatedAt=? WHERE id=?")
.run(updated.name, JSON.stringify(updated.value), JSON.stringify(updated.placement), updated.updatedAt, id);
db.bumpLastModified();
return updated;
},
deleteCredential(id: string): void {
db.prepare("DELETE FROM cli_press_credentials WHERE id = ?").run(id);
db.bumpLastModified();
},
listSettings(serviceId: string): ServiceSetting[] {
const rows = db.prepare("SELECT * FROM cli_press_service_settings WHERE serviceId = ? ORDER BY createdAt DESC").all(serviceId) as unknown as ServiceSettingRow[];
return rows.map(mapSetting);
},
setSetting(input: ServiceSettingCreateInput): ServiceSetting {
const existing = db.prepare("SELECT * FROM cli_press_service_settings WHERE serviceId = ? AND key = ? AND scope = ?")
.get(input.serviceId, input.key, input.scope) as unknown as ServiceSettingRow | undefined;
const now = nowIso();
if (existing) {
db.prepare("UPDATE cli_press_service_settings SET value = ?, updatedAt = ? WHERE id = ?").run(input.value, now, existing.id);
db.bumpLastModified();
return mapSetting({ ...existing, value: input.value, updatedAt: now });
}
const setting: ServiceSetting = { id: createId("set"), ...input, createdAt: now, updatedAt: now };
db.prepare(`INSERT INTO cli_press_service_settings (id, serviceId, key, value, scope, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)`)
.run(setting.id, setting.serviceId, setting.key, setting.value, setting.scope, setting.createdAt, setting.updatedAt);
db.bumpLastModified();
return setting;
},
deleteSetting(id: string): void {
db.prepare("DELETE FROM cli_press_service_settings WHERE id = ?").run(id);
db.bumpLastModified();
},
};
}
export type CliPressStore = ReturnType<typeof createCliPressStore>;