Per-project extension enablement — the hub's context layer.
The catalog (which extension types exist) lives in
PhoenixKitProjects.Extensions.Registry; this module owns the
per-project state: which extensions a given project has enabled, each
instance's config, and the enable/disable lifecycle (activity rows,
PubSub, provider callbacks).
Semantics (locked by the 2026-08-05 plan + panel round)
- Disable hides, never deletes —
disable/3flipsenabledto false; the row, itsconfig, and the extension's own data survive. Re-enabling restores everything. - Instance-keyed identity — every function threads
instance_key(default"default"); v1 UI exposes one instance per extension. - Config writes are whitelisted against the extension's declared
config_schemakeys — raw params never reach the JSONB. - Effective enablement is an intersection: a row with
enabled: truecounts only while the extension is in the catalog AND its backing site module is enabled (PhoenixKitProjects.Extensions.Registry.available?/1) — a site-level disable wins instantly without touching rows. - Defaults: a project with NO row for an extension falls back to the
catalog's
default_enabled(the built-in Tasks extension shipsdefault_enabled: true, preserving pre-hub behavior for every existing project). Presets (Step 3) write explicit rows at creation — absence always means "inherit the catalog default", never a frozen choice.
Authorization note: these functions trust their caller (the LiveView
layer gates on PhoenixKitProjects.Authz.can?/5 with :manage_modules).
Activity actor flows in via opts[:actor_uuid], LV-layer convention.
Summary
Functions
Disables an extension instance for a project. The row (and its config)
survives — see the moduledoc. Creates an explicit disabled row when the
enablement was implicit (catalog default_enabled), so "turn tasks off"
works on a project that never had a row.
Enables an extension for a project (upserts the instance row).
Whether one extension is effectively enabled for a project.
The extensions EFFECTIVELY enabled for a project: catalog availability ∩
(explicit row OR catalog default). Returns [{%Extension{}, %ProjectModule{} | nil}]
— the row is nil when enablement comes from default_enabled with no
explicit row yet.
The effective enablement of EVERY catalog extension for a project — its
DEFAULT instance, which is what enabled?/2 asks about; a named
instance is enabled?/3's business — as %{ext_key => boolean}, from
ONE list_rows/1 read: an explicit row wins, otherwise the catalog
default; an unavailable extension (its
module off) is false however its row reads — the same answer
enabled?/3 gives per key, for the callers that need all of them at
once (Features.gates/1, the Modules panel, the members page: each
used to ask per extension, per flag, on every mount and broadcast).
One enablement row (or nil).
All enablement rows for a project (enabled AND disabled — panel UI needs both).
Updates one instance's config, whitelisted against the extension's
config_schema keys. Unknown keys are silently dropped (never written);
the stored map is REPLACED by the whitelisted input merged over it, so
omitted known keys survive.
Functions
@spec disable(map() | binary(), String.t(), keyword()) :: {:ok, PhoenixKitProjects.Schemas.ProjectModule.t()} | {:error, term()}
Disables an extension instance for a project. The row (and its config)
survives — see the moduledoc. Creates an explicit disabled row when the
enablement was implicit (catalog default_enabled), so "turn tasks off"
works on a project that never had a row.
@spec enable(map() | binary(), String.t(), keyword()) :: {:ok, PhoenixKitProjects.Schemas.ProjectModule.t()} | {:error, term()}
Enables an extension for a project (upserts the instance row).
Options: :actor_uuid, :instance_key, :config (whitelisted),
:name. Returns {:ok, row} or {:error, reason}. Unknown extension
keys are rejected ({:error, :unknown_extension}); an extension whose
site module is disabled is rejected ({:error, :module_disabled}) —
enabling something that can't render is a config trap.
Whether one extension is effectively enabled for a project.
Accepts a project struct or uuid. The intersection rule from the moduledoc applies; unknown keys are always false (fail-closed).
@spec enabled_for_project(binary()) :: [ {PhoenixKitProjects.Extensions.Extension.t(), PhoenixKitProjects.Schemas.ProjectModule.t() | nil} ]
The extensions EFFECTIVELY enabled for a project: catalog availability ∩
(explicit row OR catalog default). Returns [{%Extension{}, %ProjectModule{} | nil}]
— the row is nil when enablement comes from default_enabled with no
explicit row yet.
The effective enablement of EVERY catalog extension for a project — its
DEFAULT instance, which is what enabled?/2 asks about; a named
instance is enabled?/3's business — as %{ext_key => boolean}, from
ONE list_rows/1 read: an explicit row wins, otherwise the catalog
default; an unavailable extension (its
module off) is false however its row reads — the same answer
enabled?/3 gives per key, for the callers that need all of them at
once (Features.gates/1, the Modules panel, the members page: each
used to ask per extension, per flag, on every mount and broadcast).
@spec get_row(binary(), String.t(), String.t()) :: PhoenixKitProjects.Schemas.ProjectModule.t() | nil
One enablement row (or nil).
@spec list_rows(binary()) :: [PhoenixKitProjects.Schemas.ProjectModule.t()]
All enablement rows for a project (enabled AND disabled — panel UI needs both).
@spec update_config(map() | binary(), String.t(), map(), keyword()) :: {:ok, PhoenixKitProjects.Schemas.ProjectModule.t()} | {:error, term()}
Updates one instance's config, whitelisted against the extension's
config_schema keys. Unknown keys are silently dropped (never written);
the stored map is REPLACED by the whitelisted input merged over it, so
omitted known keys survive.