From 7e83af570176edb309ce5e481292345491f3df7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 29 Jun 2026 14:58:30 +0200 Subject: [PATCH] fix(modules): switch the inbound-documents module off in code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inbound-documents surface is withdrawn from this release and returns later with more depth. Park its module key (`inboundDocuments`) in a new `CODE_DISABLED_MODULE_KEYS` list so it is hard-off for every account ahead of both the operator-availability and the per-user opt-in layer. The gate short-circuits a code-disabled key to disabled, so the resolved module map reports it off everywhere: the nav entry stays born-gated and hidden, the `/documents` page redirects, and every `/api/documents/inbound/*` route returns the 403 module-disabled envelope. The Modules hub and the operator-availability panel drop the row, and the per-user PATCH surface refuses to write the key (a clean 422 instead of an inert preference). The whole implementation stays intact — routes, the inbound-documents view, the registry entry with its opt-in marker, the validation schemas, and the migration are all preserved. Re-enabling the module is a one-line removal from the list once the rebuild lands. Regenerate the OpenAPI contract: the module-preferences PATCH body no longer advertises the parked key. --- docs/api/openapi.yaml | 2 - .../admin/module-availability-section.tsx | 48 ++++++---- .../__tests__/modules-section.test.tsx | 14 ++- src/components/settings/modules-section.tsx | 89 ++++++++++--------- src/lib/modules/__tests__/gate.test.ts | 19 ++-- src/lib/modules/gate.ts | 9 ++ src/lib/modules/registry.ts | 31 +++++++ src/lib/validations/modules.ts | 13 ++- 8 files changed, 152 insertions(+), 73 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index eb8ad6004..938ac99ac 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -10290,8 +10290,6 @@ components: type: boolean mcp: type: boolean - inboundDocuments: - type: boolean mentalHealth: type: boolean additionalProperties: false diff --git a/src/components/admin/module-availability-section.tsx b/src/components/admin/module-availability-section.tsx index c15c72723..c552c0245 100644 --- a/src/components/admin/module-availability-section.tsx +++ b/src/components/admin/module-availability-section.tsx @@ -7,7 +7,11 @@ import { toast } from "sonner"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -import { MODULE_KEYS, MODULE_REGISTRY } from "@/lib/modules/registry"; +import { + MODULE_KEYS, + MODULE_REGISTRY, + isCodeDisabledModule, +} from "@/lib/modules/registry"; import type { ModuleKey } from "@/lib/modules/registry"; import { SettingsToggle } from "./_shared"; import { apiGet, apiPatch } from "@/lib/api/api-fetch"; @@ -90,24 +94,30 @@ export function ModuleAvailabilitySection() { description={t("admin.modules.description")} />
- {MODULE_KEYS.map((key) => { - const def = MODULE_REGISTRY[key]; - // Default-available until the read resolves; an explicit `false` - // from the operator layer is the only thing that turns it off. - const available = availability?.[key] ?? true; - return ( - - mutation.mutate({ [key]: checked } as Partial) - } - disabled={mutation.isPending} - /> - ); - })} + {MODULE_KEYS + // A module switched off in code (pending a rebuild) is hard-off + // server-wide; drop its row so the operator toggle can't mislead. + .filter((key) => !isCodeDisabledModule(key)) + .map((key) => { + const def = MODULE_REGISTRY[key]; + // Default-available until the read resolves; an explicit `false` + // from the operator layer is the only thing that turns it off. + const available = availability?.[key] ?? true; + return ( + + mutation.mutate({ + [key]: checked, + } as Partial) + } + disabled={mutation.isPending} + /> + ); + })}
); diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx index dbd2822da..db37c4881 100644 --- a/src/components/settings/__tests__/modules-section.test.tsx +++ b/src/components/settings/__tests__/modules-section.test.tsx @@ -20,7 +20,11 @@ import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; import type { AuthUser } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; -import { MODULE_KEYS, moduleDelegatesTo } from "@/lib/modules/registry"; +import { + MODULE_KEYS, + isCodeDisabledModule, + moduleDelegatesTo, +} from "@/lib/modules/registry"; // Capture the latest useMutation config + a shared mutate spy so we can // invoke `mutationFn` / `onSuccess` by hand (the SSR pass can't fire a @@ -112,7 +116,13 @@ describe("", () => { // only renders the toggleable modules. const html = render(); for (const key of MODULE_KEYS) { - if (moduleDelegatesTo(key) !== undefined) { + if (isCodeDisabledModule(key)) { + // v1.25.3 — a module switched off in code (pending a rebuild) drops + // out of the hub entirely: no Switch, no deep-link, no row. + expect(html, `code-disabled has no switch ${key}`).not.toContain( + `id="module-toggle-${key}"`, + ); + } else if (moduleDelegatesTo(key) !== undefined) { // Delegated modules (cycle/coach) are owned by their real control // elsewhere — they render as a read-only "manage in X" deep-link, // never a live Switch that would write an inert disabled-allowlist diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx index e67da7e3f..d38419b5b 100644 --- a/src/components/settings/modules-section.tsx +++ b/src/components/settings/modules-section.tsx @@ -55,6 +55,7 @@ import { apiPatch } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; import { MODULE_REGISTRY, + isCodeDisabledModule, moduleDelegatesTo, type ModuleKey, } from "@/lib/modules/registry"; @@ -130,48 +131,52 @@ export function ModulesSection() { {t("settings.sections.modules.toggleable.description")}

- {(Object.keys(MODULE_REGISTRY) as ModuleKey[]).map((key) => { - const def = MODULE_REGISTRY[key]; - // Default-on: a module is enabled unless explicitly `false`. - const enabled = modules[key] !== false; - // Delegated modules (cycle/coach) are owned elsewhere — never a - // live toggle here; deep-link to their real control instead. - const delegated = moduleDelegatesTo(key) !== undefined; - // Operator turned this module off server-wide: no per-user - // toggle can re-enable it, so show a read-only note. Delegated - // rows already deep-link, so this only matters for owned keys. - const operatorDisabled = - !delegated && moduleAvailability[key] === false; - return ( - toggle.mutate({ key, enabled: next })} - /> - ); - })} + {(Object.keys(MODULE_REGISTRY) as ModuleKey[]) + // Modules switched off in code (pending a rebuild) carry no live + // toggle — drop the row entirely so a user can't turn one on. + .filter((key) => !isCodeDisabledModule(key)) + .map((key) => { + const def = MODULE_REGISTRY[key]; + // Default-on: a module is enabled unless explicitly `false`. + const enabled = modules[key] !== false; + // Delegated modules (cycle/coach) are owned elsewhere — never a + // live toggle here; deep-link to their real control instead. + const delegated = moduleDelegatesTo(key) !== undefined; + // Operator turned this module off server-wide: no per-user + // toggle can re-enable it, so show a read-only note. Delegated + // rows already deep-link, so this only matters for owned keys. + const operatorDisabled = + !delegated && moduleAvailability[key] === false; + return ( + toggle.mutate({ key, enabled: next })} + /> + ); + })}
); diff --git a/src/lib/modules/__tests__/gate.test.ts b/src/lib/modules/__tests__/gate.test.ts index 730bf8b87..8785629eb 100644 --- a/src/lib/modules/__tests__/gate.test.ts +++ b/src/lib/modules/__tests__/gate.test.ts @@ -20,6 +20,7 @@ import { } from "../operator-availability"; import { isCoreDomain, + isCodeDisabledModule, isModuleKey, isOptInModule, moduleDelegatesTo, @@ -373,14 +374,22 @@ describe("registry — opt-in marker", () => { }); }); -describe("resolveModuleEnabled — inboundDocuments module (opt-in, default-OFF)", () => { +describe("resolveModuleEnabled — inboundDocuments module (disabled in code)", () => { + // v1.25.3 — `inboundDocuments` is switched off in code pending a rebuild + // (`CODE_DISABLED_MODULE_KEYS`). It is hard-off ahead of both the operator + // and the per-user layer: no stored preference or availability blob can + // re-surface it. The registry entry + `optIn` marker stay intact. + it("is flagged as code-disabled in the registry", () => { + expect(isCodeDisabledModule("inboundDocuments")).toBe(true); + }); + it("is OFF when no preference is recorded", () => { expect( resolveModuleEnabled("inboundDocuments", inputs(), false, ALL_AVAILABLE), ).toBe(false); }); - it("turns ON only on an explicit true (opt-in)", () => { + it("stays OFF even when the user opted in (the in-code switch wins)", () => { expect( resolveModuleEnabled( "inboundDocuments", @@ -388,16 +397,16 @@ describe("resolveModuleEnabled — inboundDocuments module (opt-in, default-OFF) false, ALL_AVAILABLE, ), - ).toBe(true); + ).toBe(false); }); - it("operator-off short-circuits even when the user opted in", () => { + it("stays OFF when the user opted in and the operator left it available", () => { expect( resolveModuleEnabled( "inboundDocuments", inputs({ modulePreferences: { inboundDocuments: true } }), false, - operator({ inboundDocuments: false }), + operator({ inboundDocuments: true }), ), ).toBe(false); }); diff --git a/src/lib/modules/gate.ts b/src/lib/modules/gate.ts index ff32f383a..61bb9bf04 100644 --- a/src/lib/modules/gate.ts +++ b/src/lib/modules/gate.ts @@ -55,6 +55,7 @@ import { } from "./operator-availability"; import { MODULE_KEYS, + isCodeDisabledModule, isOptInModule, moduleDelegatesTo, type ModuleKey, @@ -126,6 +127,14 @@ export function resolveModuleEnabled( assistantCoach: boolean, operatorAvailability: OperatorModuleAvailability, ): boolean { + // LAYER 0 — switched off in code pending a rebuild. Hard-off for every + // account, ahead of both the operator and the per-user layer, so no + // stored preference or availability blob can re-surface it. See + // `CODE_DISABLED_MODULE_KEYS` in ./registry. + if (isCodeDisabledModule(key)) { + return false; + } + // LAYER 1 — operator server-wide availability. An operator-disabled // module is off for everyone regardless of personal preference. if (operatorAvailability[key] === false) { diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts index 2af5a9636..97d422b38 100644 --- a/src/lib/modules/registry.ts +++ b/src/lib/modules/registry.ts @@ -327,6 +327,37 @@ export function isModuleKey(key: string): key is ModuleKey { return MODULE_KEY_SET.has(key); } +/** + * Modules switched OFF in code, pending a holistic rebuild. + * + * A key listed here is hard-off for every account regardless of the + * per-user opt-in or the operator-availability layer: the gate short- + * circuits it to disabled (`resolveModuleEnabled`), the Modules hub and the + * operator-availability panel drop its row, and the per-user PATCH surface + * refuses to write it. The whole implementation (routes, components, schema, + * migration) stays intact — re-enabling the module is a one-line removal + * from this list once the rebuild lands. + * + * v1.25.3 — `inboundDocuments` is parked here: the inbound-documents surface + * is withdrawn from this release and returns later with more depth. Its + * registry entry, `optIn` marker, routes, view, and migration 0222 are all + * preserved; only the user-reachable switches are removed. + */ +export const CODE_DISABLED_MODULE_KEYS = ["inboundDocuments"] as const; + +const CODE_DISABLED_MODULE_SET: ReadonlySet = new Set( + CODE_DISABLED_MODULE_KEYS, +); + +/** + * True for a module intentionally switched off in code pending a rebuild + * (see `CODE_DISABLED_MODULE_KEYS`). Such a module is hard-off everywhere: + * the gate never reports it enabled and the user-facing toggles drop it. + */ +export function isCodeDisabledModule(key: ModuleKey): boolean { + return CODE_DISABLED_MODULE_SET.has(key); +} + /** The two delegated keys, resolved by their existing source of truth. */ export function moduleDelegatesTo( key: ModuleKey, diff --git a/src/lib/validations/modules.ts b/src/lib/validations/modules.ts index 05c041870..7b9189e29 100644 --- a/src/lib/validations/modules.ts +++ b/src/lib/validations/modules.ts @@ -25,14 +25,21 @@ */ import { z } from "zod/v4"; -import { MODULE_KEYS, moduleDelegatesTo } from "@/lib/modules/registry"; +import { + MODULE_KEYS, + isCodeDisabledModule, + moduleDelegatesTo, +} from "@/lib/modules/registry"; /** * The directly-owned (non-delegated) toggleable keys — the only keys this - * PATCH surface may write into `modulePreferencesJson`. + * PATCH surface may write into `modulePreferencesJson`. A module switched + * off in code (pending a rebuild) is also excluded, so a client trying to + * opt into it gets a clean 422 rather than persisting an inert `true` the + * gate would ignore anyway. */ const WRITABLE_MODULE_KEYS = MODULE_KEYS.filter( - (k) => moduleDelegatesTo(k) === undefined, + (k) => moduleDelegatesTo(k) === undefined && !isCodeDisabledModule(k), ); /** Build `{ ?: boolean }` from the directly-owned key list. */