Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion middleware/src/api/admin-v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,18 @@ export type SetupFieldType =
| 'integer'
/** #91: operator-curated list of bare hostnames. Values are unioned into
* the plugin's effective `ctx.http` allowlist at runtime (Option B). */
| 'host_list';
| 'host_list'
/**
* #603 (OM-17): upload a JSON credential file instead of transcribing values
* out of it. The field itself stores NOTHING — the server parses the upload
* and explodes it into the keys named in `extracts`, which remain ordinary
* `secret`/`string` fields for every other code path.
*
* Exists because hand-transcribing a service-account key into an email field
* and a masked field stacked beneath it is the visual pattern of a login, and
* a tester duly typed their real password into it.
*/
| 'json_file';

export interface PluginSetupField {
key: string;
Expand All @@ -65,6 +76,25 @@ export interface PluginSetupField {
* install wizard and post-install editor; loader passes it through
* unchanged. */
placeholder?: string;
/**
* #603 — `json_file` only. MIME type for the file picker's `accept`
* attribute. Advisory: a picker hint, never a validation. The server decides
* what the upload is, and `expect` is what actually rejects the wrong file.
*/
accept?: string;
/**
* #603 — `json_file` only. Target setup-field key → `$.dotted.path` into the
* uploaded document. The extracted values are stored under those keys; the
* `json_file` field itself stores nothing. See `setupJsonFile.ts` for the
* supported path subset and why it is not full JSONPath.
*/
extracts?: Record<string, string>;
/**
* #603 — `json_file` only. Shallow equality assertions the uploaded document
* must satisfy (e.g. `{ type: 'service_account' }`), checked BEFORE any value
* is extracted so the wrong file is rejected rather than half-consumed.
*/
expect?: Record<string, unknown>;
/** Manifest default. Forwarded so the post-install editor can pre-select
* the default option in an `enum` dropdown when no value is stored yet.
* A `string[]` for `type === 'host_list'`, a `string` otherwise. */
Expand Down Expand Up @@ -700,6 +730,27 @@ export interface InstallSetupField {
* it just isn't shown at install time. For flow-populated credentials.
* Older UIs ignore the flag and render the field as usual. */
install_hidden?: boolean;
/**
* #603 — `json_file` only. Mirrors {@link PluginSetupField.accept}: the file
* picker's `accept` hint. Advisory — the server, not the picker, decides what
* an upload actually is.
*/
accept?: string;
/**
* #603 — `json_file` only. Mirrors {@link PluginSetupField.extracts}.
*
* Carried on the INSTALL projection too, not just the catalog one, because
* `POST …/secrets/from-json` resolves the extraction map through
* `extractSetupSchema` — which returns THIS type. A `json_file` field that
* reaches the route without it carries no upload contract and is refused,
* so an omission here is a silently dead upload button.
*/
extracts?: Record<string, string>;
/**
* #603 — `json_file` only. Mirrors {@link PluginSetupField.expect}: shallow
* assertions the uploaded document must satisfy before anything is extracted.
*/
expect?: Record<string, unknown>;
}

export interface InstallSetupSchema {
Expand Down
4 changes: 4 additions & 0 deletions middleware/src/plugins/builder/agentSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ const SetupFieldSchema = z
'boolean',
'integer',
'host_list',
// #603 (OM-17). This schema is `.strict()`, so an unknown member is
// REJECTED outright rather than ignored — a builder spec declaring
// `json_file` fails to load until this list carries it.
'json_file',
]),
required: z.boolean().optional(),
description: z.string().optional(),
Expand Down
46 changes: 46 additions & 0 deletions middleware/src/plugins/installService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,32 @@ export function extractSetupSchema(
// previously masked every secret field with a hardcoded `••••••••`.
const placeholder = asString(f['placeholder']);
if (placeholder) field.placeholder = placeholder;
// #603 (OM-17) — the `json_file` upload contract. Must stay in agreement
// with the catalog projection in `manifestLoader.ts`, which applies the SAME
// "no usable extracts ⇒ drop the field" rule: a `json_file` field that
// survives one projection but not the other is a file picker that renders on
// one screen and cannot save from the other.
if (type === 'json_file') {
const extractsRaw = f['extracts'];
const extracts: Record<string, string> = {};
if (extractsRaw && typeof extractsRaw === 'object' && !Array.isArray(extractsRaw)) {
for (const [target, path] of Object.entries(
extractsRaw as Record<string, unknown>,
)) {
const p = asString(path);
if (target.length > 0 && p !== undefined) extracts[target] = p;
}
}
if (Object.keys(extracts).length === 0) continue;
field.extracts = extracts;
const expectRaw = f['expect'];
if (expectRaw && typeof expectRaw === 'object' && !Array.isArray(expectRaw)) {
const expect = expectRaw as Record<string, unknown>;
if (Object.keys(expect).length > 0) field.expect = expect;
}
const accept = asString(f['accept']);
if (accept) field.accept = accept;
}
if (f['default'] !== undefined) field.default = f['default'];
// OM-17 — same load-time compile gate as manifestLoader: an uncompilable or
// catastrophically-backtracking pattern is dropped (warned once, cached) so
Expand Down Expand Up @@ -813,6 +839,20 @@ function coerce(field: InstallSetupField, raw: unknown): CoerceResult {
// #602 (OM-17) — see validateValues: German templates, no request locale.
const label = resolveLocalized(field.label, 'de') ?? field.key;
switch (field.type) {
// #603 (OM-17) — a `json_file` field is an INPUT affordance, not a stored
// value: the server explodes the upload into the keys named in `extracts`
// and stores only those. So nothing may be submitted under this field's own
// key, and a value arriving here means the client tried to write the raw
// document into the vault — which is exactly what this feature exists to
// avoid. Refused rather than ignored: silently dropping it would let a
// client believe it had stored a credential.
case 'json_file':
return {
error: {
code: 'not_submittable',
message: `"${label}" wird hochgeladen, nicht eingegeben — es kann nicht direkt gesetzt werden.`,
},
};
case 'string':
case 'secret':
return typeof raw === 'string'
Expand Down Expand Up @@ -973,6 +1013,12 @@ const SUPPORTED_TYPES = new Set<string>([
'boolean',
'integer',
'host_list',
// #603 (OM-17). Fifth place this union is mirrored — the issue named three
// (`admin-v1.ts`, `manifestLoader.isSetupFieldType`, `agentSpec`'s z.enum);
// this set and `InstallSetupField`'s shape are the other two. A member missing
// HERE does not error: `isSupportedType` simply skips the field, so the upload
// disappears from the install wizard with no diagnostic at all.
'json_file',
]);

function isSupportedType(t: string): t is InstallSetupField['type'] {
Expand Down
32 changes: 31 additions & 1 deletion middleware/src/plugins/manifestLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,34 @@ export function adaptManifestV1(doc: Record<string, unknown>): Plugin | null {
const defaultValue = asString(f['default']);
if (defaultValue !== undefined) entry.default = defaultValue;
}
if (type === 'json_file') {
// #603 (OM-17) — the upload's extraction map. Validated HERE, at load
// time, for the same reason `pattern` is: a manifest is untrusted input,
// and a `json_file` field whose `extracts` is missing or unusable would
// otherwise reach the operator as a file picker that silently produces
// nothing. Dropping the field entirely is the honest degradation — the
// form then shows the underlying `secret` fields, i.e. exactly the
// pre-#603 behaviour, instead of an upload that cannot work.
const extractsRaw = asRecord(f['extracts']);
const extracts: Record<string, string> = {};
for (const [target, path] of Object.entries(extractsRaw ?? {})) {
const p = asString(path);
if (target.length > 0 && p !== undefined) extracts[target] = p;
}
if (Object.keys(extracts).length === 0) {
console.warn(
`[manifestLoader] ${id}: setup field '${key}' is type json_file but declares no usable 'extracts' — dropping the field.`,
);
continue;
}
entry.extracts = extracts;
const expectRaw = asRecord(f['expect']);
if (expectRaw && Object.keys(expectRaw).length > 0) {
entry.expect = expectRaw;
}
const accept = asString(f['accept']);
if (accept) entry.accept = accept;
}
if (type === 'enum') {
const enumRaw = f['enum'];
if (Array.isArray(enumRaw)) {
Expand Down Expand Up @@ -974,6 +1002,8 @@ function isSetupFieldType(value: string): value is PluginSetupField['type'] {
value === 'enum' ||
value === 'boolean' ||
value === 'integer' ||
value === 'host_list'
value === 'host_list' ||
// #603 (OM-17) — upload a JSON credential file instead of transcribing it.
value === 'json_file'
);
}
Loading
Loading