fix(security): W4-E1 approvals admin-session only, shared redact table, TMDB key redaction, prod mock gate

- POST /approvals and PATCH /approvals/{id} require a console admin session
  (EndpointDef auth:"session"); API keys incl. admin scope get 403, so an
  organize.execute key can no longer approve its own plan (S1)
- tests for single-use approval checks: expired, revision/payload hash
  mismatch, consumed, revoked, pending/rejected, plan mismatch; re-approve
  after plan change succeeds once and cannot be replayed (S4)
- one redact key table in @umt/contracts shared by server logger and agent
  redactForModel: rsskey, torrent_pass, authkey, downhash, c_secure_pass,
  passkey variants, x-api-key, cookie headers, nested objects/arrays (S2)
- TMDB network errors: message and cause redacted via redactUrl (S3)
- web Agent ?mock=1 only in DEV builds or when pi.provider is fake (S7)
- EndpointDef auth annotations for /auth/*, asserted against
  PUBLIC_AUTH_PATHS (S10); doc drift fixes (S11)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 22:24:44 +08:00
1 parent 79cdd26457
commit f48e1aac96
25 files changed
+825 -235

No files matched your search

+16 -2
View File
@@ -7,6 +7,7 @@
* match/match_multi 的匹配链见 match.ts。
* 调整:网络/鉴权/限流错误以 TmdbError 抛出而非返回 None,调用方不得把"查询失败"当成"没有结果"。
*/
import { redactUrl } from "@umt/contracts";
import { RequestLimiter, TtlLruCache } from "./cache.ts";
export type TmdbErrorKind = "auth" | "network" | "timeout" | "notfound" | "rate" | "protocol";
@@ -122,6 +123,11 @@ export class TmdbClient {
this.fetchImpl = cfg.fetch ?? fetch;
}
/** 错误信息里去掉 api_key / access token(query 参数与明文两种形态都遮) */
private redact(text: string): string {
return redactUrl(text, [this.cfg.apiKey, this.cfg.accessToken]);
}
cacheStats() {
return { size: this.cache.size, hits: this.cache.hits, misses: this.cache.misses, coalesced: this.cache.coalesced };
}
@@ -173,7 +179,12 @@ export class TmdbClient {
const name = (e as { name?: string })?.name;
if (name === "TimeoutError" || name === "AbortError")
throw new TmdbError("timeout", "TMDB request timed out", { path });
throw new TmdbError("network", `TMDB request failed: ${(e as Error)?.message}`, { path, cause: e });
// Deno fetch 的错误信息带完整 URL(含 api_key):message 先脱敏,cause 只留脱敏后的名字与信息,不透传原对象
const msg = this.redact(String((e as Error)?.message ?? e));
throw new TmdbError("network", `TMDB request failed: ${msg}`, {
path,
cause: { name: (e as Error)?.name ?? "Error", message: msg },
});
}
if (res.status === 401) {
await res.body?.cancel();
@@ -199,7 +210,10 @@ export class TmdbClient {
try {
return await res.json();
} catch (e) {
throw new TmdbError("protocol", "TMDB returned invalid JSON", { path, cause: e });
throw new TmdbError("protocol", "TMDB returned invalid JSON", {
path,
cause: { name: (e as Error)?.name ?? "Error", message: this.redact(String((e as Error)?.message ?? e)) },
});
}
}