Skip to content

Commit 46e20e6

Browse files
youhaoweiclaude
andauthored
feat(types,server): [YW-139] DataSource.config typed field + SetDataSourceConfig wiring (#139)
## Summary - Adds `ConnectorConfig` to `@dashframe/types`: structured public-safe config blob where credential fields (`hasApiKey`, `hasConnectionString`) are boolean presence flags, never raw `SecretRef` values or plaintext. Non-credential connector settings pass through as-is. - `rowToDataSource` converts the internal `DataSourceConfig` (SecretRef slots) to `ConnectorConfig`: credential slots are redacted to booleans; non-credential keys are passed through verbatim BUT any value that is a well-formed `SecretRef` (`secret:<uuid>`) is also stripped regardless of key name — sink guarded by value shape, not provenance. - `SetDataSourceConfig` gains an optional `extra: Record<string,unknown>` field for non-credential connector settings (e.g. `database`, `schema`, `host`). Sink guard rejects any attempt to pass `"apiKey"` or `"connectionString"` via `extra` — callers must use the typed credential fields. - UI components and tests migrated from `dataSource.hasApiKey` to `dataSource.config.hasApiKey`. Tracked internally as YW-139. ## Test plan - [ ] `turbo typecheck` — 44 tasks, 0 failures - [ ] `@dashframe/server` tests — 222 pass (219 existing + 3 new) - `SetDataSourceConfig sink guard: extra.apiKey throws and leaves config unchanged` - `SetDataSourceConfig sink guard: extra.connectionString throws and leaves config unchanged` - `SetDataSourceConfig extra: non-credential settings round-trip through config` - [ ] `@dashframe/app` tests — 510 pass (all existing) - [ ] vault-control-plane AC3 test extended: `config.apiKey` is absent from the read DTO even when `hasApiKey` is true (SecretRef redaction verified) - [ ] CI green ## Findings triaged to LATER - **OOD-1** — Legacy `updateDataSource` coexistence. The `utils.ts` header calls it a "transition window" which collides with the no-backward-compat memory. TRIGGER: before any new connector (PostgreSQL or similar) adds a caller, cut `updateDataSource` — it has no sink guard and no `extra` path. - **OOD-2** — `CreateDataSource` lacks `extra` parameter; `SetDataSourceConfig` has it. TRIGGER: when the first connector needing creation-time non-credential settings is defined, add `extra` (+ same sink guard) to `CreateDataSource` for symmetry. - **OOD-3** — `ConnectorConfig` index signature `[key: string]: unknown`. TRIGGER: when the 3rd connector lands, revisit as a discriminated union per Rule of Three. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- greptile_comment --> <details open><summary><h3>Greptile Summary</h3></summary> This PR introduces `ConnectorConfig` as a typed public-safe DTO for the `DataSource.config` field, nesting credential presence flags (`hasApiKey`, `hasConnectionString`) and pass-through non-credential settings there instead of at the top level. It also wires an `extra` parameter into `SetDataSourceConfig` with a sink guard that blocks `"apiKey"`/`"connectionString"` from being smuggled in. - **New `ConnectorConfig` type** (`packages/types/src/data-sources.ts`): replaces flat `hasApiKey`/`hasConnectionString` on `DataSource` with a structured `config` blob; credential fields are boolean presence flags, extra non-credential keys pass through under an open index signature. - **`rowToDataSource` redaction loop** (`app-artifacts.ts`): iterates stored config, drops credential slots by key name and drops any SecretRef-shaped value by value shape, then spreads remaining keys into the DTO after the vault-computed booleans. - **`SetDataSourceConfig extra` field** (`commands.ts`): accepts non-credential connector settings (e.g. `database`, `schema`) and merges them into the stored config; sink guard rejects `"apiKey"`/`"connectionString"` in `extra`, directing callers to use the typed credential fields. </details> <details><summary><h3>Confidence Score: 4/5</h3></summary> Safe to merge with the understanding that the previously-flagged spoofing path in the sink guard remains open. The core redaction logic in `rowToDataSource` and the `extra` sink guard in `SetDataSourceConfig` are well-structured, and all UI callsites are consistently migrated. However, the existing unresolved finding from a prior review (callers can pass `extra: { hasApiKey: true }`, which `Object.assign` writes into stored config and then the `...otherKeys` spread in `rowToDataSource` surfaces as a spoofed `config.hasApiKey: true` in the DTO, overriding the vault-computed boolean) is still present in this diff and has not been addressed. `apps/server/src/functions/commands.ts` (sink guard) and `apps/server/src/functions/app-artifacts.ts` (`rowToDataSource` spread order) together form the unresolved spoofing path flagged in the prior review. </details> <details><summary><h3>Important Files Changed</h3></summary> | Filename | Overview | |----------|----------| | packages/types/src/data-sources.ts | Adds `ConnectorConfig` type with `hasApiKey`, `hasConnectionString`, and an open index signature; replaces flat `hasApiKey`/`hasConnectionString` on `DataSource` with `config: ConnectorConfig`. | | apps/server/src/functions/app-artifacts.ts | Adds `otherKeys` loop in `rowToDataSource` to pass through non-credential, non-SecretRef config keys; spreads them after the vault-computed booleans, which means stored `hasApiKey`/`hasConnectionString` keys in `otherKeys` win over the vault-derived values. | | apps/server/src/functions/commands.ts | Adds `extra: jsonb.optional()` to `SetDataSourceConfig`; sink guard blocks `"apiKey"`/`"connectionString"` keys but not `"hasApiKey"`/`"hasConnectionString"`, and guard runs after `applyCredentialField` vault writes (both previously flagged). | | apps/server/src/functions/commands.test.ts | Adds three new tests for `SetDataSourceConfig`: sink-guard rejection for `extra.apiKey`, sink-guard rejection for `extra.connectionString`, and round-trip of non-credential extra keys. | | apps/server/src/functions/vault-control-plane.test.ts | Migrates all assertions from `ds.hasApiKey` to `ds.config.hasApiKey` and adds a new assertion verifying the SecretRef is absent from the public DTO (AC3 extension). | | packages/app/src/app/data-sources/[sourceId]/_components/DataSourcePageContent.test.tsx | Updates test fixture from unsafe `as DataSource` cast to type-checked `satisfies DataSource` with correct `config` shape and `createdAt`. | | packages/app/src/components/data-sources/DataSourceControls.tsx | Mechanical migration of `dataSource.hasApiKey` → `dataSource.config.hasApiKey` at three call sites. | | packages/app/src/components/data-sources/DataSourceDisplay.tsx | Mechanical migration of `dataSource.hasApiKey` → `dataSource.config.hasApiKey` at three guard sites. | | packages/types/src/index.ts | Adds `ConnectorConfig` to the public package exports. One-line change, correct. | </details> </details> <details><summary><h3>Sequence Diagram</h3></summary> <a href="#gh-light-mode-only"> ```mermaid %%{init: {'theme': 'neutral'}}%% sequenceDiagram participant UI as UI Component participant CMD as SetDataSourceConfig participant Vault as SecretVault participant DB as Database participant DTO as rowToDataSource UI->>CMD: "{ id, apiKey?, connectionString?, extra? }" CMD->>CMD: applyCredentialField(apiKey) → SecretRef CMD->>Vault: vault.store(apiKey plaintext) Vault-->>CMD: SecretRef CMD->>CMD: sink guard: reject if extra has apiKey/connectionString CMD->>CMD: Object.assign(config, extra) CMD->>DB: UPDATE data_sources SET config DB-->>CMD: ok UI->>DTO: getDataSource(id) DTO->>DB: SELECT config FROM data_sources DB-->>DTO: "{ apiKey: SecretRef, database: analytics, ... }" DTO->>Vault: vault.has(SecretRef) → boolean Vault-->>DTO: hasApiKey true/false DTO->>DTO: otherKeys loop: skip apiKey, connectionString, SecretRef values DTO-->>UI: "{ config: { hasApiKey, hasConnectionString, database, ... } }" ``` </a> <a href="#gh-dark-mode-only"> ```mermaid %%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant UI as UI Component participant CMD as SetDataSourceConfig participant Vault as SecretVault participant DB as Database participant DTO as rowToDataSource UI->>CMD: "{ id, apiKey?, connectionString?, extra? }" CMD->>CMD: applyCredentialField(apiKey) → SecretRef CMD->>Vault: vault.store(apiKey plaintext) Vault-->>CMD: SecretRef CMD->>CMD: sink guard: reject if extra has apiKey/connectionString CMD->>CMD: Object.assign(config, extra) CMD->>DB: UPDATE data_sources SET config DB-->>CMD: ok UI->>DTO: getDataSource(id) DTO->>DB: SELECT config FROM data_sources DB-->>DTO: "{ apiKey: SecretRef, database: analytics, ... }" DTO->>Vault: vault.has(SecretRef) → boolean Vault-->>DTO: hasApiKey true/false DTO->>DTO: otherKeys loop: skip apiKey, connectionString, SecretRef values DTO-->>UI: "{ config: { hasApiKey, hasConnectionString, database, ... } }" ``` </a> </details> <!-- greptile_failed_comments --> <details><summary><h3>Comments Outside Diff (1)</h3></summary> 1. `apps/server/src/functions/commands.ts`, line 355-380 ([link](https://github.com/youhaowei/dashframe/blob/e596efaf5a417bd9d01cf005badd6a228128a79b/apps/server/src/functions/commands.ts#L355-L380)) <a href="#"><img alt="P2" src="https://greptile-static-assets.s3.amazonaws.com/badges/p2.svg?v=9" align="top"></a> **Sink guard runs after vault writes — unnecessary vault-ref orphaning on rejection** `applyCredentialField` is called before the `extra` sink guard. If a caller supplies both a non-empty `apiKey` and `extra: { apiKey: "x" }`, the vault write for `apiKey` completes (and a new `SecretRef` is generated) before the guard throws and aborts the DB update. The orphaned ref is benign per the deferred-cleanup design, but the guard should be hoisted above the `applyCredentialField` calls so rejections fail before touching the vault at all. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: apps/server/src/functions/commands.ts Line: 355-380 Comment: **Sink guard runs after vault writes — unnecessary vault-ref orphaning on rejection** `applyCredentialField` is called before the `extra` sink guard. If a caller supplies both a non-empty `apiKey` and `extra: { apiKey: "x" }`, the vault write for `apiKey` completes (and a new `SecretRef` is generated) before the guard throws and aborts the DB update. The orphaned ref is benign per the deferred-cleanup design, but the guard should be hoisted above the `applyCredentialField` calls so rejections fail before touching the vault at all. How can I resolve this? If you propose a fix, please make it concise. ````` </details> Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! <a href="https://app.greptile.com/ide/claude-code?prompt=This%20is%20a%20comment%20left%20during%20a%20code%20review.%0APath%3A%20apps%2Fserver%2Fsrc%2Ffunctions%2Fcommands.ts%0ALine%3A%20355-380%0A%0AComment%3A%0A**Sink%20guard%20runs%20after%20vault%20writes%20%E2%80%94%20unnecessary%20vault-ref%20orphaning%20on%20rejection**%0A%0A%60applyCredentialField%60%20is%20called%20before%20the%20%60extra%60%20sink%20guard.%20If%20a%20caller%20supplies%20both%20a%20non-empty%20%60apiKey%60%20and%20%60extra%3A%20%7B%20apiKey%3A%20%22x%22%20%7D%60%2C%20the%20vault%20write%20for%20%60apiKey%60%20completes%20%28and%20a%20new%20%60SecretRef%60%20is%20generated%29%20before%20the%20guard%20throws%20and%20aborts%20the%20DB%20update.%20The%20orphaned%20ref%20is%20benign%20per%20the%20deferred-cleanup%20design%2C%20but%20the%20guard%20should%20be%20hoisted%20above%20the%20%60applyCredentialField%60%20calls%20so%20rejections%20fail%20before%20touching%20the%20vault%20at%20all.%0A%0AHow%20can%20I%20resolve%20this%3F%20If%20you%20propose%20a%20fix%2C%20please%20make%20it%20concise.&repo=youhaowei%2Fdashframe&pr=139&platform=github"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInClaudeDark.svg?v=3"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInClaude.svg?v=3"><img alt="Fix in Claude Code" src="https://greptile-static-assets.s3.amazonaws.com/badges/FixInClaude.svg?v=3" height="20"></picture></a> <a href="https://app.greptile.com/api/ide/codex?prompt=IMPORTANT%3A%20Work%20in%20the%20repository%20%22youhaowei%2Fdashframe%22%20on%20the%20existing%20branch%20%22charixandra%2Fyw-139-datasourceconfig-typed-field-setdatasourceconfig-wiring%22.%20Checkout%20that%20branch%20%E2%80%94%20do%20NOT%20create%20a%20new%20branch%20or%20open%20a%20new%20PR.%20Push%20your%20changes%20to%20%22charixandra%2Fyw-139-datasourceconfig-typed-field-setdatasourceconfig-wiring%22.%0A%0AThis%20is%20a%20comment%20left%20during%20a%20code%20review.%0APath%3A%20apps%2Fserver%2Fsrc%2Ffunctions%2Fcommands.ts%0ALine%3A%20355-380%0A%0AComment%3A%0A**Sink%20guard%20runs%20after%20vault%20writes%20%E2%80%94%20unnecessary%20vault-ref%20orphaning%20on%20rejection**%0A%0A%60applyCredentialField%60%20is%20called%20before%20the%20%60extra%60%20sink%20guard.%20If%20a%20caller%20supplies%20both%20a%20non-empty%20%60apiKey%60%20and%20%60extra%3A%20%7B%20apiKey%3A%20%22x%22%20%7D%60%2C%20the%20vault%20write%20for%20%60apiKey%60%20completes%20%28and%20a%20new%20%60SecretRef%60%20is%20generated%29%20before%20the%20guard%20throws%20and%20aborts%20the%20DB%20update.%20The%20orphaned%20ref%20is%20benign%20per%20the%20deferred-cleanup%20design%2C%20but%20the%20guard%20should%20be%20hoisted%20above%20the%20%60applyCredentialField%60%20calls%20so%20rejections%20fail%20before%20touching%20the%20vault%20at%20all.%0A%0AHow%20can%20I%20resolve%20this%3F%20If%20you%20propose%20a%20fix%2C%20please%20make%20it%20concise.&repo=youhaowei%2Fdashframe&pr=139&platform=github"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCodexDark.svg?v=3"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCodex.svg?v=3"><img alt="Fix in Codex" src="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCodex.svg?v=3" height="20"></picture></a> <a href="https://app.greptile.com/api/ide/cursor?prompt=This%20is%20a%20comment%20left%20during%20a%20code%20review.%0APath%3A%20apps%2Fserver%2Fsrc%2Ffunctions%2Fcommands.ts%0ALine%3A%20355-380%0A%0AComment%3A%0A**Sink%20guard%20runs%20after%20vault%20writes%20%E2%80%94%20unnecessary%20vault-ref%20orphaning%20on%20rejection**%0A%0A%60applyCredentialField%60%20is%20called%20before%20the%20%60extra%60%20sink%20guard.%20If%20a%20caller%20supplies%20both%20a%20non-empty%20%60apiKey%60%20and%20%60extra%3A%20%7B%20apiKey%3A%20%22x%22%20%7D%60%2C%20the%20vault%20write%20for%20%60apiKey%60%20completes%20%28and%20a%20new%20%60SecretRef%60%20is%20generated%29%20before%20the%20guard%20throws%20and%20aborts%20the%20DB%20update.%20The%20orphaned%20ref%20is%20benign%20per%20the%20deferred-cleanup%20design%2C%20but%20the%20guard%20should%20be%20hoisted%20above%20the%20%60applyCredentialField%60%20calls%20so%20rejections%20fail%20before%20touching%20the%20vault%20at%20all.%0A%0AHow%20can%20I%20resolve%20this%3F%20If%20you%20propose%20a%20fix%2C%20please%20make%20it%20concise.&pr=139&platform=github"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCursorDark.svg?v=3"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCursor.svg?v=3"><img alt="Fix in Cursor" src="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCursor.svg?v=3" height="20"></picture></a> </details> <!-- /greptile_failed_comments --> <sub>Reviews (3): Last reviewed commit: ["fix(server): harden SetDataSourceConfig ..."](1aac070) | [Re-trigger Greptile](https://app.greptile.com/api/retrigger?id=38073036)</sub> <!-- /greptile_comment --> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f3e8660 commit 46e20e6

9 files changed

Lines changed: 171 additions & 33 deletions

File tree

apps/server/src/functions/app-artifacts.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,12 +256,25 @@ async function rowToDataSource(
256256
hasApiKey = Boolean(config.apiKey);
257257
hasConnectionString = Boolean(config.connectionString);
258258
}
259+
// Pass through any non-credential keys from the stored config so future
260+
// fields are forward-compatible. Two exclusion criteria apply:
261+
// 1. Key name — "apiKey" and "connectionString" are the typed credential
262+
// slots; they never appear in the public DTO (only the boolean flags do).
263+
// 2. Value shape — any value that is a well-formed SecretRef ("secret:<uuid>")
264+
// is also dropped, regardless of key name. Guards the sink by value shape,
265+
// not only by provenance: a future credential field stored under a
266+
// non-standard key name can't leak a live ref to the renderer.
267+
const otherKeys: Record<string, unknown> = {};
268+
for (const [k, v] of Object.entries(config)) {
269+
if (k !== "apiKey" && k !== "connectionString" && !isSecretRef(v)) {
270+
otherKeys[k] = v;
271+
}
272+
}
259273
return {
260274
id: row.id,
261275
type: row.kind,
262276
name: row.name,
263-
hasApiKey,
264-
hasConnectionString,
277+
config: { hasApiKey, hasConnectionString, ...otherKeys },
265278
createdAt: row.createdAt.getTime(),
266279
};
267280
}

