diff --git a/docs/console-api.md b/docs/console-api.md index 81ebc00..19b3779 100644 --- a/docs/console-api.md +++ b/docs/console-api.md @@ -6,7 +6,11 @@ Miftah includes an optional, local-only browser Console over its control API. It miftah dashboard ``` -Without `--config`, the dashboard discovers direct, validated Miftah JSON files in `~/.config/miftah` and asks the operator to select one. It does not scan Claude, Cursor, VS Code, process arguments, or arbitrary home directories. Candidate paths must be canonical regular files in that bounded directory; unsafe, malformed, duplicate, and symbolic candidates are omitted without exposing their paths or parser errors. Windows discovery additionally verifies the current-user owner and restrictive DACL; if that proof is unavailable, automatic discovery fails closed. A selection is bound to the verified file content: if the file changes, select it again rather than applying controls to a replacement. If no safe configuration exists, first-run onboarding creates `~/.config/miftah/miftah.json` only after explicit submission. +Without `--config`, the dashboard discovers direct, validated Miftah JSON files in `~/.config/miftah` and asks the operator to select one. It does not scan Claude, Cursor, VS Code, process arguments, or arbitrary home directories. Candidate paths must be canonical regular files in that bounded directory. The catalog shows aggregate found, ready, and need-attention counts. Rejected candidates are grouped only as file permissions, invalid configuration, unsafe path or replacement, duplicate, or unreadable/changing file. The Console never returns a rejected candidate's name, path, configuration value, or parser error. + +On macOS and Linux, expected configuration files must be owned by the current user, must not be symlinks, and must not grant group or other read/write access; `0600` is the normal generated mode. The standard directory must not be group- or other-writable; `0700` is the normal generated mode. On Windows, discovery instead verifies current-user ownership and a restrictive DACL. If the directory boundary cannot be proved, automatic discovery fails closed. Correct the expected file or directory access outside the browser, run `miftah validate --config /absolute/path/to/config.json`, and refresh. Do not relax permissions merely to make a candidate appear. + +A selection is bound to the verified file content: if the file changes, select it again rather than applying controls to a replacement. If no safe configuration exists and no candidates need attention, first-run onboarding creates `~/.config/miftah/miftah.json` only after explicit submission. `miftah dashboard --config ` is different: it opens exactly that one configuration and does not show or scan a catalog. Use `--port ` for a fixed loopback port, or `--no-open` to print the URL without launching a browser. The API-only compatibility command remains: diff --git a/src/console/console-assets.ts b/src/console/console-assets.ts index 8241fd3..715ef05 100644 --- a/src/console/console-assets.ts +++ b/src/console/console-assets.ts @@ -76,6 +76,11 @@ const page = `

Only validated files in Miftah's standard configuration directory appear here. Client settings and running MCP processes are never inspected.

+
+

