Merge v1.25.3: v1253-disable-documents

This commit is contained in:
Marc-André Bombeck committed 2026-06-29 15:30:32 +02:00
commit a333776725
8 files changed
+152 -73

No files matched your search

-2
View File
@@ -10290,8 +10290,6 @@ components:
type: boolean
mcp:
type: boolean
inboundDocuments:
type: boolean
mentalHealth:
type: boolean
additionalProperties: false
@@ -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")}
/>
<div className="mt-4 space-y-4 pl-7">
{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 (
<SettingsToggle
key={key}
label={t(def.labelKey)}
description={t(def.descriptionKey)}
checked={available}
onCheckedChange={(checked) =>
mutation.mutate({ [key]: checked } as Partial<AvailabilityMap>)
}
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 (
<SettingsToggle
key={key}
label={t(def.labelKey)}
description={t(def.descriptionKey)}
checked={available}
onCheckedChange={(checked) =>
mutation.mutate({
[key]: checked,
} as Partial<AvailabilityMap>)
}
disabled={mutation.isPending}
/>
);
})}
</div>
</div>
);
@@ -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("<ModulesSection>", () => {
// 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
+47 -42
View File
@@ -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")}
</p>
<div className="divide-border divide-y pl-7">
{(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 (
<ModuleToggleRow
key={key}
moduleKey={key}
icon={MODULE_ICONS[key]}
label={t(def.labelKey)}
description={t(def.descriptionKey)}
enabled={enabled}
pending={toggle.isPending}
managedAt={
delegated && def.managedAt
? {
href: def.managedAt.href,
label: t(def.managedAt.labelKey),
}
: undefined
}
manageLinkLabel={
delegated && def.managedAt
? t("settings.sections.modules.manageIn", {
section: t(def.managedAt.labelKey),
})
: undefined
}
operatorDisabled={operatorDisabled}
operatorNote={t("settings.sections.modules.operatorDisabled")}
onToggle={(next) => 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 (
<ModuleToggleRow
key={key}
moduleKey={key}
icon={MODULE_ICONS[key]}
label={t(def.labelKey)}
description={t(def.descriptionKey)}
enabled={enabled}
pending={toggle.isPending}
managedAt={
delegated && def.managedAt
? {
href: def.managedAt.href,
label: t(def.managedAt.labelKey),
}
: undefined
}
manageLinkLabel={
delegated && def.managedAt
? t("settings.sections.modules.manageIn", {
section: t(def.managedAt.labelKey),
})
: undefined
}
operatorDisabled={operatorDisabled}
operatorNote={t("settings.sections.modules.operatorDisabled")}
onToggle={(next) => toggle.mutate({ key, enabled: next })}
/>
);
})}
</div>
</SettingsCard>
);
+14 -5
View File
@@ -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);
});
+9
View File
@@ -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) {
+31
View File
@@ -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<string> = 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,
+10 -3
View File
@@ -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 `{ <writableKey>?: boolean }` from the directly-owned key list. */