§ · Nested detail page

App Keys 密钥治理 深度解析

这页拆 Rome 的 write-only app key:值能写入 app 环境,但 API 和 UI 都不能读回明文。

§ 00 · IMPORT CHAIN

导入链与契约

共享契约在 packages/api-types/src/app-keys.ts:6 到 packages/api-types/src/app-keys.ts:15 先规定 wire DTO 不带 value;packages/web/src/lib/app-keys-api.ts:6 到 packages/web/src/lib/app-keys-api.ts:25 让前端只调用列表、保存、删除三个 API;packages/web/src/components/connections/app-keys-section.tsx:19 到 packages/web/src/components/connections/app-keys-section.tsx:22 又把 replace 表单设计成永远重输 value。packages/core/src/api/routes/app-keys.ts:16 是只读列表入口,packages/core/src/api/routes/app-keys.ts:28 是写入入口,packages/core/src/api/routes/app-keys.ts:54 负责持久化,packages/core/src/api/routes/app-keys.ts:71 负责删除后刷新;其余校验行分散在后文逐段解释,避免把源码证据堆成不可读的行号清单。 这些路由行共同证明秘密值没有读回路径。

packages/api-types/src/app-keys.ts · L1-30TypeScript
1// App keys — guardian-entered named values injected into the Rome process
2// environment for apps to read. Shared contract between core's routes, the
3// dashboard, and mock mode. Name validation lives here rather than in core so
4// the mock rejects exactly the names the server rejects.
5
6/** The value-free wire shape. Values never travel: the API returns names and
7 * labels only, and `overridden` says a real environment variable is shadowing
8 * the stored value (operator env always wins). */
9export interface AppKeyDto {
10  name: string;
11  label: string;
12  /** ISO-8601 timestamp of the last write. */
13  updatedAt: string;
14  overridden: boolean;
15}
16
17export interface AppKeysListResponse {
18  keys: AppKeyDto[];
19}
20
21export const APP_KEY_MAX_NAME_LENGTH = 64;
22export const APP_KEY_MAX_LABEL_LENGTH = 120;
23export const APP_KEY_MAX_VALUE_LENGTH = 32768;
24
25const NAME_PATTERN = /^[A-Z][A-Z0-9_]*$/;
26
27// An app key lands in `process.env`, so a name that collides with platform
28// configuration could reroute the database, the API ports, or an upstream
29// credential. Config env names are only expressed as inline `env.X` reads in
30// core's `config.ts` (`envToRawConfig`) — there is no enumerable schema to
§ 01 · CORE IMPL

核心实现,逐段拆

▸API route 只返回投影

packages/core/src/api/routes/app-keys.ts:16 到 packages/core/src/api/routes/app-keys.ts:25 把 repository rows 映射成 name、label、updatedAt、overridden;packages/core/src/api/routes/app-keys.ts:37 到 packages/core/src/api/routes/app-keys.ts:45 拒绝空值和超长值;packages/core/src/api/routes/app-keys.ts:54 到 packages/core/src/api/routes/app-keys.ts:61 写入后按 injector 返回结果决定是否刷新 runtime。