apps/server/src/functions/commands.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,77 @@ describe("command vocabulary", () => {
632632
).rejects.toThrow(/not found/);
633633
});
634634

635+
it("SetDataSourceConfig sink guard: extra.apiKey throws and leaves config unchanged", async () => {
636+
const sourceId = id();
637+
await commit(
638+
cmd("CreateDataSource", {
639+
id: sourceId,
640+
type: "notion",
641+
name: "N",
642+
apiKey: "original",
643+
}),
644+
);
645+
const refBefore = (
646+
(await sourcesById(sourceId))[0]?.config as { apiKey?: string }
647+
).apiKey;
648+
// Attempt to smuggle a credential via extra — must throw.
649+
await expect(
650+
commit(
651+
cmd("SetDataSourceConfig", {
652+
id: sourceId,
653+
extra: { apiKey: "smuggled-plaintext" } as Record<string, unknown>,
654+
}),
655+
),
656+
).rejects.toThrow(/apiKey.*connectionString.*typed credential/i);
657+
// Config must be unchanged — the original ref is still there.
658+
const refAfter = (
659+
(await sourcesById(sourceId))[0]?.config as { apiKey?: string }
660+
).apiKey;
661+
expect(refAfter).toBe(refBefore);
662+
});
663+
664+
it("SetDataSourceConfig sink guard: extra.connectionString throws and leaves config unchanged", async () => {
665+
const sourceId = id();
666+
await commit(
667+
cmd("CreateDataSource", { id: sourceId, type: "postgres", name: "P" }),
668+
);
669+
await expect(
670+
commit(
671+
cmd("SetDataSourceConfig", {
672+
id: sourceId,
673+
extra: { connectionString: "postgresql://plaintext" } as Record<
674+
string,
675+
unknown
676+
>,
677+
}),
678+
),
679+
).rejects.toThrow(/apiKey.*connectionString.*typed credential/i);
680+
});
681+
682+
it("SetDataSourceConfig extra: non-credential settings round-trip through config", async () => {
683+
const sourceId = id();
684+
await commit(
685+
cmd("CreateDataSource", { id: sourceId, type: "postgres", name: "P" }),
686+
);
687+
await commit(
688+
cmd("SetDataSourceConfig", {
689+
id: sourceId,
690+
extra: { database: "analytics", schema: "public" } as Record<
691+
string,
692+
unknown
693+
>,
694+
}),
695+
);
696+
const [row] = await sourcesById(sourceId);
697+
const stored = row?.config as Record<string, unknown>;
698+
// Non-credential keys persist as-is.
699+
expect(stored["database"]).toBe("analytics");
700+
expect(stored["schema"]).toBe("public");
701+
// Credential slots remain absent (never set).
702+
expect(stored["apiKey"]).toBeUndefined();
703+
expect(stored["connectionString"]).toBeUndefined();
704+
});
705+
635706
it("should throw on UpdateField with a missing fieldId", async () => {
636707
const sourceId = id();
637708
const tableId = id();

apps/server/src/functions/commands.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,16 +320,21 @@ const createDataSource = mutation({
320320
* SetDataSourceConfig — replaces the config slice of a DataSource (the connector
321321
* secrets). The `name` slice is NOT here — renaming is `RenameNode`. This is the
322322
* config half decomposed out of the coarse `updateDataSource`.
323+
*
324+
* `extra` carries optional non-credential connector settings (e.g. database name,
325+
* schema). Sink guard: any key in `extra` that matches "apiKey" or
326+
* "connectionString" is rejected — callers must use the typed credential fields.
323327
*/
324328
const setDataSourceConfig = mutation({
325329
args: {
326330
id: uuid,
327331
apiKey: text.optional(),
328332
connectionString: text.optional(),
333+
extra: jsonb.optional(),
329334
},
330335
handler: async (
331336
ctx,
332-
{ id, apiKey, connectionString },
337+
{ id, apiKey, connectionString, extra },
333338
): Promise<{ ok: true }> => {
334339
const vault = vaultFromCtx(ctx);
335340
const preview = modeFromCtx(ctx) === "preview";
@@ -363,6 +368,16 @@ const setDataSourceConfig = mutation({
363368
`connectionString-${id}`,
364369
preview,
365370
);
371+
// Sink guard: callers may not sneak credential keys in via `extra`.
372+
if (isRecord(extra)) {
373+
if ("apiKey" in extra || "connectionString" in extra) {
374+
throw new Error(
375+
"SetDataSourceConfig: 'apiKey' and 'connectionString' must use the typed credential fields, not extra",
376+
);
377+
}
378+
// Merge non-credential keys into the config.
379+
Object.assign(config, extra);
380+
}
366381
await ctx.db.from(dataSources).where(eq("id", id)).update({ config });
367382
return { ok: true };
368383
},
@@ -1889,6 +1904,8 @@ export interface CommandPayloads {
18891904
id: UUID;
18901905
apiKey?: string;
18911906
connectionString?: string;
1907+
/** Non-credential connector settings. Must not include 'apiKey' or 'connectionString'. */
1908+
extra?: Record<string, unknown>;
18921909
};
18931910
// DataTable
18941911
CreateDataTable: {

apps/server/src/functions/vault-control-plane.test.ts

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,9 @@ describe("vault control-plane — store→ref + has→presence", () => {
174174

175175
// Presence now reads false — no usable credential remains.
176176
const { result: ds } = await app.call("getDataSource", { id });
177-
expect((ds as { hasApiKey: boolean }).hasApiKey).toBe(false);
177+
expect((ds as { config: { hasApiKey: boolean } }).config.hasApiKey).toBe(
178+
false,
179+
);
178180
});
179181

180182
it("AC2 — setDataSourceConfig with empty apiKey CLEARS the credential", async () => {
@@ -256,9 +258,14 @@ describe("vault control-plane — store→ref + has→presence", () => {
256258
const id = (createResult as { id: string }).id;
257259

258260
const { result } = await app.call("getDataSource", { id });
259-
const ds = result as { hasApiKey: boolean; hasConnectionString: boolean };
260-
expect(ds.hasApiKey).toBe(true);
261-
expect(ds.hasConnectionString).toBe(false);
261+
const ds = result as {
262+
config: { hasApiKey: boolean; hasConnectionString: boolean };
263+
};
264+
expect(ds.config.hasApiKey).toBe(true);
265+
expect(ds.config.hasConnectionString).toBe(false);
266+
// The SecretRef must NOT appear in the public DTO — only the boolean flag.
267+
// Absence here proves rowToDataSource redacts the ref, not just the name.
268+
expect((ds.config as Record<string, unknown>)["apiKey"]).toBeUndefined();
262269
});
263270

264271
it("AC3 — hasApiKey is false when no credential was stored", async () => {
@@ -269,9 +276,11 @@ describe("vault control-plane — store→ref + has→presence", () => {
269276
const id = (createResult as { id: string }).id;
270277

271278
const { result } = await app.call("getDataSource", { id });
272-
const ds = result as { hasApiKey: boolean; hasConnectionString: boolean };
273-
expect(ds.hasApiKey).toBe(false);
274-
expect(ds.hasConnectionString).toBe(false);
279+
const ds = result as {
280+
config: { hasApiKey: boolean; hasConnectionString: boolean };
281+
};
282+
expect(ds.config.hasApiKey).toBe(false);
283+
expect(ds.config.hasConnectionString).toBe(false);
275284
});
276285

277286
it("AC3 — hasApiKey reflects backend.has(), not raw config truthiness", async () => {
@@ -285,8 +294,8 @@ describe("vault control-plane — store→ref + has→presence", () => {
285294

286295
// The backend has() must have been called (not withSecret).
287296
const { result } = await app.call("getDataSource", { id });
288-
const ds = result as { hasApiKey: boolean };
289-
expect(ds.hasApiKey).toBe(true);
297+
const ds = result as { config: { hasApiKey: boolean } };
298+
expect(ds.config.hasApiKey).toBe(true);
290299
// Verify withSecret was NOT called (control plane never decrypts).
291300
expect(backend.resolveCallCount).toBe(0);
292301
// has() was called (presence check).
@@ -306,11 +315,14 @@ describe("vault control-plane — store→ref + has→presence", () => {
306315
});
307316

308317
const { result } = await app.call("listDataSources", {});
309-
const sources = result as { name: string; hasApiKey: boolean }[];
318+
const sources = result as {
319+
name: string;
320+
config: { hasApiKey: boolean };
321+
}[];
310322
const withKey = sources.find((s) => s.name === "With key");
311323
const withoutKey = sources.find((s) => s.name === "Without key");
312-
expect(withKey?.hasApiKey).toBe(true);
313-
expect(withoutKey?.hasApiKey).toBe(false);
324+
expect(withKey?.config.hasApiKey).toBe(true);
325+
expect(withoutKey?.config.hasApiKey).toBe(false);
314326
});
315327
});
316328

packages/app/src/app/data-sources/[sourceId]/_components/DataSourcePageContent.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,9 +191,9 @@ const DATA_SOURCE = {
191191
id: SOURCE_ID,
192192
name: "My Database",
193193
type: "csv",
194-
apiKey: null,
195-
connectionString: null,
196-
} as import("@dashframe/types").DataSource;
194+
config: { hasApiKey: false, hasConnectionString: false },
195+
createdAt: 0,
196+
} satisfies import("@dashframe/types").DataSource;
197197

198198
// ── Tests ─────────────────────────────────────────────────────────────────────
199199

packages/app/src/components/data-sources/DataSourceControls.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,11 @@ export function DataSourceControls({ dataSourceId }: DataSourceControlsProps) {
209209

210210
// Fetch databases with permanent caching (only refreshes on manual click)
211211
const fetchDatabases = async (force = false) => {
212-
if (!dataSource || dataSource.type !== "notion" || !dataSource.hasApiKey)
212+
if (
213+
!dataSource ||
214+
dataSource.type !== "notion" ||
215+
!dataSource.config.hasApiKey
216+
)
213217
return;
214218

215219
// Use cached data unless explicitly forced to refresh
@@ -325,10 +329,12 @@ export function DataSourceControls({ dataSourceId }: DataSourceControlsProps) {
325329
value={apiKeyInput}
326330
onChange={handleApiKeyChange}
327331
className="font-mono text-xs"
328-
placeholder={dataSource.hasApiKey ? "••••••••" : "secret_..."}
332+
placeholder={
333+
dataSource.config.hasApiKey ? "••••••••" : "secret_..."
334+
}
329335
/>
330336
<p className="mt-1.5 text-xs text-neutral-fg-subtle">
331-
{dataSource.hasApiKey
337+
{dataSource.config.hasApiKey
332338
? "A token is saved. Enter a new one to replace it."
333339
: "Your Notion integration token"}
334340
</p>

packages/app/src/components/data-sources/DataSourceDisplay.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ export function DataSourceDisplay({ dataSourceId }: DataSourceDisplayProps) {
372372
!selectedDataTable ||
373373
!dataSource ||
374374
dataSource.type !== "notion" ||
375-
!dataSource.hasApiKey
375+
!dataSource.config.hasApiKey
376376
) {
377377
return;
378378
}
@@ -426,7 +426,7 @@ export function DataSourceDisplay({ dataSourceId }: DataSourceDisplayProps) {
426426
!selectedDataTable ||
427427
!dataSource ||
428428
dataSource.type !== "notion" ||
429-
!dataSource.hasApiKey
429+
!dataSource.config.hasApiKey
430430
) {
431431
return;
432432
}
@@ -480,7 +480,7 @@ export function DataSourceDisplay({ dataSourceId }: DataSourceDisplayProps) {
480480
!selectedDataTable ||
481481
!dataSource ||
482482
dataSource.type !== "notion" ||
483-
!dataSource.hasApiKey
483+
!dataSource.config.hasApiKey
484484
) {
485485
return;
486486
}

packages/types/src/data-sources.ts

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,43 @@ import type { UUID } from "./uuid";
55
// Data Source Types
66
// ============================================================================
77

8+
/**
9+
* Public-safe connector config blob on the DataSource read DTO.
10+
*
11+
* Credential fields (apiKey, connectionString) are represented as boolean
12+
* presence flags — never as the raw SecretRef or plaintext. The connector
13+
* KIND (DataSource.type) interprets any additional keys.
14+
*
15+
* This is the structured, safe-to-diff config; the server strips secret refs
16+
* before populating it. Non-credential keys are passed through as-is.
17+
*/
18+
export type ConnectorConfig = {
19+
/** True when an API key is stored in the vault. Never the raw value. */
20+
hasApiKey: boolean;
21+
/** True when a connection string is stored in the vault. Never the raw value. */
22+
hasConnectionString: boolean;
23+
/** Any additional non-credential connector settings, kind-interpreted. */
24+
[key: string]: unknown;
25+
};
26+
827
/**
928
* DataSource interface - generic for any connector type.
1029
* Type is the connector ID from the registry (e.g., "csv", "notion").
1130
*
1231
* SECURITY: this is a read DTO. Raw credential values are NEVER returned
13-
* by the read path. Presence is indicated by boolean flags so the UI can
14-
* show "key is set" without receiving the secret itself.
32+
* by the read path. Presence is indicated by boolean flags inside `config`
33+
* so the UI can show "key is set" without receiving the secret itself.
1534
*/
1635
export interface DataSource {
1736
id: UUID;
1837
type: string; // Connector ID from registry
1938
name: string;
20-
// Credential presence flags — true when the config field is non-empty.
21-
// Always set by the read path (rowToDataSource), so non-optional: this
22-
// avoids a three-state `true | false | undefined` where a missing field
23-
// and a stored `false` would be indistinguishable.
24-
// The actual secret is never returned by list/get queries.
25-
hasApiKey: boolean; // For remote API connectors (e.g., Notion)
26-
hasConnectionString: boolean; // For database connectors (future)
39+
/**
40+
* Public-safe connector config. Credential slots are boolean presence
41+
* flags; non-credential keys are passed through as-is.
42+
* Always set by the read path (rowToDataSource), so non-optional.
43+
*/
44+
config: ConnectorConfig;
2745
createdAt: number;
2846
}
2947

packages/types/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export type { DataTableField, DataTableInfo } from "./data-table-info";
4444
export type { UseQueryResult } from "./repository-base";
4545

4646
export type {
47+
ConnectorConfig,
4748
CreateDataSourceInput,
4849
DataSource,
4950
DataSourceMutations,

0 commit comments

Comments
 (0)