Merge branch 'fix8'
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
commit
9ef2e8554c
39 files changed
+1175
-232
No files matched your search
@@ -19,6 +19,7 @@ import {
|
||||
PathMappingCreate,
|
||||
PathMappingUpdate,
|
||||
type SecretPurpose,
|
||||
secretInput,
|
||||
} from "@umt/contracts";
|
||||
import { z } from "zod";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
@@ -172,6 +173,14 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
return await ctx.secrets.create({ purpose, plaintext });
|
||||
};
|
||||
|
||||
/** 审计里的字段名:凭证只记"设置 / 清除",不记值;空串(不变)不记 */
|
||||
const auditFields = (body: Record<string, unknown>) =>
|
||||
Object.keys(body).flatMap((k) => {
|
||||
if (k !== "credential") return [k];
|
||||
const c = secretInput(body.credential as string | null | undefined);
|
||||
return c === undefined ? [] : [c === null ? "credential(cleared)" : "credential(set)"];
|
||||
});
|
||||
|
||||
// ── library_root ──
|
||||
const sentinel = async (localPath: string, file: string | null | undefined) => {
|
||||
const name = file || DEFAULT_ROOT_SENTINEL;
|
||||
@@ -254,7 +263,8 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
return { ...curSettings, ...(next.settings ?? {}), authKind };
|
||||
};
|
||||
endpoint(app, DL.create, async ({ body, actor }) => {
|
||||
const { credential, authKind, ...rest } = body;
|
||||
const { credential: rawCredential, authKind, ...rest } = body;
|
||||
const credential = secretInput(rawCredential) ?? undefined;
|
||||
const settings = dlAuth(authKind, { ...rest, ...(credential ? { credential } : {}) });
|
||||
const keep = authKind !== "none" && credential;
|
||||
const ref = keep ? await ctx.secrets.create({ purpose: "downloader_password", plaintext: credential }) : null;
|
||||
@@ -277,13 +287,18 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
}
|
||||
});
|
||||
endpoint(app, DL.update, async ({ params, body, actor }) => {
|
||||
const { credential, authKind, ...rest } = body;
|
||||
// 只写凭证统一语义:省略或空串 = 不变,null = 清除
|
||||
const { credential: rawCredential, authKind, ...rest } = body;
|
||||
const credential = secretInput(rawCredential);
|
||||
const cur = await getRow("downloader_instance", params.id);
|
||||
const curAuth = ((cur.settings ?? {}) as Record<string, unknown>).authKind;
|
||||
if (credential === null && (authKind ?? curAuth) === "api_key")
|
||||
throw new ApiException("validation_failed", "API Key 认证不能清除 API Key;不用认证时把认证方式改为无认证");
|
||||
const settings = dlAuth(authKind, { ...rest, ...(credential ? { credential } : {}) }, cur);
|
||||
const values = toDb({ ...rest, ...(settings ? { settings } : {}) }, JSON_COLS);
|
||||
let dropRef: string | null = null;
|
||||
if (authKind === "none") {
|
||||
// 无认证:不再保留密码 / Key
|
||||
if (authKind === "none" || credential === null) {
|
||||
// 无认证或显式清除:不再保留密码 / Key
|
||||
if (cur.secret_ref) {
|
||||
values.secret_ref = null;
|
||||
dropRef = cur.secret_ref;
|
||||
@@ -299,7 +314,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
"downloader_instance.update",
|
||||
{ type: "downloader_instance", id: row.id },
|
||||
{
|
||||
fields: Object.keys(body).map((k) => (k === "credential" ? "credential(set)" : k)),
|
||||
fields: auditFields(body),
|
||||
},
|
||||
);
|
||||
return toApi(row);
|
||||
@@ -354,7 +369,8 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
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 { credential: rawCredential, ...rest } = body;
|
||||
const credential = secretInput(rawCredential) ?? undefined;
|
||||
const ref = credential
|
||||
? await ctx.secrets.create({ purpose: "media_server_api_key", plaintext: credential })
|
||||
: null;
|
||||
@@ -374,20 +390,27 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
}
|
||||
});
|
||||
endpoint(app, MS.update, async ({ params, body, actor }) => {
|
||||
const { credential, ...rest } = body;
|
||||
// 只写凭证统一语义:省略或空串 = 不变,null = 清除
|
||||
const { credential: rawCredential, ...rest } = body;
|
||||
const credential = secretInput(rawCredential);
|
||||
const values = toDb(rest, JSON_COLS);
|
||||
if (credential) {
|
||||
let dropRef: string | null = null;
|
||||
if (credential !== undefined) {
|
||||
const cur = await getRow("media_server_instance", params.id);
|
||||
values.secret_ref = await putCredential(cur.secret_ref, "media_server_api_key", credential);
|
||||
if (credential === null) {
|
||||
values.secret_ref = null;
|
||||
dropRef = cur.secret_ref ?? null;
|
||||
} else values.secret_ref = await putCredential(cur.secret_ref, "media_server_api_key", credential);
|
||||
}
|
||||
const row = await update("media_server_instance", params.id, values);
|
||||
if (dropRef) await ctx.secrets.remove(dropRef).catch(() => {});
|
||||
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)),
|
||||
fields: auditFields(body),
|
||||
},
|
||||
);
|
||||
return toApi(row);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
mergeSettings,
|
||||
type SecretPurpose,
|
||||
type SettingsKey,
|
||||
secretInput,
|
||||
} from "@umt/contracts";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import type { ModuleApp, ServerContext } from "../types.ts";
|
||||
@@ -75,11 +76,14 @@ export function registerSettingsRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
void ctx.events.publish(SETTINGS_CHANGED_EVENT, { keys });
|
||||
// 审计里不留明文密钥
|
||||
const logged: Record<string, unknown> = { ...patch };
|
||||
const mark = (v: string | null | undefined) => {
|
||||
const x = secretInput(v);
|
||||
return x === undefined ? undefined : x === null ? "(cleared)" : "(set)";
|
||||
};
|
||||
if (patch.metadata && "tmdbApiKey" in patch.metadata)
|
||||
logged.metadata = { ...patch.metadata, tmdbApiKey: patch.metadata.tmdbApiKey === null ? "(cleared)" : "(set)" };
|
||||
logged.metadata = { ...patch.metadata, tmdbApiKey: mark(patch.metadata.tmdbApiKey) };
|
||||
const ws = patch.agent?.webSearch;
|
||||
if (ws && "apiKey" in ws)
|
||||
logged.agent = { ...patch.agent, webSearch: { ...ws, apiKey: ws.apiKey === null ? "(cleared)" : "(set)" } };
|
||||
if (ws && "apiKey" in ws) logged.agent = { ...patch.agent, webSearch: { ...ws, apiKey: mark(ws.apiKey) } };
|
||||
await audit(ctx, actor, "settings.update", { type: "settings", id: keys.join(",") }, { patch: logged });
|
||||
return next;
|
||||
});
|
||||
@@ -96,7 +100,8 @@ interface SecretSlot {
|
||||
/** PATCH 里出现的只写密钥字段(undefined = 不改;null = 清除;字符串 = 设置) */
|
||||
function secretSlots(patch: GlobalSettingsUpdate): SecretSlot[] {
|
||||
const out: SecretSlot[] = [];
|
||||
const tmdb = patch.metadata?.tmdbApiKey;
|
||||
// 只写凭证统一语义:省略或空串 = 不变,null = 清除
|
||||
const tmdb = secretInput(patch.metadata?.tmdbApiKey);
|
||||
if (tmdb !== undefined)
|
||||
out.push({
|
||||
plaintext: tmdb,
|
||||
@@ -107,7 +112,7 @@ function secretSlots(patch: GlobalSettingsUpdate): SecretSlot[] {
|
||||
s.metadata.tmdbApiKeySecretRef = ref;
|
||||
},
|
||||
});
|
||||
const ws = patch.agent?.webSearch?.apiKey;
|
||||
const ws = secretInput(patch.agent?.webSearch?.apiKey);
|
||||
if (ws !== undefined)
|
||||
out.push({
|
||||
plaintext: ws,
|
||||
|
||||
@@ -80,6 +80,9 @@ describe.skipIf(!up)("浏览器服务登记", () => {
|
||||
const p2 = await h.req(`/browser/providers/${id}/probe`, { body: {} });
|
||||
expect(p2.status).toBe(200);
|
||||
expect((p2.body.data as Loose).capabilities.upstreamVersion).toBe("1.17.0-mock");
|
||||
// 空串 = 保持原凭据(FIX-8 只写凭证统一语义)
|
||||
const kept = await h.req(`/browser/providers/${id}`, { method: "PATCH", body: { accessKey: "", enabled: true } });
|
||||
expect(kept.body.data).toMatchObject({ hasAccessKey: true, secretRef: u.body.data.secretRef });
|
||||
const cleared = await h.req(`/browser/providers/${id}`, { method: "PATCH", body: { accessKey: null } });
|
||||
expect(cleared.body.data).toMatchObject({ hasAccessKey: false, secretRef: null });
|
||||
expect((await h.req(`/browser/providers/${id}`, { method: "DELETE" })).status).toBe(200);
|
||||
|
||||
@@ -39,6 +39,7 @@ import type {
|
||||
DownloadArtifact,
|
||||
NavigationBudget,
|
||||
} from "@umt/contracts";
|
||||
import { secretInput } from "@umt/contracts";
|
||||
import {
|
||||
type BrowserControlLeaseRow,
|
||||
type BrowserProviderRow,
|
||||
@@ -354,8 +355,9 @@ export class BrowserService {
|
||||
.where("name", "=", body.name)
|
||||
.executeTakeFirst();
|
||||
if (exists) throw new BrowserServiceError("conflict", `浏览器服务「${body.name}」已存在`);
|
||||
const secretRef = body.accessKey
|
||||
? await this.ctx.secrets.create({ purpose: "other", plaintext: body.accessKey, label: `browser:${body.name}` })
|
||||
const accessKey = secretInput(body.accessKey);
|
||||
const secretRef = accessKey
|
||||
? await this.ctx.secrets.create({ purpose: "other", plaintext: accessKey, label: `browser:${body.name}` })
|
||||
: null;
|
||||
const row = await this.ctx.db
|
||||
.insertInto("browser_provider")
|
||||
@@ -374,8 +376,10 @@ export class BrowserService {
|
||||
/** 改地址 / 凭据 / 启用;accessKey = null 清除凭据。camofox-env 的地址与凭据归环境变量管,只能改启用 */
|
||||
async updateProvider(
|
||||
id: string,
|
||||
body: { baseUrl?: string | undefined; accessKey?: string | null | undefined; enabled?: boolean | undefined },
|
||||
input: { baseUrl?: string | undefined; accessKey?: string | null | undefined; enabled?: boolean | undefined },
|
||||
): Promise<BrowserProviderView> {
|
||||
// 只写凭证统一语义:省略或空串 = 不变,null = 清除
|
||||
const body = { ...input, accessKey: secretInput(input.accessKey) };
|
||||
const row = await this.ctx.db.selectFrom("browser_provider").selectAll().where("id", "=", id).executeTakeFirst();
|
||||
if (!row) throw new BrowserServiceError("not_found", "浏览器服务不存在");
|
||||
const changesAddr = body.baseUrl !== undefined && body.baseUrl !== row.base_url;
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type UpstreamCredentialStore,
|
||||
type UpstreamSpec,
|
||||
} from "@umt/agent";
|
||||
import type { Actor, PiUpstream, PiUpstreamCreate, PiUpstreamUpdate } from "@umt/contracts";
|
||||
import { type Actor, type PiUpstream, type PiUpstreamCreate, type PiUpstreamUpdate, secretInput } from "@umt/contracts";
|
||||
import type { PiUpstreamRow } from "@umt/db";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import type { ServerContext } from "../types.ts";
|
||||
@@ -91,8 +91,10 @@ export async function listUpstreams(
|
||||
export async function createUpstream(
|
||||
ctx: ServerContext,
|
||||
actor: Actor | undefined,
|
||||
body: PiUpstreamCreate,
|
||||
input: PiUpstreamCreate,
|
||||
): Promise<PiUpstream> {
|
||||
// 只写凭证统一语义:空串 = 不设置
|
||||
const body = { ...input, apiKey: secretInput(input.apiKey) ?? undefined };
|
||||
validateShape(body);
|
||||
const id = randomUUID();
|
||||
let secretRef: string | null = null;
|
||||
@@ -132,9 +134,13 @@ export async function updateUpstream(
|
||||
ctx: ServerContext,
|
||||
actor: Actor | undefined,
|
||||
row: PiUpstreamRow,
|
||||
body: PiUpstreamUpdate,
|
||||
input: PiUpstreamUpdate,
|
||||
onCredentialDropped: () => void,
|
||||
): Promise<PiUpstream> {
|
||||
// 只写凭证统一语义:省略或空串 = 不变,null = 清除
|
||||
const { apiKey: rawKey, ...rest } = input;
|
||||
const apiKey = secretInput(rawKey);
|
||||
const body: PiUpstreamUpdate = apiKey === undefined ? rest : { ...rest, apiKey };
|
||||
const authKind = body.authKind ?? row.auth_kind;
|
||||
const baseUrl = body.baseUrl !== undefined ? body.baseUrl : authKind === "oauth" ? null : row.base_url;
|
||||
const defaultModel = body.defaultModel !== undefined ? body.defaultModel : row.default_model;
|
||||
|
||||
@@ -21,7 +21,9 @@ import type {
|
||||
SiteLoginResult,
|
||||
SiteUserDataPoint,
|
||||
} from "@umt/contracts";
|
||||
import { secretInput } from "@umt/contracts";
|
||||
import { type SiteAccountRow, type SiteRow, sql } from "@umt/db";
|
||||
import { ApiException } from "../../../http/errors.ts";
|
||||
import { toApi } from "../rows.ts";
|
||||
import type { SourcesRuntime } from "../runtime.ts";
|
||||
import { type Actor, audit, closeDecisions, type Exec, openDecision, SYSTEM } from "./common.ts";
|
||||
@@ -92,24 +94,40 @@ const PURPOSE: Record<string, SecretPurpose> = {
|
||||
password: "site_password",
|
||||
};
|
||||
|
||||
const MISSING_SECRET: Record<string, string> = {
|
||||
password: "填写密码",
|
||||
cookie: "粘贴 Cookie",
|
||||
api_key: "填写 API Key",
|
||||
};
|
||||
|
||||
type NormalizedCredentials =
|
||||
| {
|
||||
kind: "password";
|
||||
label: string;
|
||||
username: string;
|
||||
password: string;
|
||||
totpSecret?: string;
|
||||
/** undefined = 保持已存密码 */
|
||||
password?: string | undefined;
|
||||
/** undefined = 保持;null = 清除 */
|
||||
totpSecret?: string | null | undefined;
|
||||
saveCookieOnly?: boolean;
|
||||
userAgent?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
| { kind: "cookie"; label: string; cookie: string; userAgent?: string; enabled?: boolean }
|
||||
| { kind: "api_key"; label: string; apiKey: string; enabled?: boolean }
|
||||
| { kind: "cookie"; label: string; cookie?: string | undefined; userAgent?: string; enabled?: boolean }
|
||||
| { kind: "api_key"; label: string; apiKey?: string | undefined; enabled?: boolean }
|
||||
| { kind: "passkey"; label: string; passkey: string; enabled?: boolean };
|
||||
|
||||
/** 旧格式 {authKind, credential, username?} → 新格式 */
|
||||
export function normalizeCredentials(body: SiteCredentialsPut): NormalizedCredentials {
|
||||
if ("kind" in body) return body as NormalizedCredentials;
|
||||
if ("kind" in body) {
|
||||
// 只写凭证统一语义:省略或空串 = 保持已存值;TOTP 的 null = 清除
|
||||
const b = body as NormalizedCredentials;
|
||||
if (b.kind === "password")
|
||||
return { ...b, password: secretInput(b.password) ?? undefined, totpSecret: secretInput(b.totpSecret) };
|
||||
if (b.kind === "cookie") return { ...b, cookie: secretInput(b.cookie) ?? undefined };
|
||||
if (b.kind === "api_key") return { ...b, apiKey: secretInput(b.apiKey) ?? undefined };
|
||||
return b;
|
||||
}
|
||||
const label = body.label;
|
||||
const enabled = body.enabled;
|
||||
const opt = enabled === undefined ? {} : { enabled };
|
||||
@@ -148,7 +166,7 @@ export async function setSiteCredentials(
|
||||
// 已有账号的同类凭证原地覆盖(replace),不新建 secret
|
||||
const pre = await db
|
||||
.selectFrom("site_account")
|
||||
.select(["auth_kind", "secret_ref", "passkey_secret_ref", "totp_secret_ref"])
|
||||
.select(["auth_kind", "secret_ref", "passkey_secret_ref", "totp_secret_ref", "session_secret_ref", "login_name"])
|
||||
.where("site_id", "=", site.id)
|
||||
.where("label", "=", label)
|
||||
.executeTakeFirst();
|
||||
@@ -213,11 +231,35 @@ export async function setSiteCredentials(
|
||||
});
|
||||
} else {
|
||||
const plaintext = body.kind === "password" ? body.password : body.kind === "cookie" ? body.cookie : body.apiKey;
|
||||
const ref = await put(pre?.auth_kind === body.kind ? pre.secret_ref : null, PURPOSE[body.kind]!, plaintext);
|
||||
const sameKind = pre?.auth_kind === body.kind;
|
||||
// 留空 = 保持已存的主凭证:只在同类账号且已存时可以
|
||||
const keepMain = plaintext === undefined;
|
||||
if (keepMain && !(sameKind && pre?.secret_ref))
|
||||
throw new ApiException("validation_failed", MISSING_SECRET[body.kind] ?? "填写凭证");
|
||||
const ref =
|
||||
plaintext === undefined
|
||||
? pre!.secret_ref!
|
||||
: await put(sameKind ? pre?.secret_ref : null, PURPOSE[body.kind]!, plaintext);
|
||||
if (keepMain) reused.add(ref);
|
||||
// TOTP:新值替换;省略 = 同类账号保持;null 或换类型 = 清除
|
||||
const keepTotp = body.kind === "password" && body.totpSecret === undefined && sameKind && pre?.totp_secret_ref;
|
||||
const totpRef =
|
||||
body.kind === "password" && body.totpSecret
|
||||
? await put(pre?.totp_secret_ref, "site_password", body.totpSecret, ":totp")
|
||||
: keepTotp
|
||||
? pre!.totp_secret_ref
|
||||
: null;
|
||||
if (totpRef) reused.add(totpRef);
|
||||
// 只改 UA / 启用等、密码与登录名都没变:会话 Cookie 仍有效,不丢
|
||||
const keepSession =
|
||||
keepMain &&
|
||||
body.kind === "password" &&
|
||||
pre?.session_secret_ref &&
|
||||
pre.login_name === body.username &&
|
||||
totpRef === pre.totp_secret_ref
|
||||
? pre.session_secret_ref
|
||||
: null;
|
||||
if (keepSession) reused.add(keepSession);
|
||||
result = await db.transaction().execute(async (trx) => {
|
||||
const cur = await trx
|
||||
.selectFrom("site_account")
|
||||
@@ -236,7 +278,7 @@ export async function setSiteCredentials(
|
||||
secret_ref: ref,
|
||||
status: "unknown" as const,
|
||||
last_error: null,
|
||||
session_secret_ref: null,
|
||||
session_secret_ref: keepSession,
|
||||
totp_secret_ref: totpRef,
|
||||
login_name: body.kind === "password" ? body.username : null,
|
||||
user_agent: "userAgent" in body ? (body.userAgent ?? null) : null,
|
||||
|
||||
Reference in new issue
Block a user