packages/core/src/api/routes/app-keys.ts · L16-76TypeScript
16  app.get("/app-keys", async (c) => {
17    const rows = await deps.appKeysRepo.list();
18    return c.json({
19      keys: rows.map((row) => ({
20        name: row.name,
21        label: row.label,
22        updatedAt: row.updatedAt.toISOString(),
23        overridden: deps.appKeyInjector.isOverridden(row.name),
24      })),
25    });
26  });
27
28  app.put("/app-keys/:name", async (c) => {
29    const name = c.req.param("name");
30    const nameError = appKeyNameError(name);
31    if (nameError) return c.json({ error: nameError }, 400);
32
33    const body = await c.req
34      .json<{ label?: unknown; value?: unknown }>()
35      .catch(() => ({}) as { label?: unknown; value?: unknown });
36
37    if (typeof body.value !== "string" || body.value.length === 0) {
38      return c.json({ error: "Value is required." }, 400);
39    }
40    if (body.value.length > APP_KEY_MAX_VALUE_LENGTH) {
41      return c.json(
42        { error: `Value must be at most ${APP_KEY_MAX_VALUE_LENGTH} characters.` },
43        400,
44      );
45    }
46    const label = typeof body.label === "string" && body.label.trim() ? body.label.trim() : name;
47    if (label.length > APP_KEY_MAX_LABEL_LENGTH) {
48      return c.json(
49        { error: `Label must be at most ${APP_KEY_MAX_LABEL_LENGTH} characters.` },
50        400,
51      );
52    }
53
54    await deps.appKeysRepo.upsert({ name, label, value: body.value });
55    const live = deps.appKeyInjector.apply(name, body.value);
56    // Running app code captured the old environment: warm workers hold a
57    // fork-time snapshot, and cached main-process modules (API handlers,
58    // hooks) may have read env at module scope. Refresh them whenever the
59    // environment actually changed (an overridden save changes nothing).
60    if (live) await deps.refreshAppRuntime();
61    return c.json({ ok: true, overridden: !live });
62  });
63
64  app.delete("/app-keys/:name", async (c) => {
65    const name = c.req.param("name");
66    const existing = await deps.appKeysRepo.get(name);
67    if (!existing) return c.json({ error: "Unknown app key." }, 404);
68    await deps.appKeysRepo.delete(name);
69    // Same staleness as the save path: without a refresh, running app code
70    // keeps serving the deleted secret until Rome restarts.
71    if (deps.appKeyInjector.remove(name)) await deps.refreshAppRuntime();
72    return c.json({ ok: true });
73  });
74
75  return app;
76}

▸Repository 保留 value-free list

packages/core/src/db/repositories/app-keys.ts:13 到 packages/core/src/db/repositories/app-keys.ts:21 明确 summary 不带 value,packages/core/src/db/repositories/app-keys.ts:26 到 packages/core/src/db/repositories/app-keys.ts:35 的 list 只 select 四个字段;只有 packages/core/src/db/repositories/app-keys.ts:38 到 packages/core/src/db/repositories/app-keys.ts:40 的 listWithValues 能读出明文,注释限定给启动和注入路径使用。

packages/core/src/db/repositories/app-keys.ts · L13-64TypeScript
13/** The value-free projection the API returns. The secret only leaves this
14 * repository through `listWithValues`, whose sole consumer is the boot/save
15 * injector. */
16export interface AppKeySummary {
17  name: string;
18  label: string;
19  createdAt: Date;
20  updatedAt: Date;
21}
22
23export class AppKeysRepository {
24  constructor(private db: DrizzleDb) {}
25
26  async list(): Promise<AppKeySummary[]> {
27    const rows = await this.db
28      .select({
29        name: appKeys.name,
30        label: appKeys.label,
31        createdAt: appKeys.createdAt,
32        updatedAt: appKeys.updatedAt,
33      })
34      .from(appKeys);
35    return rows.sort((a, b) => a.name.localeCompare(b.name));
36  }
37
38  async listWithValues(): Promise<AppKeyRow[]> {
39    return await this.db.select().from(appKeys);
40  }
41
42  async get(name: string): Promise<AppKeyRow | null> {
43    const rows = await this.db.select().from(appKeys).where(eq(appKeys.name, name));
44    return rows[0] ?? null;
45  }
46
47  // One atomic upsert rather than a read-then-branch: two concurrent writers to
48  // the same name would both observe an empty read and race into a duplicate
49  // insert on the `app_keys.name` primary key.
50  async upsert(input: { name: string; label: string; value: string }): Promise<void> {
51    const now = new Date();
52    await this.db
53      .insert(appKeys)
54      .values({ ...input, createdAt: now, updatedAt: now })
55      .onConflictDoUpdate({
56        target: appKeys.name,
57        set: { label: input.label, value: input.value, updatedAt: now },
58      });
59  }
60
61  async delete(name: string): Promise<void> {
62    await this.db.delete(appKeys).where(eq(appKeys.name, name));
63  }
64}

▸Injector 尊重 operator env