+
    +

    Miftah keeps rejected names and paths hidden. For files you expect to see, check private access, validate the configuration, replace symlinks with regular files, then refresh.

    +
    @@ -489,6 +494,10 @@ button.danger { color: #ffd7cf; background: transparent; border: 1px solid #7043 .restart-note { margin: 1rem 0 4rem; padding: 1rem 1.2rem; border-left: .2rem solid var(--key); background: rgb(239 180 77 / 7%); } .connection-list { display: grid; gap: .8rem; margin-bottom: 1.2rem; } .configuration-catalog { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .8rem; } +.configuration-catalog-status { margin: 0 0 1rem; padding: .85rem 1rem; border: 1px solid var(--line); background: rgb(255 255 255 / 2%); } +.configuration-catalog-status p { margin: 0; } +.configuration-catalog-status ul { margin: .55rem 0 0; padding-left: 1.2rem; color: var(--muted); } +.configuration-catalog-status ul:empty { display: none; } .configuration-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 1rem; align-items: center; padding: 1.15rem 1.25rem; border: 1px solid var(--line); background: var(--panel); } .configuration-card p { margin: .25rem 0 0; font-size: .82rem; } .configuration-card .configuration-meta { font: .73rem/1.5 ui-monospace, monospace; } @@ -534,6 +543,8 @@ const script = `(() => { const workspaceView = byId("workspace-view"); const configurationCatalogView = byId("configuration-catalog-view"); const configurationCatalog = byId("configuration-catalog"); + const configurationCatalogSummary = byId("configuration-catalog-summary"); + const configurationCatalogAttention = byId("configuration-catalog-attention"); const setupCompletionView = byId("setup-completion-view"); const setupCompletionVerification = byId("setup-completion-verification"); const setupCompletionNextAction = byId("setup-completion-next-action"); @@ -782,16 +793,60 @@ const script = `(() => { function catalogConfigurations(metadata) { const catalog = record(metadata.catalog); + const configurations = Array.isArray(catalog.configurations) ? catalog.configurations.map(record) : []; + const safeCount = (value, fallback) => Number.isSafeInteger(value) && value >= 0 ? value : fallback; + const attentionReasons = Array.isArray(catalog.attentionReasons) + ? catalog.attentionReasons.map(record).flatMap((item) => { + const reason = typeof item.reason === "string" ? item.reason : ""; + const count = safeCount(item.count, 0); + return count > 0 ? [{ reason, count }] : []; + }) + : []; + const attentionCount = safeCount( + catalog.attentionCount, + attentionReasons.reduce((total, item) => total + item.count, 0) + ); + const readyCount = safeCount(catalog.readyCount, configurations.length); return { discoveryState: typeof catalog.discoveryState === "string" ? catalog.discoveryState : "", selectedConfigurationId: typeof catalog.selectedConfigurationId === "string" ? catalog.selectedConfigurationId : "", - configurations: Array.isArray(catalog.configurations) ? catalog.configurations.map(record) : [] + discoveredCount: safeCount(catalog.discoveredCount, readyCount + attentionCount), + readyCount, + attentionCount, + attentionReasons, + configurations }; } function renderConfigurationCatalog(metadata) { const catalog = catalogConfigurations(metadata); - if (configurationCatalogView) configurationCatalogView.hidden = catalog.configurations.length === 0; + const hasCatalogState = catalog.discoveredCount > 0 || catalog.readyCount > 0 || catalog.attentionCount > 0; + if (configurationCatalogView) configurationCatalogView.hidden = !hasCatalogState; + if (configurationCatalogSummary) { + const files = catalog.discoveredCount === 1 ? "configuration file found" : "configuration files found"; + const attention = catalog.attentionCount === 1 ? "needs attention" : "need attention"; + configurationCatalogSummary.textContent = + catalog.discoveredCount + " " + files + " · " + + catalog.readyCount + " ready · " + + catalog.attentionCount + " " + attention; + } + if (configurationCatalogAttention) { + configurationCatalogAttention.replaceChildren(); + const labels = { + "file-permissions": "private file permission", + "invalid-configuration": "invalid configuration", + "unsafe-path": "unsafe path or file replacement", + "duplicate": "duplicate file", + "unreadable": "unreadable or changing file" + }; + catalog.attentionReasons.forEach((item) => { + const label = labels[item.reason]; + if (typeof label !== "string") return; + const entry = document.createElement("li"); + entry.textContent = item.count + " " + label + (item.count === 1 ? "" : "s"); + configurationCatalogAttention.append(entry); + }); + } if (!configurationCatalog) return catalog; configurationCatalog.replaceChildren(); catalog.configurations.forEach((configuration) => { @@ -1448,6 +1503,14 @@ const script = `(() => { message("Choose a configuration to open it. Miftah does not inspect or change MCP client settings."); return; } + if (catalog.attentionCount > 0) { + if (onboardingView) onboardingView.hidden = true; + if (presetOnboardingView) presetOnboardingView.hidden = true; + if (clientEntryOnboardingView) clientEntryOnboardingView.hidden = true; + if (workspaceView) workspaceView.hidden = true; + message("Miftah found configuration files, but none passed every trust and validation check. Review the safe reason summary, correct the expected files, then refresh."); + return; + } if (catalog.discoveryState === "unavailable") { if (onboardingView) onboardingView.hidden = true; if (presetOnboardingView) presetOnboardingView.hidden = true; diff --git a/src/console/console-config-catalog.ts b/src/console/console-config-catalog.ts index b1b80dc..9ff06b0 100644 --- a/src/console/console-config-catalog.ts +++ b/src/console/console-config-catalog.ts @@ -12,6 +12,8 @@ import { loadConfigFromText } from "../config/load-config.js"; import { consoleInitializedConfigMetadata, type ConsoleConfigCatalog, + type ConsoleConfigCatalogAttention, + type ConsoleConfigCatalogAttentionReason, type ConsoleDiscoveredConfiguration } from "./console-config-metadata.js"; import type { ConsoleTrustedConfiguration } from "./console-trusted-configuration.js"; @@ -115,6 +117,21 @@ export interface ConsoleConfigCatalogDiscovery { readonly configurations: readonly DiscoveredConsoleConfiguration[]; } +type TrustedConfigurationCandidate = + | { + readonly status: "accepted"; + readonly path: string; + /** Exact-width identity used for security comparisons and catalog dedupe. */ + readonly identity: string; + /** Test-only Number projection retained to diagnose platform precision loss. */ + readonly numberIdentity?: string; + readonly trustedConfiguration: ConsoleTrustedConfiguration; + } + | { + readonly status: "attention"; + readonly reason: ConsoleConfigCatalogAttentionReason; + }; + /** Sensitive verified bytes/configuration stay off the serializable catalog entry. */ const trustedConfigurations = new WeakMap(); @@ -171,6 +188,14 @@ function isTrustedFile(entry: Stats | BigIntStats, ownerUid: number | undefined, return entry.isFile() && !entry.isSymbolicLink() && hasExpectedOwner(entry, ownerUid) && hasSafeFileMode(entry, platform); } +function hasTrustedFilePermissions( + entry: Pick, + ownerUid: number | undefined, + platform: NodeJS.Platform +): boolean { + return hasExpectedOwner(entry, ownerUid) && hasSafeFileMode(entry, platform); +} + function configurationId(path: string): string { return createHash("sha256").update(path).digest("base64url"); } @@ -258,20 +283,23 @@ async function readTrustedConfiguration( candidateIndex: number, candidateStageObserver: ConsoleConfigCatalogCandidateStageObserver | undefined, candidateIdentityObserver: ConsoleConfigCatalogCandidateIdentityDiagnosticObserver | undefined -): Promise<{ - readonly path: string; - /** Exact-width identity used for security comparisons and catalog dedupe. */ - readonly identity: string; - /** Test-only Number projection retained to diagnose platform precision loss. */ - readonly numberIdentity?: string; - readonly trustedConfiguration: ConsoleTrustedConfiguration; -} | undefined> { +): Promise { const observed = await lstat(path, { bigint: true }); - if (!isTrustedFile(observed, ownerUid, platform)) return undefined; + if (!observed.isFile() || observed.isSymbolicLink()) { + return { status: "attention", reason: "unsafe-path" }; + } + if (!hasTrustedFilePermissions(observed, ownerUid, platform)) { + return { status: "attention", reason: "file-permissions" }; + } const canonical = await realpath(path); - if (!isWithin(directory, canonical)) return undefined; + if (!isWithin(directory, canonical)) return { status: "attention", reason: "unsafe-path" }; const resolved = await stat(canonical, { bigint: true }); - if (!isTrustedFile(resolved, ownerUid, platform) || !sameBigIntFileIdentity(observed, resolved)) return undefined; + if (!resolved.isFile() || resolved.isSymbolicLink() || !sameBigIntFileIdentity(observed, resolved)) { + return { status: "attention", reason: "unsafe-path" }; + } + if (!hasTrustedFilePermissions(resolved, ownerUid, platform)) { + return { status: "attention", reason: "file-permissions" }; + } const aclPaths: readonly WindowsPrivatePath[] = useBatchedWindowsAclVerifier ? [{ path: directory, kind: "directory" }, { path: canonical, kind: "file" }] : [{ path: canonical, kind: "file" }]; @@ -283,7 +311,7 @@ async function readTrustedConfiguration( useBatchedWindowsAclVerifier ))) { observeCandidateStage(candidateStageObserver, candidateIndex, "acl", "rejected"); - return undefined; + return { status: "attention", reason: "file-permissions" }; } if (useBatchedWindowsAclVerifier) windowsAclBoundary.verified = true; observeCandidateStage(candidateStageObserver, candidateIndex, "acl", "success"); @@ -311,7 +339,12 @@ async function readTrustedConfiguration( opened.size > maximumConfigurationBytes ) { observeCandidateStage(candidateStageObserver, candidateIndex, "opened-validation", "rejected"); - return undefined; + return { + status: "attention", + reason: hasTrustedFilePermissions(openedIdentity, ownerUid, platform) + ? "unsafe-path" + : "file-permissions" + }; } observeCandidateStage(candidateStageObserver, candidateIndex, "opened-validation", "success"); const identity = bigintFileIdentity(openedIdentity); @@ -339,34 +372,40 @@ async function readTrustedConfiguration( content.byteLength > maximumConfigurationBytes ) { observeCandidateStage(candidateStageObserver, candidateIndex, "after-read-validation", "rejected"); - return undefined; + return { + status: "attention", + reason: hasTrustedFilePermissions(afterReadIdentity, ownerUid, platform) + ? "unsafe-path" + : "file-permissions" + }; } observeCandidateStage(candidateStageObserver, candidateIndex, "after-read-validation", "success"); let text: string; try { text = new TextDecoder("utf-8", { fatal: true }).decode(content); - } catch (error) { + } catch { observeCandidateStage(candidateStageObserver, candidateIndex, "decode", "error"); - throw error; + return { status: "attention", reason: "invalid-configuration" }; } observeCandidateStage(candidateStageObserver, candidateIndex, "decode", "success"); let config: ReturnType; try { config = loadConfigFromText(text, canonical); - } catch (error) { + } catch { observeCandidateStage(candidateStageObserver, candidateIndex, "parse", "error"); - throw error; + return { status: "attention", reason: "invalid-configuration" }; } observeCandidateStage(candidateStageObserver, candidateIndex, "parse", "success"); let migrationSource: ReturnType; try { migrationSource = createConfigMigrationSource(content, afterRead); - } catch (error) { + } catch { observeCandidateStage(candidateStageObserver, candidateIndex, "migration-source", "error"); - throw error; + return { status: "attention", reason: "invalid-configuration" }; } observeCandidateStage(candidateStageObserver, candidateIndex, "migration-source", "success"); return { + status: "accepted", path: canonical, identity, ...(numberIdentity === undefined ? {} : { numberIdentity }), @@ -381,6 +420,40 @@ async function readTrustedConfiguration( } } +function catalogAttention( + attentionCounts: ReadonlyMap +): readonly ConsoleConfigCatalogAttention[] { + const exhaustiveOrder = { + "file-permissions": true, + "invalid-configuration": true, + "unsafe-path": true, + "duplicate": true, + "unreadable": true + } satisfies Record; + const order = Object.keys(exhaustiveOrder) as ConsoleConfigCatalogAttentionReason[]; + return order.flatMap((reason) => { + const count = attentionCounts.get(reason) ?? 0; + return count === 0 ? [] : [{ reason, count }]; + }); +} + +function readyCatalog( + discoveredCount: number, + configurations: readonly DiscoveredConsoleConfiguration[], + attentionCounts: ReadonlyMap +): ConsoleConfigCatalog { + const attentionReasons = catalogAttention(attentionCounts); + return { + source: "standard-config-directory", + discoveryState: "ready", + discoveredCount, + readyCount: configurations.length, + attentionCount: attentionReasons.reduce((total, item) => total + item.count, 0), + attentionReasons, + configurations: configurations.map((configuration) => configuration.metadata) + }; +} + /** * Discovers only direct, trusted JSON files in Miftah's standard configuration * directory. Invalid or unsafe candidates are deliberately not surfaced. @@ -415,7 +488,15 @@ export async function discoverConsoleConfigCatalog( } if (directory === undefined) { return { - catalog: { source: "standard-config-directory", discoveryState: "ready", configurations: [] }, + catalog: { + source: "standard-config-directory", + discoveryState: "ready", + discoveredCount: 0, + readyCount: 0, + attentionCount: 0, + attentionReasons: [], + configurations: [] + }, configurations: [] }; } @@ -437,8 +518,13 @@ export async function discoverConsoleConfigCatalog( const identities = new Set(); const numberIdentities = candidateIdentityObserver === undefined ? undefined : new Set(); const configurations: DiscoveredConsoleConfiguration[] = []; + const attentionCounts = new Map(); + const recordAttention = (reason: ConsoleConfigCatalogAttentionReason): void => { + attentionCounts.set(reason, (attentionCounts.get(reason) ?? 0) + 1); + }; const windowsAclBoundary: WindowsCatalogAclBoundary = { verified: !useBatchedWindowsAclVerifier }; for (const [candidateIndex, name] of names.entries()) { + let failureReason: ConsoleConfigCatalogAttentionReason = "unreadable"; try { const discovered = await readTrustedConfiguration( join(directory, name), @@ -453,12 +539,17 @@ export async function discoverConsoleConfigCatalog( candidateStageObserver, candidateIdentityObserver ); - if (discovered === undefined) continue; + if (discovered.status === "attention") { + recordAttention(discovered.reason); + continue; + } + failureReason = "invalid-configuration"; const bigintDuplicate = identities.has(discovered.identity); const numberDuplicate = discovered.numberIdentity !== undefined && numberIdentities?.has(discovered.numberIdentity) === true; candidateIdentityObserver?.({ candidateIndex, numberDuplicate, bigintDuplicate }); if (bigintDuplicate) { observeCandidateStage(candidateStageObserver, candidateIndex, "dedupe", "duplicate"); + recordAttention("duplicate"); continue; } identities.add(discovered.identity); @@ -495,6 +586,7 @@ export async function discoverConsoleConfigCatalog( observeCandidateStage(candidateStageObserver, candidateIndex, "accepted", "success"); } catch { observeCandidateStage(candidateStageObserver, candidateIndex, "candidate", "error"); + recordAttention(failureReason); // A malformed, raced, or untrusted candidate is never a Console entry. } } @@ -514,11 +606,7 @@ export async function discoverConsoleConfigCatalog( left.metadata.name.localeCompare(right.metadata.name) || left.metadata.id.localeCompare(right.metadata.id) ); return { - catalog: { - source: "standard-config-directory", - discoveryState: "ready", - configurations: configurations.map((configuration) => configuration.metadata) - }, + catalog: readyCatalog(names.length, configurations, attentionCounts), configurations }; } diff --git a/src/console/console-config-metadata.ts b/src/console/console-config-metadata.ts index 6154886..bee33ee 100644 --- a/src/console/console-config-metadata.ts +++ b/src/console/console-config-metadata.ts @@ -50,9 +50,29 @@ export interface ConsoleDiscoveredConfiguration { readonly source: "standard-config-directory"; } +export type ConsoleConfigCatalogAttentionReason = + | "file-permissions" + | "invalid-configuration" + | "unsafe-path" + | "duplicate" + | "unreadable"; + +export interface ConsoleConfigCatalogAttention { + readonly reason: ConsoleConfigCatalogAttentionReason; + readonly count: number; +} + export interface ConsoleConfigCatalog { readonly source: "standard-config-directory"; readonly discoveryState: "ready" | "unavailable"; + /** Direct JSON candidates found without exposing their names or paths. */ + readonly discoveredCount?: number; + /** Candidates that passed every trust and configuration check. */ + readonly readyCount?: number; + /** Candidates omitted from the selector because one or more checks failed. */ + readonly attentionCount?: number; + /** Aggregate safe categories only; never paths, values, or parser details. */ + readonly attentionReasons?: readonly ConsoleConfigCatalogAttention[]; readonly configurations: readonly ConsoleDiscoveredConfiguration[]; readonly selectedConfigurationId?: string; } diff --git a/tests/console-dashboard-application-service.test.ts b/tests/console-dashboard-application-service.test.ts index b91a687..ea8635e 100644 --- a/tests/console-dashboard-application-service.test.ts +++ b/tests/console-dashboard-application-service.test.ts @@ -1006,6 +1006,14 @@ describe("Console dashboard application service", () => { }); expect(catalog.catalog).toMatchObject({ discoveryState: "ready", + discoveredCount: 5, + readyCount: 2, + attentionCount: 3, + attentionReasons: [ + { reason: "invalid-configuration", count: 1 }, + { reason: "unsafe-path", count: 1 }, + { reason: "duplicate", count: 1 } + ], configurations: [{ name: "gsc" }, { name: "sentry" }] }); expect(catalog.configurations).toHaveLength(2); @@ -1014,7 +1022,7 @@ describe("Console dashboard application service", () => { expect(JSON.stringify(catalog.configurations)).not.toContain("client-secrets.json"); }); - it.skipIf(process.platform === "win32")("omits symbolic and group-readable candidates without disclosing their paths", async () => { + it.skipIf(process.platform === "win32")("reports hidden candidate reasons without disclosing their paths", async () => { const directory = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-safe-")); temporaryDirectories.push(directory); await chmod(directory, 0o700); @@ -1044,6 +1052,13 @@ describe("Console dashboard application service", () => { const metadata = await service.configMetadata(); expect(metadata.catalog).toMatchObject({ discoveryState: "ready", + discoveredCount: 3, + readyCount: 1, + attentionCount: 2, + attentionReasons: [ + { reason: "file-permissions", count: 1 }, + { reason: "unsafe-path", count: 1 } + ], configurations: [{ name: "safe" }] }); expect(metadata.catalog?.configurations).toHaveLength(1); @@ -1051,6 +1066,37 @@ describe("Console dashboard application service", () => { expect(JSON.stringify(metadata.catalog)).not.toContain(safePath); }); + it("classifies a failure after trusted parsing as an invalid configuration", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-post-read-failure-")); + temporaryDirectories.push(directory); + await writeCatalogFixture(join(directory, "candidate.json"), { + version: "3", + name: "candidate", + defaultProfile: "default", + upstream: { transport: "stdio", command: "node", args: [] }, + profiles: { default: {} } + }); + let injectedFailure = false; + + const result = await discoverConsoleConfigCatalog({ + configDirectory: directory, + windowsAclVerifier: async () => true, + candidateStageObserver(event) { + if (!injectedFailure && event.stage === "metadata" && event.outcome === "success") { + injectedFailure = true; + throw new Error("simulated post-read metadata failure"); + } + } + }); + + expect(result.catalog).toMatchObject({ + discoveredCount: 1, + readyCount: 0, + attentionCount: 1, + attentionReasons: [{ reason: "invalid-configuration", count: 1 }] + }); + }); + it.skipIf(process.platform === "win32")("accepts a non-writable standard config directory when each discovered config is private", async () => { const directory = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-readable-directory-")); temporaryDirectories.push(directory); @@ -1127,6 +1173,38 @@ describe("Console dashboard application service", () => { ]]); }); + it("reports a Windows file ACL rejection without exposing the candidate path", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-windows-file-acl-")); + temporaryDirectories.push(directory); + const configPath = join(directory, "gsc.json"); + await writeConfig(configPath, { + version: "3", + name: "gsc", + defaultProfile: "default", + upstream: { transport: "stdio", command: "node", args: [] }, + profiles: { default: {} } + }); + + const result = await discoverConsoleConfigCatalog({ + configDirectory: directory, + platform: "win32", + windowsAclVerifier: async (_path, kind) => kind === "directory" + }); + + expect(result).toMatchObject({ + catalog: { + discoveryState: "ready", + discoveredCount: 1, + readyCount: 0, + attentionCount: 1, + attentionReasons: [{ reason: "file-permissions", count: 1 }], + configurations: [] + }, + configurations: [] + }); + expect(JSON.stringify(result)).not.toContain(configPath); + }); + it.skipIf(process.platform === "win32")("revalidates a selected configuration before any later Console operation", async () => { const directory = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-revalidate-")); temporaryDirectories.push(directory); diff --git a/tests/console-server.test.ts b/tests/console-server.test.ts index a434731..b3d0efd 100644 --- a/tests/console-server.test.ts +++ b/tests/console-server.test.ts @@ -1480,6 +1480,8 @@ describe("local Console control server", () => { expect(html).toContain('id="profile-inventory-list"'); expect(html).toContain("Configured accounts"); expect(html).toContain('id="configuration-catalog-view"'); + expect(html).toContain('id="configuration-catalog-summary"'); + expect(html).toContain('id="configuration-catalog-attention"'); expect(html).toContain('id="provider-authentication-view"'); expect(html).toContain('id="profile-readiness-view"'); expect(html).toContain('id="profile-readiness-profile"'); @@ -1541,6 +1543,12 @@ describe("local Console control server", () => { expect(javascript).toContain("discardSetupDraft.disabled = true;"); expect(javascript).toContain("finally { discardSetupDraft.disabled = false; }"); expect(javascript).toContain("renderSetupCompletion"); + expect(javascript).toContain("configuration files found"); + expect(javascript).toContain("need attention"); + expect(javascript).toContain('"file-permissions": "private file permission"'); + expect(javascript).not.toContain('"file-permissions": "private file permissions"'); + expect(javascript).toContain("invalid configuration"); + expect(javascript).toContain("unsafe path or file replacement"); expect(javascript).toContain("function selectSetupSource(source)"); expect(javascript).toContain("setup-source-choice"); expect(javascript).toContain('querySelectorAll("input[data-setup-source]")'); diff --git a/tests/oauth-console-threat-model-docs-contract.test.ts b/tests/oauth-console-threat-model-docs-contract.test.ts index 9c0c3ed..d822aea 100644 --- a/tests/oauth-console-threat-model-docs-contract.test.ts +++ b/tests/oauth-console-threat-model-docs-contract.test.ts @@ -50,6 +50,9 @@ describe("OAuth and Console threat-model documentation contract", () => { expect(consoleApi).toContain("`DELETE /api/v1/connections/:ref/credential`"); expect(consoleApi).toContain("must send `Content-Type: application/json` with the JSON body `{}`"); expect(consoleApi).toContain("It cannot inspect or take over another Miftah process"); + expect(consoleApi).toContain("found, ready, and need-attention counts"); + expect(consoleApi).toContain("file permissions, invalid configuration, unsafe path or replacement, duplicate, or unreadable/changing file"); + expect(consoleApi).toContain("The Console never returns a rejected candidate's name, path, configuration value, or parser error."); expect(consoleApi).toContain("authenticated `GET` and `HEAD` requests may omit `Origin`"); expect(consoleApi).toContain("Every request must use the exact listener `Host`"); expect(consoleApi).toContain("Browser mutations, including bootstrap, must also use the exact listener `Origin`");