- Admin login gate (docs/design/auth-reference.md): admin_user/admin_session/login_attempt
(migration 0015), one-time setup token logged once + conditional UPDATE, argon2id via
hash-wasm (WASM; compiled binary has no --allow-ffi), sha256-stored sessions in HttpOnly
SameSite=Lax cookie umt_session (fixed 12h TTL, soft revoke), HMAC-derived double-submit
CSRF, IP (5) + account (15) failure limits per 15 min, password change revokes other
sessions, optional TOTP with replay counter, AuditAction enum.
- Unified auth middleware: cookie session or Bearer API key -> c.var.actor
{type: admin|api_key, id, name, scopes}; requireScope works for both; requireAdminSession.
- ctx.secrets: AES-256-GCM, per-secret HKDF-SHA256 key from SECRET_MASTER_KEY, AAD bound
to (id, purpose); ctx.settings: 30s cache invalidated by settings.changed events.
- Endpoints: /auth/*, /settings, /api-keys, /secrets, /audit-events, library-roots,
downloader/media-server instances (+test), path-mappings (+verify),
PUT /sites/{id}/credentials, /engine/runs|stats|cancel (read-only openworkflow schema).
- SSE: PgEventBus (sse_event table, migration 0016, LISTEN/NOTIFY umt_events, catch-up by
seq, pruning, resync); EventBusPort is now async.
- Tests: login/rate limit/revocation/CSRF/TOTP, secrets round trip and no log leakage,
admin APIs against mock qB/Emby, SSE across two Deno subprocesses.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
558 lines
20 KiB
TypeScript
558 lines
20 KiB
TypeScript
import { stat } from "node:fs/promises";
|
|
import { checkRoot, DEFAULT_ROOT_SENTINEL, EmbyClient, QbClient, writeRootSentinel } from "@umt/adapters";
|
|
import {
|
|
Api,
|
|
type AuditAction,
|
|
DownloaderInstance,
|
|
DownloaderInstanceCreate,
|
|
DownloaderInstanceUpdate,
|
|
IdParam,
|
|
InstanceTestResult,
|
|
LibraryRoot,
|
|
LibraryRootCreate,
|
|
LibraryRootUpdate,
|
|
MediaServerInstance,
|
|
MediaServerInstanceCreate,
|
|
MediaServerInstanceUpdate,
|
|
PathMapping,
|
|
PathMappingCreate,
|
|
PathMappingUpdate,
|
|
type SecretPurpose,
|
|
SiteCredentialsPut,
|
|
SiteCredentialsResult,
|
|
} from "@umt/contracts";
|
|
import type { DB } from "@umt/db";
|
|
import { z } from "zod";
|
|
import { ApiException } from "../../http/errors.ts";
|
|
import { redact, redactString } from "../../logger.ts";
|
|
import type { ModuleApp, ServerContext } from "../types.ts";
|
|
import {
|
|
afterCursor,
|
|
audit,
|
|
crudDefs,
|
|
cursorCol,
|
|
type Def,
|
|
decodeCursor,
|
|
endpoint,
|
|
pageOf,
|
|
pgConflict,
|
|
toApi,
|
|
toDb,
|
|
} from "./endpoint.ts";
|
|
|
|
// 实例与路径:library_root / downloader_instance / media_server_instance / path_mapping 的 CRUD + 连通性测试,
|
|
// 以及站点账号凭证录入 PUT /sites/{id}/credentials。凭证明文只在请求里出现,存为 secret 后只回 secretRef。
|
|
|
|
const OkResult = z.object({ ok: z.literal(true) });
|
|
const del = (path: string, summary: string, tag: string) => ({
|
|
method: "delete" as const,
|
|
path,
|
|
scope: "admin" as const,
|
|
summary,
|
|
params: IdParam,
|
|
response: OkResult,
|
|
tags: [tag],
|
|
});
|
|
const test = (path: string, summary: string, tag: string) => ({
|
|
method: "post" as const,
|
|
path,
|
|
scope: "admin" as const,
|
|
summary,
|
|
params: IdParam,
|
|
response: InstanceTestResult,
|
|
tags: [tag],
|
|
});
|
|
|
|
type Table = "library_root" | "downloader_instance" | "media_server_instance" | "path_mapping";
|
|
const JSON_COLS = ["settings", "version_info", "health_detail", "verify_detail"] as const;
|
|
|
|
const errMessage = (e: unknown) => redactString(String((e as Error)?.message ?? e)).slice(0, 500);
|
|
|
|
const LR = crudDefs("/library-roots", "媒体库根", {
|
|
row: LibraryRoot,
|
|
create: LibraryRootCreate,
|
|
update: LibraryRootUpdate,
|
|
});
|
|
const DL = crudDefs("/downloader-instances", "下载器实例", {
|
|
row: DownloaderInstance,
|
|
create: DownloaderInstanceCreate,
|
|
update: DownloaderInstanceUpdate,
|
|
});
|
|
const MS = crudDefs("/media-server-instances", "媒体服务器实例", {
|
|
row: MediaServerInstance,
|
|
create: MediaServerInstanceCreate,
|
|
update: MediaServerInstanceUpdate,
|
|
});
|
|
const PM = crudDefs("/path-mappings", "路径映射", {
|
|
row: PathMapping,
|
|
create: PathMappingCreate,
|
|
update: PathMappingUpdate,
|
|
});
|
|
|
|
export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
|
|
const db = () => ctx.db;
|
|
|
|
/** 通用列表 / 详情 / 删除 */
|
|
const list = (table: Table, tag: string, def: Def) =>
|
|
endpoint(app, { ...def, tags: [tag] }, async ({ query }) => {
|
|
const q0 = query as { cursor?: string; limit: number };
|
|
const cur = decodeCursor(q0.cursor);
|
|
// biome-ignore lint/suspicious/noExplicitAny: 四张表共用的通用查询
|
|
let q = (db() as any).selectFrom(table).selectAll().select(cursorCol("created_at"));
|
|
if (cur) q = q.where(afterCursor("created_at", cur));
|
|
const rows = await q
|
|
.orderBy("created_at", "desc")
|
|
.orderBy("id", "desc")
|
|
.limit(q0.limit + 1)
|
|
.execute();
|
|
const page = pageOf(rows as { cursor_us: number; id: string }[], q0.limit);
|
|
return { items: page.items.map((r) => toApi(r)), nextCursor: page.nextCursor };
|
|
});
|
|
const getRow = async (table: Table, id: string) => {
|
|
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
|
const r = await (db() as any).selectFrom(table).selectAll().where("id", "=", id).executeTakeFirst();
|
|
if (!r) throw new ApiException("not_found", `${table} ${id} 不存在`);
|
|
return r as Record<string, unknown> & { id: string; secret_ref?: string | null };
|
|
};
|
|
const get = (table: Table, tag: string, def: Def) =>
|
|
endpoint(app, { ...def, tags: [tag] }, async ({ params }) =>
|
|
toApi(await getRow(table, (params as { id: string }).id)),
|
|
);
|
|
const remove = (table: Table, tag: string, path: string, action: AuditAction) =>
|
|
endpoint(app, del(path, `删除 ${table}`, tag), async ({ params, actor }) => {
|
|
const id = (params as { id: string }).id;
|
|
const row = await getRow(table, id);
|
|
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
|
await (db() as any)
|
|
.deleteFrom(table)
|
|
.where("id", "=", id)
|
|
.execute()
|
|
.catch((e: unknown) => pgConflict(e, `${table} 仍被引用,不能删除`));
|
|
if (row.secret_ref) await ctx.secrets.remove(row.secret_ref).catch(() => {});
|
|
await audit(ctx, actor, action, { type: table, id });
|
|
return { ok: true as const };
|
|
});
|
|
const insert = async (table: Table, values: Record<string, unknown>) =>
|
|
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
|
(await (db() as any)
|
|
.insertInto(table)
|
|
.values(values)
|
|
.returningAll()
|
|
.executeTakeFirstOrThrow()
|
|
.catch((e: unknown) => pgConflict(e, `${table} 名称/路径重复或引用不存在`))) as Record<string, unknown> & {
|
|
id: string;
|
|
};
|
|
const update = async (table: Table, id: string, values: Record<string, unknown>) => {
|
|
if (!Object.keys(values).length) return await getRow(table, id);
|
|
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
|
const r = await (db() as any)
|
|
.updateTable(table)
|
|
.set(values)
|
|
.where("id", "=", id)
|
|
.returningAll()
|
|
.executeTakeFirst()
|
|
.catch((e: unknown) => pgConflict(e, `${table} 名称/路径重复或引用不存在`));
|
|
if (!r) throw new ApiException("not_found", `${table} ${id} 不存在`);
|
|
return r as Record<string, unknown> & { id: string };
|
|
};
|
|
|
|
/** 凭证:有旧 ref 就原地替换,否则新建 */
|
|
const putCredential = async (oldRef: string | null | undefined, purpose: SecretPurpose, plaintext: string) => {
|
|
if (oldRef) {
|
|
await ctx.secrets.replace(oldRef, plaintext);
|
|
return oldRef;
|
|
}
|
|
return await ctx.secrets.create({ purpose, plaintext });
|
|
};
|
|
|
|
// ── library_root ──
|
|
const sentinel = async (localPath: string, file: string | null | undefined) => {
|
|
const name = file || DEFAULT_ROOT_SENTINEL;
|
|
try {
|
|
const st = await stat(localPath);
|
|
if (!st.isDirectory()) throw new Error("不是目录");
|
|
await writeRootSentinel(localPath, name);
|
|
} catch (e) {
|
|
throw new ApiException(
|
|
"precondition_failed",
|
|
`库根不可写(需要目录存在且可写入哨兵文件 ${name}):${errMessage(e)}`,
|
|
);
|
|
}
|
|
return name;
|
|
};
|
|
list("library_root", "library-roots", LR.list);
|
|
get("library_root", "library-roots", LR.get);
|
|
endpoint(app, LR.create, async ({ body, actor }) => {
|
|
const file = await sentinel(body.localPath, body.sentinelFile);
|
|
const row = await insert("library_root", {
|
|
...toDb(body, JSON_COLS),
|
|
sentinel_file: file,
|
|
health_status: "healthy",
|
|
last_health_at: new Date(),
|
|
});
|
|
await audit(ctx, actor, "library_root.create", { type: "library_root", id: row.id }, { localPath: body.localPath });
|
|
return toApi(row);
|
|
});
|
|
endpoint(app, LR.update, async ({ params, body, actor }) => {
|
|
const values = toDb(body, JSON_COLS);
|
|
if (body.localPath !== undefined || body.sentinelFile !== undefined) {
|
|
const cur = await getRow("library_root", params.id);
|
|
values.sentinel_file = await sentinel(
|
|
body.localPath ?? String(cur.local_path),
|
|
body.sentinelFile ?? (cur.sentinel_file as string | null),
|
|
);
|
|
}
|
|
const row = await update("library_root", params.id, values);
|
|
await audit(ctx, actor, "library_root.update", { type: "library_root", id: row.id }, { fields: Object.keys(body) });
|
|
return toApi(row);
|
|
});
|
|
remove("library_root", "library-roots", "/library-roots/{id}", "library_root.delete");
|
|
endpoint(
|
|
app,
|
|
test("/library-roots/{id}/test", "检查库根在线(目录 + 哨兵文件)", "library-roots"),
|
|
async ({ params, actor }) => {
|
|
const row = await getRow("library_root", params.id);
|
|
const h = await checkRoot(String(row.local_path), (row.sentinel_file as string | null) ?? DEFAULT_ROOT_SENTINEL);
|
|
const ok = h.kind === "online";
|
|
await update("library_root", row.id, {
|
|
health_status: ok ? "healthy" : "offline",
|
|
last_health_at: new Date(),
|
|
health_detail: JSON.stringify(h),
|
|
});
|
|
await audit(ctx, actor, "library_root.test", { type: "library_root", id: row.id }, { ok });
|
|
return {
|
|
ok,
|
|
version: null,
|
|
message: ok ? null : `offline: ${"reason" in h ? h.reason : "unknown"}`,
|
|
healthStatus: ok ? "healthy" : "offline",
|
|
};
|
|
},
|
|
);
|
|
|
|
// ── downloader_instance ──
|
|
list("downloader_instance", "downloader-instances", DL.list);
|
|
get("downloader_instance", "downloader-instances", DL.get);
|
|
endpoint(app, DL.create, async ({ body, actor }) => {
|
|
const { credential, ...rest } = body;
|
|
const ref = credential ? await ctx.secrets.create({ purpose: "downloader_password", plaintext: credential }) : null;
|
|
try {
|
|
const row = await insert("downloader_instance", { ...toDb(rest, JSON_COLS), secret_ref: ref });
|
|
await audit(
|
|
ctx,
|
|
actor,
|
|
"downloader_instance.create",
|
|
{ type: "downloader_instance", id: row.id },
|
|
{ name: row.name },
|
|
);
|
|
return toApi(row);
|
|
} catch (e) {
|
|
if (ref) await ctx.secrets.remove(ref).catch(() => {});
|
|
throw e;
|
|
}
|
|
});
|
|
endpoint(app, DL.update, async ({ params, body, actor }) => {
|
|
const { credential, ...rest } = body;
|
|
const values = toDb(rest, JSON_COLS);
|
|
if (credential) {
|
|
const cur = await getRow("downloader_instance", params.id);
|
|
values.secret_ref = await putCredential(cur.secret_ref, "downloader_password", credential);
|
|
}
|
|
const row = await update("downloader_instance", params.id, values);
|
|
await audit(
|
|
ctx,
|
|
actor,
|
|
"downloader_instance.update",
|
|
{ type: "downloader_instance", id: row.id },
|
|
{
|
|
fields: Object.keys(body).map((k) => (k === "credential" ? "credential(set)" : k)),
|
|
},
|
|
);
|
|
return toApi(row);
|
|
});
|
|
remove("downloader_instance", "downloader-instances", "/downloader-instances/{id}", "downloader_instance.delete");
|
|
endpoint(
|
|
app,
|
|
{
|
|
...test("/downloader-instances/{id}/test", "测试下载器连接", "downloader-instances"),
|
|
response: InstanceTestResult,
|
|
},
|
|
async ({ params, actor }) => {
|
|
const row = await getRow("downloader_instance", params.id);
|
|
const secret = row.secret_ref ? await ctx.secrets.resolve(row.secret_ref) : undefined;
|
|
const username = (row.username as string | null) ?? undefined;
|
|
// 有用户名走 WebUI 登录(密码);没有用户名但有凭证视为 qB 5.2+ WebUI API Key
|
|
const client = new QbClient({
|
|
instanceId: row.id,
|
|
baseUrl: String(row.base_url),
|
|
...(username ? { username, ...(secret ? { password: secret } : {}) } : secret ? { apiKey: secret } : {}),
|
|
timeoutMs: 10_000,
|
|
retries: 0,
|
|
});
|
|
let result: z.infer<typeof InstanceTestResult>;
|
|
try {
|
|
const v = await client.version();
|
|
result = { ok: true, version: v.app, message: null, healthStatus: "healthy" };
|
|
await update("downloader_instance", row.id, {
|
|
health_status: "healthy",
|
|
last_seen_at: new Date(),
|
|
version_info: JSON.stringify({ app: v.app, webApi: v.webApi }),
|
|
});
|
|
} catch (e) {
|
|
result = { ok: false, version: null, message: errMessage(e), healthStatus: "offline" };
|
|
await update("downloader_instance", row.id, { health_status: "offline" });
|
|
}
|
|
await audit(
|
|
ctx,
|
|
actor,
|
|
"downloader_instance.test",
|
|
{ type: "downloader_instance", id: row.id },
|
|
{ ok: result.ok },
|
|
);
|
|
return result;
|
|
},
|
|
);
|
|
|
|
// ── media_server_instance ──
|
|
list("media_server_instance", "media-server-instances", MS.list);
|
|
get("media_server_instance", "media-server-instances", MS.get);
|
|
endpoint(app, MS.create, async ({ body, actor }) => {
|
|
const { credential, ...rest } = body;
|
|
const ref = credential
|
|
? await ctx.secrets.create({ purpose: "media_server_api_key", plaintext: credential })
|
|
: null;
|
|
try {
|
|
const row = await insert("media_server_instance", { ...toDb(rest, JSON_COLS), secret_ref: ref });
|
|
await audit(
|
|
ctx,
|
|
actor,
|
|
"media_server_instance.create",
|
|
{ type: "media_server_instance", id: row.id },
|
|
{ name: row.name },
|
|
);
|
|
return toApi(row);
|
|
} catch (e) {
|
|
if (ref) await ctx.secrets.remove(ref).catch(() => {});
|
|
throw e;
|
|
}
|
|
});
|
|
endpoint(app, MS.update, async ({ params, body, actor }) => {
|
|
const { credential, ...rest } = body;
|
|
const values = toDb(rest, JSON_COLS);
|
|
if (credential) {
|
|
const cur = await getRow("media_server_instance", params.id);
|
|
values.secret_ref = await putCredential(cur.secret_ref, "media_server_api_key", credential);
|
|
}
|
|
const row = await update("media_server_instance", params.id, values);
|
|
await audit(
|
|
ctx,
|
|
actor,
|
|
"media_server_instance.update",
|
|
{ type: "media_server_instance", id: row.id },
|
|
{
|
|
fields: Object.keys(body).map((k) => (k === "credential" ? "credential(set)" : k)),
|
|
},
|
|
);
|
|
return toApi(row);
|
|
});
|
|
remove(
|
|
"media_server_instance",
|
|
"media-server-instances",
|
|
"/media-server-instances/{id}",
|
|
"media_server_instance.delete",
|
|
);
|
|
endpoint(
|
|
app,
|
|
test("/media-server-instances/{id}/test", "测试媒体服务器连接", "media-server-instances"),
|
|
async ({ params, actor }) => {
|
|
const row = await getRow("media_server_instance", params.id);
|
|
let result: z.infer<typeof InstanceTestResult>;
|
|
if (!row.secret_ref) {
|
|
result = { ok: false, version: null, message: "未配置 API Key", healthStatus: "unknown" };
|
|
} else {
|
|
const client = new EmbyClient({
|
|
instanceId: row.id,
|
|
baseUrl: String(row.base_url),
|
|
apiKey: await ctx.secrets.resolve(row.secret_ref),
|
|
flavor: row.kind === "jellyfin" ? "jellyfin" : "emby",
|
|
...(row.user_id ? { userId: String(row.user_id) } : {}),
|
|
timeoutMs: 10_000,
|
|
retries: 0,
|
|
});
|
|
const r = await client.ping();
|
|
if (r.status === "ok") {
|
|
result = {
|
|
ok: true,
|
|
version: r.value.version ?? null,
|
|
message: r.value.serverName ?? null,
|
|
healthStatus: "healthy",
|
|
};
|
|
await update("media_server_instance", row.id, {
|
|
health_status: "healthy",
|
|
last_seen_at: new Date(),
|
|
version_info: JSON.stringify(redact(r.value)),
|
|
});
|
|
} else {
|
|
result = {
|
|
ok: false,
|
|
version: null,
|
|
message: `${r.reason}: ${redactString(r.message)}`,
|
|
healthStatus: "offline",
|
|
};
|
|
await update("media_server_instance", row.id, { health_status: "offline" });
|
|
}
|
|
}
|
|
await audit(
|
|
ctx,
|
|
actor,
|
|
"media_server_instance.test",
|
|
{ type: "media_server_instance", id: row.id },
|
|
{ ok: result.ok },
|
|
);
|
|
return result;
|
|
},
|
|
);
|
|
|
|
// ── path_mapping ──
|
|
list("path_mapping", "path-mappings", PM.list);
|
|
get("path_mapping", "path-mappings", PM.get);
|
|
endpoint(app, PM.create, async ({ body, actor }) => {
|
|
const row = await insert("path_mapping", toDb(body, JSON_COLS));
|
|
await audit(
|
|
ctx,
|
|
actor,
|
|
"path_mapping.create",
|
|
{ type: "path_mapping", id: row.id },
|
|
{
|
|
remotePrefix: body.remotePrefix,
|
|
localPrefix: body.localPrefix,
|
|
},
|
|
);
|
|
return toApi(row);
|
|
});
|
|
endpoint(app, PM.update, async ({ params, body, actor }) => {
|
|
const values = toDb(body, JSON_COLS);
|
|
if (body.localPrefix !== undefined || body.remotePrefix !== undefined) values.verify_status = "unverified";
|
|
const row = await update("path_mapping", params.id, values);
|
|
await audit(ctx, actor, "path_mapping.update", { type: "path_mapping", id: row.id }, { fields: Object.keys(body) });
|
|
return toApi(row);
|
|
});
|
|
endpoint(app, { ...Api.pathMappings.remove, tags: ["path-mappings"] }, async ({ params, actor }) => {
|
|
const r = await db().deleteFrom("path_mapping").where("id", "=", params.id).returning("id").executeTakeFirst();
|
|
if (!r) throw new ApiException("not_found", "路径映射不存在");
|
|
await audit(ctx, actor, "path_mapping.delete", { type: "path_mapping", id: params.id });
|
|
return { ok: true as const };
|
|
});
|
|
endpoint(app, { ...Api.pathMappings.verify, tags: ["path-mappings"] }, async ({ params, actor }) => {
|
|
const row = await getRow("path_mapping", params.id);
|
|
let detail: Record<string, unknown>;
|
|
let ok = false;
|
|
try {
|
|
const st = await stat(String(row.local_prefix));
|
|
ok = st.isDirectory();
|
|
detail = ok ? { localPrefix: "directory" } : { error: "localPrefix 不是目录" };
|
|
} catch (e) {
|
|
detail = { error: errMessage(e), code: (e as { code?: string })?.code ?? null };
|
|
}
|
|
const updated = await update("path_mapping", row.id, {
|
|
verify_status: ok ? "ok" : "failed",
|
|
verified_at: new Date(),
|
|
verify_detail: JSON.stringify(detail),
|
|
});
|
|
await audit(ctx, actor, "path_mapping.verify", { type: "path_mapping", id: row.id }, { ok });
|
|
return toApi(updated);
|
|
});
|
|
|
|
// ── 站点账号凭证 ──
|
|
const PURPOSE: Record<SiteCredentialsPut["authKind"], SecretPurpose> = {
|
|
cookie: "site_cookie",
|
|
passkey: "site_passkey",
|
|
api_key: "site_api_key",
|
|
password: "site_password",
|
|
};
|
|
endpoint(
|
|
app,
|
|
{
|
|
method: "put",
|
|
path: "/sites/{id}/credentials",
|
|
scope: "admin",
|
|
summary: "录入站点账号凭证(cookie/passkey/apiKey/密码 → secret_ref,不回显)",
|
|
params: IdParam,
|
|
body: SiteCredentialsPut,
|
|
response: SiteCredentialsResult,
|
|
tags: ["sites"],
|
|
},
|
|
async ({ params, body, actor }) => {
|
|
const site = await db().selectFrom("site").select("id").where("id", "=", params.id).executeTakeFirst();
|
|
if (!site) throw new ApiException("not_found", "站点不存在");
|
|
const newRef = await ctx.secrets.create({ purpose: PURPOSE[body.authKind], plaintext: body.credential });
|
|
let oldRef: string | null = null;
|
|
let created = false;
|
|
let accountId: string;
|
|
try {
|
|
accountId = await db()
|
|
.transaction()
|
|
.execute(async (trx) => {
|
|
const cur = await trx
|
|
.selectFrom("site_account")
|
|
.select(["id", "secret_ref", "user_info"])
|
|
.where("site_id", "=", params.id)
|
|
.where("label", "=", body.label)
|
|
.forUpdate()
|
|
.executeTakeFirst();
|
|
const userInfo = {
|
|
...((cur?.user_info as Record<string, unknown> | undefined) ?? {}),
|
|
...(body.username ? { username: body.username } : {}),
|
|
};
|
|
const common: Partial<Record<keyof DB["site_account"], unknown>> = {
|
|
auth_kind: body.authKind,
|
|
secret_ref: newRef,
|
|
status: "unknown",
|
|
user_info: JSON.stringify(userInfo),
|
|
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
|
|
};
|
|
if (cur) {
|
|
oldRef = cur.secret_ref;
|
|
await trx
|
|
.updateTable("site_account")
|
|
.set(common as never)
|
|
.where("id", "=", cur.id)
|
|
.execute();
|
|
return cur.id;
|
|
}
|
|
created = true;
|
|
const r = await trx
|
|
.insertInto("site_account")
|
|
.values({ site_id: params.id, label: body.label, ...common } as never)
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
return r.id;
|
|
});
|
|
} catch (e) {
|
|
await ctx.secrets.remove(newRef).catch(() => {});
|
|
throw e;
|
|
}
|
|
if (oldRef) await ctx.secrets.remove(oldRef).catch(() => {});
|
|
await audit(
|
|
ctx,
|
|
actor,
|
|
"site_account.credentials_set",
|
|
{ type: "site_account", id: accountId },
|
|
{
|
|
siteId: params.id,
|
|
label: body.label,
|
|
authKind: body.authKind,
|
|
created,
|
|
},
|
|
);
|
|
return {
|
|
siteAccountId: accountId,
|
|
siteId: params.id,
|
|
label: body.label,
|
|
authKind: body.authKind,
|
|
secretRef: newRef,
|
|
created,
|
|
};
|
|
},
|
|
);
|
|
}
|