notFound().
That hid the three situations an operator can actually act on, and it hid a
plain bug: pages 404’d personal workspaces even though the sidebar links them
and the underlying APIs normalize personal into a real workspace id.
Those pages now resolve one of four states through
resolveWorkspaceFeatureAccess.
Gate states
The order matters: an unresolved workspace is reported before a permission
denial, and a permission denial before the toggle, so a member never learns a
module’s configuration from a page they cannot open.
Modules and toggles
Shared workspaces keep whatever each module defaulted to before the gate
existed, so no workspace loses a module. Personal workspaces start opted out
and opt in from the page itself, which is what replaces the old hard 404.
Approvals has no module toggle of its own:
ENABLE_POST_APPROVAL and
ENABLE_REPORT_APPROVAL already govern approval behavior, and a third switch
would be ambiguous. Approvals also stays unavailable in personal workspaces by
product decision — it renders an explanation, not a 404.Writing a toggle from Contacts
Config toggles go through the Contacts-owned routePUT /api/v1/workspaces/{wsId}/settings/{configId}, which is satellite-session
aware. Reading a toggle needs only the module’s view permission; writing needs
manage_workspace_settings.
Topic announcements is a workspace secret, not a config, and the platform
secrets endpoint authenticates through the Supabase cookie — which resolves
anonymous on a satellite domain, so every write from Contacts 401s there.
PUT /api/v1/workspaces/{wsId}/settings/feature-secrets/{secretName} exists for
exactly that case. It is deliberately not a general secrets endpoint:
- only names in
CONTACTS_FEATURE_SECRET_NAMESare addressable; - only the literal values
trueandfalseare accepted; manage_workspace_secretsis required;- it never reads a secret back.