packages/core/src/app-keys/injector.ts:5 到 packages/core/src/app-keys/injector.ts:15 写出两个不变量:真实环境变量优先,以及只删除自己写过的键。packages/core/src/app-keys/injector.ts:25、packages/core/src/app-keys/injector.ts:32、packages/core/src/app-keys/injector.ts:40、packages/core/src/app-keys/injector.ts:42 是这个状态机的关键转移点。

packages/core/src/app-keys/injector.ts · L5-50TypeScript
5/**
6 * Owns the stored-key slice of `process.env`. Two invariants:
7 *
8 * - The real process environment always wins. A name that already had a value
9 *   before injection is never overwritten — operator config (env files, CI,
10 *   container env) cannot be shadowed from the dashboard. Such keys are
11 *   reported as overridden instead of silently going live.
12 * - Only names this injector set are ever deleted or scrubbed. It tracks its
13 *   own writes, so removing a stored key can never unset an operator-set var
14 *   that happens to share the name.
15 */
16export class AppKeyInjector {
17  private injected = new Set<string>();
18  private overridden = new Set<string>();
19
20  constructor(private env: NodeJS.ProcessEnv = process.env) {}
21
22  /** Applies a stored key. Returns true when the value went live, false when a
23   * pre-existing environment value kept precedence. */
24  apply(name: string, value: string): boolean {
25    if (!this.injected.has(name) && this.env[name] !== undefined) {
26      this.overridden.add(name);
27      logger.warn(
28        `App key ${name} is overridden by the process environment; the stored value is not live`,
29      );
30      return false;
31    }
32    this.env[name] = value;
33    this.injected.add(name);
34    return true;
35  }
36
37  /** Removes a stored key from the environment, only if this injector set it.
38   * Returns true when the environment actually changed. */
39  remove(name: string): boolean {
40    this.overridden.delete(name);
41    if (!this.injected.has(name)) return false;
42    delete this.env[name];
43    this.injected.delete(name);
44    return true;
45  }
46
47  isOverridden(name: string): boolean {
48    return this.overridden.has(name);
49  }
50}
§ 02 · KEY FLOW

关键流程

UI form → /api/app-keys/:name → AppKeysRepository.upsert → AppKeyInjector.apply → refreshAppRuntime
delete → repository.delete → injector.remove → refresh only when env changed

UI 侧 packages/web/src/components/connections/app-keys-section.tsx:77 到 packages/web/src/components/connections/app-keys-section.tsx:89 先做客户端校验和 upper-case name;packages/web/src/components/connections/app-keys-section.tsx:157 到 packages/web/src/components/connections/app-keys-section.tsx:163 显示 overridden/set 状态;packages/web/src/components/connections/app-keys-section.tsx:210 到 packages/web/src/components/connections/app-keys-section.tsx:220 使用 password input 且 value 不回填。这个流程把用户体验和服务端安全模型对齐。

packages/web/src/components/connections/app-keys-section.tsx · L77-89TSX
77  const submit = () => {
78    const trimmedName = name.trim();
79    const nameError = appKeyNameError(trimmedName);
80    if (nameError) {
81      setFormError(nameError);
82      return;
83    }
84    if (!value) {
85      setFormError(t("appKeys.form.valueRequired"));
86      return;
87    }
88    saveMutation.mutate({ name: trimmedName, label: label.trim() || trimmedName, value });
89  };
§ 03 · EDGE CASES

边界 / 失败模式

  • packages/core/src/api/routes/app-keys.test.ts:33 到 packages/core/src/api/routes/app-keys.test.ts:48 验证 list 不泄漏 hunter2。
  • packages/core/src/api/routes/app-keys.test.ts:71 到 packages/core/src/api/routes/app-keys.test.ts:84 验证 operator env 覆盖 dashboard 值。
  • packages/core/src/api/routes/app-keys.test.ts:86 到 packages/core/src/api/routes/app-keys.test.ts:157 验证 warm worker pool 因密钥变化重启。
  • packages/core/src/app-keys/reserved.test.ts:21 到 packages/core/src/app-keys/reserved.test.ts:30 验证 PATH、ROME、ANTHROPIC、DATABASE 等保留名被拒绝。
Generated · 2026-08-28Source · rome-os/rome