@flowdular/sdk 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/.ai/platform-capabilities.md +4 -2
  2. package/.ai/policies/capabilities.yaml +30 -3
  3. package/.ai/references/catalog/migrations/0005_catalog_list_indexes.down.sql +3 -0
  4. package/.ai/references/catalog/migrations/0005_catalog_list_indexes.up.sql +11 -0
  5. package/.ai/references/catalog/module.json +11 -1
  6. package/.ai/references/catalog/package.json +2 -2
  7. package/.ai/references/catalog/spec/module.yaml +25 -4
  8. package/.ai/references/catalog/src/agent/tools.ts +19 -10
  9. package/.ai/references/catalog/src/api/endpoints.ts +150 -10
  10. package/.ai/references/catalog/src/api/list-cursor.ts +83 -0
  11. package/.ai/references/catalog/src/client/CatalogView.tsrx +505 -159
  12. package/.ai/references/catalog/src/client/api.ts +124 -36
  13. package/.ai/references/catalog/src/client/contribution.tsrx +5 -0
  14. package/.ai/references/catalog/src/client/state.ts +169 -3
  15. package/.ai/references/catalog/src/domain/lists.ts +7 -0
  16. package/.ai/references/catalog/src/domain/types.ts +20 -0
  17. package/.ai/references/catalog/src/platform.ts +20 -0
  18. package/.ai/references/catalog/src/services/catalog-service.ts +143 -8
  19. package/.ai/references/catalog/src/services/database-repository.ts +104 -17
  20. package/.ai/references/catalog/src/services/item-export.ts +81 -0
  21. package/.ai/references/catalog/src/services/migration.ts +27 -1
  22. package/.ai/references/catalog/src/services/repository.ts +31 -2
  23. package/.ai/references/catalog/tests/agent-tools.test.ts +6 -5
  24. package/.ai/references/catalog/tests/client-state.test.ts +124 -0
  25. package/.ai/references/catalog/tests/endpoints.test.ts +269 -0
  26. package/.ai/references/catalog/tests/export.test.ts +134 -0
  27. package/.ai/references/catalog/tests/idempotency.test.ts +15 -14
  28. package/.ai/references/catalog/tests/list.test.ts +217 -0
  29. package/.ai/references/catalog/tests/migrations.test.ts +58 -2
  30. package/.ai/references/catalog/tests/module.test.ts +2 -1
  31. package/.ai/references/catalog/tests/support/database.ts +14 -0
  32. package/.ai/references/catalog/translations/en.json +35 -4
  33. package/.ai/references/catalog/translations/pl.json +35 -4
  34. package/.ai/references/catalog.provenance.json +34 -26
  35. package/.ai/skills/cli-extension/SKILL.md +1 -1
  36. package/.ai/skills/deploy-operate/SKILL.md +7 -2
  37. package/README.md +1 -1
  38. package/assets/flowdular-banner.webp +0 -0
  39. package/modules/access/module.json +1 -1
  40. package/modules/access/spec/module.yaml +1 -1
  41. package/modules/agents/module.json +1 -1
  42. package/modules/agents/spec/module.yaml +1 -1
  43. package/modules/approvals/migrations/0005_approvals_grant_audit.down.sql +4 -0
  44. package/modules/approvals/migrations/0005_approvals_grant_audit.up.sql +20 -0
  45. package/modules/approvals/module.json +2 -2
  46. package/modules/approvals/package.json +1 -1
  47. package/modules/approvals/spec/module.yaml +12 -3
  48. package/modules/approvals/src/domain/capability.ts +12 -0
  49. package/modules/approvals/src/domain/grant.ts +69 -0
  50. package/modules/approvals/src/domain/types.ts +14 -0
  51. package/modules/approvals/src/index.ts +9 -0
  52. package/modules/approvals/src/platform.ts +8 -0
  53. package/modules/approvals/src/server/runtime.ts +4 -0
  54. package/modules/approvals/src/services/approvals-service.ts +80 -0
  55. package/modules/approvals/src/services/database-repository.ts +65 -6
  56. package/modules/approvals/src/services/migration.ts +34 -0
  57. package/modules/approvals/src/services/repository.ts +8 -0
  58. package/modules/audit/module.json +1 -1
  59. package/modules/audit/spec/module.yaml +1 -1
  60. package/modules/auth/module.json +2 -2
  61. package/modules/auth/package.json +1 -1
  62. package/modules/auth/spec/module.yaml +2 -2
  63. package/modules/auth/src/middleware/authentication.ts +5 -1
  64. package/modules/auth/src/services/auth-service.ts +26 -0
  65. package/modules/auth/src/services/database-repository.ts +9 -0
  66. package/modules/auth/src/services/repository.ts +2 -0
  67. package/modules/automations/module.json +1 -1
  68. package/modules/automations/spec/module.yaml +1 -1
  69. package/modules/connectors/migrations/0003_connectors_rotation_inventory.down.sql +2 -0
  70. package/modules/connectors/migrations/0003_connectors_rotation_inventory.up.sql +19 -0
  71. package/modules/connectors/module.json +8 -4
  72. package/modules/connectors/package.json +2 -1
  73. package/modules/connectors/spec/module.yaml +3 -2
  74. package/modules/connectors/src/cli/commands.json +17 -0
  75. package/modules/connectors/src/cli/index.ts +126 -0
  76. package/modules/connectors/src/services/credential-rotation.ts +221 -0
  77. package/modules/connectors/src/services/credential-vault.ts +6 -0
  78. package/modules/connectors/src/services/migration.ts +36 -0
  79. package/modules/directory/module.json +1 -1
  80. package/modules/directory/spec/module.yaml +1 -1
  81. package/modules/documents/migrations/0003_documents_rotation_inventory.down.sql +2 -0
  82. package/modules/documents/migrations/0003_documents_rotation_inventory.up.sql +18 -0
  83. package/modules/documents/module.json +8 -4
  84. package/modules/documents/package.json +2 -1
  85. package/modules/documents/spec/module.yaml +3 -2
  86. package/modules/documents/src/cli/commands.json +17 -0
  87. package/modules/documents/src/cli/index.ts +145 -0
  88. package/modules/documents/src/services/database-repository.ts +15 -4
  89. package/modules/documents/src/services/documents-service.ts +13 -9
  90. package/modules/documents/src/services/migration.ts +35 -0
  91. package/modules/documents/src/services/repository.ts +12 -2
  92. package/modules/documents/src/services/storage-rotation.ts +157 -0
  93. package/modules/exports/migrations/0003_exports_rotation_inventory.down.sql +1 -0
  94. package/modules/exports/migrations/0003_exports_rotation_inventory.up.sql +9 -0
  95. package/modules/exports/module.json +8 -4
  96. package/modules/exports/package.json +2 -1
  97. package/modules/exports/spec/module.yaml +3 -2
  98. package/modules/exports/src/cli/commands.json +17 -0
  99. package/modules/exports/src/cli/index.ts +145 -0
  100. package/modules/exports/src/server/index.ts +0 -1
  101. package/modules/exports/src/services/data-classes.ts +16 -13
  102. package/modules/exports/src/services/database-repository.ts +30 -32
  103. package/modules/exports/src/services/migration.ts +27 -0
  104. package/modules/exports/src/services/repository.ts +9 -10
  105. package/modules/exports/src/services/storage-rotation.ts +138 -0
  106. package/modules/import/module.json +1 -1
  107. package/modules/import/spec/module.yaml +1 -1
  108. package/modules/metering/module.json +1 -1
  109. package/modules/metering/spec/module.yaml +1 -1
  110. package/modules/notifications/module.json +1 -1
  111. package/modules/notifications/spec/module.yaml +1 -1
  112. package/modules/reports/module.json +1 -1
  113. package/modules/reports/spec/module.yaml +1 -1
  114. package/modules/sandbox/module.json +1 -1
  115. package/modules/sandbox/spec/module.yaml +1 -1
  116. package/modules/search/module.json +1 -1
  117. package/modules/search/spec/module.yaml +1 -1
  118. package/modules/system/migrations/0001_system_module_activations.down.sql +2 -0
  119. package/modules/system/migrations/0001_system_module_activations.up.sql +17 -0
  120. package/modules/system/module.json +3 -2
  121. package/modules/system/package.json +2 -2
  122. package/modules/system/spec/module.yaml +98 -1
  123. package/modules/system/src/client/ModulesView.tsrx +101 -8
  124. package/modules/system/src/client/api.ts +52 -11
  125. package/modules/system/src/client/module-columns.tsrx +32 -6
  126. package/modules/system/src/client/state.ts +26 -0
  127. package/modules/system/src/domain/modules.ts +93 -0
  128. package/modules/system/src/index.ts +13 -0
  129. package/modules/system/src/platform.ts +41 -1
  130. package/modules/system/src/server/capability.ts +6 -0
  131. package/modules/system/src/server/endpoints.ts +125 -5
  132. package/modules/system/src/server/index.ts +26 -0
  133. package/modules/system/src/server/module-catalog.ts +19 -0
  134. package/modules/system/src/server/runtime.ts +102 -0
  135. package/modules/system/src/services/database-repository.ts +91 -0
  136. package/modules/system/src/services/migration.ts +39 -0
  137. package/modules/system/src/services/module-activation-service.ts +245 -0
  138. package/modules/system/src/services/repository.ts +17 -0
  139. package/modules/system/translations/en.json +17 -1
  140. package/modules/system/translations/pl.json +17 -1
  141. package/modules/users/module.json +2 -2
  142. package/modules/users/package.json +1 -1
  143. package/modules/users/spec/module.yaml +6 -9
  144. package/modules/users/src/client/UsersView.tsrx +27 -13
  145. package/modules/users/src/client/member-columns.tsrx +13 -4
  146. package/modules/users/src/client/state.ts +16 -0
  147. package/modules/users/src/services/users-service.ts +3 -0
  148. package/modules/users/translations/en.json +2 -1
  149. package/modules/users/translations/pl.json +2 -1
  150. package/modules/workflows/module.json +2 -2
  151. package/modules/workflows/package.json +1 -1
  152. package/modules/workflows/spec/module.yaml +2 -2
  153. package/modules/workflows/src/services/database-repository.ts +93 -53
  154. package/package.json +1 -1
  155. package/packages/client/package.json +1 -0
  156. package/packages/client/src/ApplicationShell.tsrx +46 -5
  157. package/packages/client/src/index.ts +5 -0
  158. package/packages/client/src/shell/modules.ts +49 -0
  159. package/packages/client/src/shell/types.ts +3 -0
  160. package/packages/contracts/src/index.ts +11 -1
  161. package/packages/database/src/backup.ts +1 -0
  162. package/packages/database/src/migrations.ts +7 -0
  163. package/packages/harness/src/runtime.ts +169 -10
  164. package/packages/harness/src/tool-adapters.ts +6 -13
  165. package/packages/kernel/src/approval-grant.ts +310 -0
  166. package/packages/kernel/src/index.ts +20 -0
  167. package/packages/server/src/endpoint.ts +17 -1
  168. package/packages/server/src/index.ts +10 -0
  169. package/packages/server/src/module-activation.ts +69 -0
  170. package/packages/storage/src/envelope.ts +70 -21
  171. package/packages/storage/src/index.ts +7 -1
  172. package/packages/storage/src/port.ts +12 -1
  173. package/packages/storage/src/reseal.ts +128 -0
  174. package/assets/flowdular-banner.png +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowdular/module-system",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
@@ -16,6 +16,7 @@
16
16
  "dependencies": {
17
17
  "@flowdular/client": "workspace:*",
18
18
  "@flowdular/contracts": "workspace:*",
19
+ "@flowdular/database": "workspace:*",
19
20
  "@flowdular/kernel": "workspace:*",
20
21
  "@flowdular/module-auth": "workspace:*",
21
22
  "@flowdular/server": "workspace:*",
@@ -26,7 +27,6 @@
26
27
  "yaml": "2.9.0"
27
28
  },
28
29
  "devDependencies": {
29
- "@flowdular/database": "workspace:*",
30
30
  "@flowdular/database-testing": "workspace:*",
31
31
  "@tsrx/typescript-plugin": "0.3.120",
32
32
  "@types/node": "24.13.3",
@@ -1,12 +1,13 @@
1
1
  schemaVersion: 2
2
2
  id: system.core
3
- specVersion: 0.7.3
3
+ specVersion: 0.8.0
4
4
  status: approved
5
5
  name: System Core
6
6
  description: Provides the application shell, platform health, module discovery, and the administration surface for module settings.
7
7
  profile: full
8
8
  capabilities:
9
9
  - api
10
+ - database
10
11
  - client
11
12
  - translations
12
13
  dependencies: []
@@ -26,6 +27,10 @@ invariants:
26
27
  - Module settings are declared by their owning module, stored per tenant by auth.core, and changed only through the settings administration API served by system.core, with an audit record.
27
28
  - system.core declares the workspace time zone as a shared tenant setting, system.core.timeZone, defaulting to UTC. Only a name the runtime zone database resolves is stored, in its canonical IANA spelling, and any module that declares a dependency on system.core reads it live through the settings runtime; a value this deployment cannot resolve reads back as UTC instead of failing the reader.
28
29
  - A feature flag is a module setting declared with kind flag, which is a non-secret boolean with a declared default, a label, a description, and tenant scope, the only scope a flag may take. It is stored, read, validated and overridden exactly like any other setting, so a module reads it on the request path through the settings runtime at no cost beyond the settings read itself; system.core adds no flag store, no flag endpoint and no second write path.
30
+ - The composed module set is CLI-owned and baked at build; a module cannot be added to a running application from the UI. system.core owns per-workspace activation of the modules the application already composes, stored in the tenant table system_module_activations under forced row-level security. A composed module without a row is active.
31
+ - system.core, auth.core, users.core and profile.core are required and always active. A module that another active module declares as a dependency, through a declared module dependency or a required public capability, cannot be deactivated; the refusal names the active dependents. A module cannot be activated while one of its dependencies is inactive.
32
+ - Every activation change appends one audit event, system.module.activated or system.module.deactivated, to the auth.core trail with the module id and the actor. Repeating the current state changes nothing and writes no event.
33
+ - Activation is enforced in three places from one per-workspace snapshot published as the public capability system.modules.v1 and memoised for at most thirty seconds per tenant, refreshed at once by a change made through this instance. defineEndpoint answers 403 MODULE_INACTIVE for an endpoint of an inactive module in the principal's workspace, required modules exempt; the application shell hides the navigation, views, widgets and command palette entries of inactive modules; the endpoint gate never reads a tenant id from the request.
29
34
  permissions:
30
35
  - id: system.workspace.access
31
36
  description: Open the authenticated ERP workspace.
@@ -44,6 +49,28 @@ dataOwnership:
44
49
  - Feature modules own their navigation metadata and screen implementations.
45
50
  - Module settings values are stored by auth.core on behalf of the declaring module; system.core owns the settings API, the per-module settings section of the Modules drawer, the Flags screen, and the settings permissions.
46
51
  - A feature flag is owned by the module that declares it; system.core only lists and edits it, and auth.core records the change.
52
+ - system.core owns the per-workspace module activation rows and the system.modules.v1 capability; the composed module set stays owned by the CLI and flowdular.json.
53
+ entities:
54
+ - id: module-activation
55
+ name: Module activation
56
+ description: The last activation decision an owner made for one composed module in one workspace.
57
+ fields:
58
+ - id: moduleId
59
+ type: string
60
+ required: true
61
+ unique: tenant
62
+ maxLength: 128
63
+ - id: active
64
+ type: boolean
65
+ required: true
66
+ - id: changedBy
67
+ type: string
68
+ required: true
69
+ maxLength: 64
70
+ description: Account id of the owner who made the change.
71
+ - id: changedAt
72
+ type: datetime
73
+ required: true
47
74
  acceptanceScenarios:
48
75
  - id: SYSTEM-MULTI-VIEW-MODULE
49
76
  given: An enabled module contributes multiple valid navigation entries and matching views.
@@ -117,15 +144,85 @@ acceptanceScenarios:
117
144
  given: Enabled modules declare feature flags and a principal with system.settings.read opens the Flags screen in Administration > Modules.
118
145
  when: The principal reads the flags and a principal with system.settings.manage turns one on for this workspace, then resets it.
119
146
  then: Every declared flag is listed grouped by its owning module with its current value and a link to the audit trail; the override applies to that workspace alone and takes effect on the next settings read; a reset restores the declared default; each change appends one settings.flag.changed audit event carrying the module, the key, the previous and next values and the actor; and a principal without system.settings.manage is refused with 401 or 403 and changes nothing.
147
+ - id: SYSTEM-MODULE-ACTIVATE
148
+ given: An owner with system.settings.manage opens Administration > Modules, where every composed module is listed with its version, activation state and dependents and is active by default.
149
+ when: The owner deactivates an optional module, confirms the dialog, and later activates it again.
150
+ then: The module is inactive for that workspace alone and active again after the second change; each change is stored with the actor and appends one system.module.deactivated or system.module.activated audit event; a member without system.settings.manage sees the row action refused with a reason, and a request without a browser session, without the scope or without a CSRF token is refused with 401 or 403 and changes nothing.
151
+ - id: SYSTEM-MODULE-DEACTIVATE-REFUSED-DEPENDENCY
152
+ given: An active module that another active module depends on, or a required module.
153
+ when: An owner attempts to deactivate it.
154
+ then: The server refuses with 409 MODULE_HAS_ACTIVE_DEPENDENTS naming the active dependents, or 409 MODULE_REQUIRED, the screen shows the reason, nothing is stored and no audit event is written; once the dependents are inactive the deactivation is accepted, and activating a module whose dependency is inactive is refused with 409 MODULE_DEPENDENCY_INACTIVE.
155
+ - id: SYSTEM-MODULE-INACTIVE-ENDPOINT
156
+ given: A module an owner deactivated for the workspace.
157
+ when: A principal of that workspace calls any endpoint the module defines, or a principal of another workspace calls the same endpoint.
158
+ then: The first request is refused with 403 MODULE_INACTIVE after the permission check, the second is served, and an endpoint of system.core, auth.core, users.core or profile.core is never gated.
159
+ - id: SYSTEM-MODULE-INACTIVE-NAVIGATION
160
+ given: A module an owner deactivated for the workspace.
161
+ when: A member of that workspace opens the application shell.
162
+ then: The shell reads the active module ids through GET /api/system/modules/active, hides the module's navigation entries, views, workspace widgets and command palette search, returns a bookmark of one of its views to the dashboard, and shows every module when the read fails.
120
163
  screens:
121
164
  - id: flags
122
165
  kind: list
123
166
  title: Flags
124
167
  navigationGroup: Administration
168
+ - id: modules
169
+ kind: list
170
+ entity: module-activation
171
+ title: Modules
172
+ navigationGroup: Administration
173
+ columns:
174
+ - moduleId
175
+ - active
125
176
  actions:
177
+ - id: activate-module
178
+ entity: module-activation
179
+ permission: system.settings.manage
180
+ kind: update
181
+ risk: workspace-write
182
+ idempotent: true
183
+ description: Activate a composed optional module for this workspace after its dependencies are active.
184
+ - id: deactivate-module
185
+ entity: module-activation
186
+ permission: system.settings.manage
187
+ kind: update
188
+ risk: workspace-write
189
+ idempotent: true
190
+ description: Deactivate a composed optional module for this workspace unless a required module or an active dependent needs it.
126
191
  - id: set-flag
127
192
  permission: system.settings.manage
128
193
  kind: update
129
194
  risk: workspace-write
130
195
  idempotent: true
131
196
  description: Turn a declared feature flag on or off for this workspace, or clear the override so the module default applies again.
197
+ decisions:
198
+ - id: D-MODULE-ACTIVATION-SCOPE
199
+ question: What can the administration UI change about modules?
200
+ answer: Per-workspace activation of the modules the application already composes; adding or removing a module stays a CLI operation because the composition is baked at build.
201
+ decidedBy: user
202
+ - id: D-MODULE-ACTIVATION-REQUIRED
203
+ question: Which modules can never be deactivated?
204
+ answer: system.core, auth.core, users.core and profile.core, and any module another active module depends on through a declared module dependency or a required public capability; the refusal names the active dependents.
205
+ decidedBy: user
206
+ - id: D-MODULE-ACTIVATION-DEFAULT
207
+ question: What is the state of a module nobody has touched?
208
+ answer: Active; the table holds only decisions an owner made, and a row is written for the module being changed, never for the whole set.
209
+ decidedBy: default
210
+ - id: D-MODULE-ACTIVATION-SNAPSHOT
211
+ question: How often is the activation read?
212
+ answer: Once per tenant into a memoised snapshot held for thirty seconds and at most 1024 tenants per process, invalidated at once by a change through the same instance; another instance sees the change within the snapshot lifetime.
213
+ decidedBy: default
214
+ - id: D-MODULE-ACTIVATION-AUDIT
215
+ question: Where is a change recorded?
216
+ answer: One system.module.activated or system.module.deactivated event in the auth.core audit trail, carrying the module id, the resulting state and the actor; a no-op repeat writes nothing.
217
+ decidedBy: default
218
+ - id: D-MODULE-ACTIVATION-SHELL
219
+ question: How does the shell learn which modules are active?
220
+ answer: From GET /api/system/modules/active, served to every member holding system.workspace.access, read once per workspace before the shell renders; a failed read shows every module because the server gate still refuses an inactive module's API.
221
+ decidedBy: default
222
+ - id: D-MODULE-ACTIVATION-AGENT-TOOLS
223
+ question: Are the agent tools of an inactive module withheld?
224
+ answer: Not yet. The harness tool registry has no per-tenant module hook and a tool carries no module id, so this is a documented follow-up; a tool of an inactive module that calls the module's service still runs.
225
+ decidedBy: default
226
+ outOfScope:
227
+ - Adding or removing a module from a running application through the UI; the composition is CLI-owned and baked at build.
228
+ - Withholding the agent tools of an inactive module from a workspace, which needs a per-tenant hook in the harness tool registry.
@@ -2,6 +2,7 @@ import { useEffect, useMemo } from 'octane';
2
2
  import {
3
3
  Alert,
4
4
  Button,
5
+ ConfirmDialog,
5
6
  Drawer,
6
7
  EmptyState,
7
8
  Icon,
@@ -14,11 +15,21 @@ import {
14
15
  } from '@flowdular/sdk/ui';
15
16
  import { activeLocale, t } from '@flowdular/sdk/client';
16
17
  import { useValue } from 'segment-state';
17
- import { ApiError, loadModuleCatalog, type ModuleCatalogEntry } from './api.ts';
18
+ import {
19
+ ApiError,
20
+ loadModuleCatalog,
21
+ setModuleActivation,
22
+ type ModuleCatalogEntry,
23
+ } from './api.ts';
18
24
  import { FlagsPanel } from './FlagsPanel.tsrx';
19
- import { moduleColumns, surfaces } from './module-columns.tsrx';
25
+ import {
26
+ activationLabel,
27
+ activationTone,
28
+ moduleColumns,
29
+ surfaces,
30
+ } from './module-columns.tsrx';
20
31
  import { ModuleSettingsSection } from './ModuleSettingsSection.tsrx';
21
- import { createModulesClientState } from './state.ts';
32
+ import { activationRefusal, createModulesClientState } from './state.ts';
22
33
 
23
34
  const TABS_ID = 'system-modules';
24
35
 
@@ -38,6 +49,11 @@ export function ModulesView(props: ModulesViewProps) @{
38
49
  client.state.selectedModuleId,
39
50
  );
40
51
  const [tab, setTab] = useValue(client.state.tab);
52
+ const [pending, setPending] = useValue(client.state.pending);
53
+ const [busyModuleId, setBusyModuleId] = useValue(client.state.busyModuleId);
54
+ const [activationError, setActivationError] = useValue(
55
+ client.state.activationError,
56
+ );
41
57
  const selected =
42
58
  modules.find((module) => module.id === selectedModuleId) ?? null;
43
59
  const term = query.trim().toLocaleLowerCase(activeLocale());
@@ -50,6 +66,9 @@ export function ModulesView(props: ModulesViewProps) @{
50
66
  module.name.toLocaleLowerCase(activeLocale()).includes(term),
51
67
  );
52
68
  const enabled = modules.filter((module) => module.enabled);
69
+ const active = enabled.filter((module) => module.active);
70
+ const pendingModule =
71
+ modules.find((module) => module.id === pending?.moduleId) ?? null;
53
72
 
54
73
  const refresh = async () => {
55
74
  setStatus('loading');
@@ -76,6 +95,33 @@ export function ModulesView(props: ModulesViewProps) @{
76
95
  void refresh();
77
96
  }, []);
78
97
 
98
+ /* The server is the authority on dependencies: a refusal it answers is
99
+ shown with the modules it named, and the catalog is read again so the
100
+ table never shows a state the workspace no longer has. */
101
+ const confirmActivation = async () => {
102
+ if (!pending) return;
103
+ setBusyModuleId(pending.moduleId);
104
+ setActivationError('');
105
+ try {
106
+ await setModuleActivation(
107
+ pending.moduleId,
108
+ pending.active,
109
+ props.csrfToken,
110
+ );
111
+ setPending(null);
112
+ await refresh();
113
+ } catch (changeError) {
114
+ setPending(null);
115
+ setActivationError(
116
+ changeError instanceof Error
117
+ ? changeError.message
118
+ : t('system.error.activation'),
119
+ );
120
+ } finally {
121
+ setBusyModuleId(null);
122
+ }
123
+ };
124
+
79
125
  <div class="ui-view">
80
126
  <PageHeader
81
127
  eyebrow={t('system.page.eyebrow')}
@@ -118,9 +164,13 @@ export function ModulesView(props: ModulesViewProps) @{
118
164
  @if (status === 'error') {
119
165
  <Alert>{error}</Alert>
120
166
  }
167
+ @if (activationError !== '') {
168
+ <Alert>{activationError}</Alert>
169
+ }
121
170
  <TableCard
122
171
  title={t('system.table.title')}
123
172
  count={t('system.table.count', {
173
+ active: active.length,
124
174
  enabled: enabled.length,
125
175
  total: modules.length,
126
176
  })}
@@ -158,6 +208,23 @@ export function ModulesView(props: ModulesViewProps) @{
158
208
  icon: 'external',
159
209
  onSelect: () => setSelectedModuleId(module.id),
160
210
  },
211
+ {
212
+ id: 'activation',
213
+ label: module.enabled && module.active
214
+ ? t('system.action.deactivate')
215
+ : t('system.action.activate'),
216
+ icon: module.enabled && module.active ? 'x' : 'play',
217
+ tone: module.enabled && module.active ? 'danger' : 'default',
218
+ disabled: busyModuleId !== null ||
219
+ activationRefusal(module, props.canManageSettings, t) !==
220
+ null,
221
+ reason: activationRefusal(module, props.canManageSettings, t) ??
222
+ undefined,
223
+ onSelect: () => setPending({
224
+ moduleId: module.id,
225
+ active: !module.active,
226
+ }),
227
+ },
161
228
  ]}
162
229
  noteIcon="code"
163
230
  note={<>
@@ -173,6 +240,28 @@ export function ModulesView(props: ModulesViewProps) @{
173
240
  </>
174
241
  }
175
242
  </div>
243
+ <ConfirmDialog
244
+ open={pending !== null}
245
+ title={pending?.active
246
+ ? t('system.activation.confirmActivateTitle', {
247
+ module: pendingModule?.name ?? pending.moduleId,
248
+ })
249
+ : t('system.activation.confirmDeactivateTitle', {
250
+ module: pendingModule?.name ?? pending?.moduleId ?? '',
251
+ })}
252
+ confirmLabel={pending?.active
253
+ ? t('system.action.activate')
254
+ : t('system.action.deactivate')}
255
+ cancelLabel={t('system.action.cancel')}
256
+ tone={pending?.active ? 'primary' : 'danger'}
257
+ busy={busyModuleId !== null}
258
+ onConfirm={() => void confirmActivation()}
259
+ onCancel={() => setPending(null)}
260
+ >
261
+ {pending?.active
262
+ ? t('system.activation.confirmActivate')
263
+ : t('system.activation.confirmDeactivate')}
264
+ </ConfirmDialog>
176
265
  <Drawer
177
266
  open={tab === 'catalog' && selected !== null}
178
267
  title={selected?.name ?? t('system.drawer.module')}
@@ -224,11 +313,9 @@ function ModuleDetail(props: {
224
313
  </span>
225
314
  <span class="ui-cell">
226
315
  <small>{t('system.table.status')}</small>
227
- <Tag tone={props.module.enabled ? 'success' : 'neutral'} dot>
228
- {props.module.enabled
229
- ? t('system.status.enabled')
230
- : t('system.status.available')}
231
- </Tag>
316
+ <Tag tone={activationTone(props.module)} dot>{activationLabel(
317
+ props.module,
318
+ )}</Tag>
232
319
  </span>
233
320
  </div>
234
321
  <span class="ui-cell">
@@ -241,6 +328,12 @@ function ModuleDetail(props: {
241
328
  <small>{t('system.detail.directory')}</small>
242
329
  <span class="ui-mono">{props.module.directory}</span>
243
330
  </span>
331
+ <span class="ui-cell">
332
+ <small>{t('system.table.dependents')}</small>
333
+ <span class="ui-mono">
334
+ {props.module.dependents.join(', ') || t('system.value.none')}
335
+ </span>
336
+ </span>
244
337
  </div>
245
338
  <div class="ui-form__section">
246
339
  <div class="ui-form__section-head">
@@ -1,7 +1,8 @@
1
- import type { ModuleCatalogEntry } from '../server/module-catalog.ts';
1
+ import type { ModuleCatalogPayload } from '../server/endpoints.ts';
2
+ import type { ModuleActivationEntry } from '../domain/modules.ts';
2
3
  import { t } from '@flowdular/sdk/client/i18n';
3
4
 
4
- export type { ModuleCatalogEntry };
5
+ export type ModuleCatalogEntry = ModuleCatalogPayload;
5
6
 
6
7
  export interface ModuleCatalog {
7
8
  readonly modules: readonly ModuleCatalogEntry[];
@@ -10,27 +11,67 @@ export interface ModuleCatalog {
10
11
 
11
12
  export class ApiError extends Error {
12
13
  readonly status: number;
14
+ /** The modules a refusal names, when the server named any. */
15
+ readonly modules: readonly string[];
13
16
 
14
- constructor(status: number, message: string) {
17
+ constructor(
18
+ status: number,
19
+ message: string,
20
+ modules: readonly string[] = [],
21
+ ) {
15
22
  super(message);
16
23
  this.name = 'ApiError';
17
24
  this.status = status;
25
+ this.modules = modules;
18
26
  }
19
27
  }
20
28
 
21
- export async function loadModuleCatalog(): Promise<ModuleCatalog> {
22
- const response = await fetch('/api/system/modules', {
23
- headers: { accept: 'application/json' },
24
- credentials: 'same-origin',
25
- });
26
- const value = (await response.json()) as ModuleCatalog & {
27
- readonly error?: { readonly message?: string };
29
+ async function payload<T>(response: Response, fallback: string): Promise<T> {
30
+ const value = (await response.json()) as T & {
31
+ readonly error?: {
32
+ readonly message?: string;
33
+ readonly modules?: readonly string[];
34
+ };
28
35
  };
29
36
  if (!response.ok) {
30
37
  throw new ApiError(
31
38
  response.status,
32
- value.error?.message ?? t('system.error.catalog'),
39
+ value.error?.message ?? fallback,
40
+ value.error?.modules ?? [],
33
41
  );
34
42
  }
35
43
  return value;
36
44
  }
45
+
46
+ export async function loadModuleCatalog(): Promise<ModuleCatalog> {
47
+ const response = await fetch('/api/system/modules', {
48
+ headers: { accept: 'application/json' },
49
+ credentials: 'same-origin',
50
+ });
51
+ return payload<ModuleCatalog>(response, t('system.error.catalog'));
52
+ }
53
+
54
+ export async function setModuleActivation(
55
+ moduleId: string,
56
+ active: boolean,
57
+ csrfToken: string,
58
+ ): Promise<ModuleActivationEntry> {
59
+ const response = await fetch(
60
+ active ? '/api/system/modules/activate' : '/api/system/modules/deactivate',
61
+ {
62
+ method: 'POST',
63
+ headers: {
64
+ 'content-type': 'application/json',
65
+ 'x-csrf-token': csrfToken,
66
+ },
67
+ credentials: 'same-origin',
68
+ body: JSON.stringify({ moduleId }),
69
+ },
70
+ );
71
+ return (
72
+ await payload<{ readonly module: ModuleActivationEntry }>(
73
+ response,
74
+ t('system.error.activation'),
75
+ )
76
+ ).module;
77
+ }
@@ -2,6 +2,22 @@ import { Tag, type TableColumn } from '@flowdular/sdk/ui';
2
2
  import { activeLocale, t } from '@flowdular/sdk/client';
3
3
  import type { ModuleCatalogEntry } from './api.ts';
4
4
 
5
+ export function activationTone(
6
+ module: ModuleCatalogEntry,
7
+ ): 'success' | 'warning' | 'ink' | 'neutral' {
8
+ if (!module.enabled) return 'neutral';
9
+ if (!module.optional) return 'ink';
10
+ return module.active ? 'success' : 'warning';
11
+ }
12
+
13
+ export function activationLabel(module: ModuleCatalogEntry): string {
14
+ if (!module.enabled) return t('system.status.available');
15
+ if (!module.optional) return t('system.status.required');
16
+ return module.active
17
+ ? t('system.status.active')
18
+ : t('system.status.inactive');
19
+ }
20
+
5
21
  export function surfaces(module: ModuleCatalogEntry): string {
6
22
  const parts = [
7
23
  module.platform.server && 'server',
@@ -37,7 +53,7 @@ export function moduleColumns(): readonly TableColumn<ModuleCatalogEntry>[] {
37
53
  {
38
54
  key: 'platform',
39
55
  header: t('system.table.platform'),
40
- width: '30%',
56
+ width: '20%',
41
57
  cell: (module) => <span class="ui-cell">
42
58
  <span>{surfaces(module)}</span>
43
59
  <small>{module.capabilities.join(', ')}</small>
@@ -52,15 +68,25 @@ export function moduleColumns(): readonly TableColumn<ModuleCatalogEntry>[] {
52
68
  module.permissions.length,
53
69
  ),
54
70
  },
71
+ {
72
+ key: 'dependents',
73
+ header: t('system.table.dependents'),
74
+ width: '14%',
75
+ cell: (module) => <span class="ui-cell">
76
+ <span>{new Intl.NumberFormat(activeLocale()).format(
77
+ module.dependents.length,
78
+ )}</span>
79
+ {module.dependents.length > 0
80
+ ? <small class="ui-mono">{module.dependents.join(', ')}</small>
81
+ : null}
82
+ </span>,
83
+ },
55
84
  {
56
85
  key: 'status',
57
86
  header: t('system.table.status'),
58
87
  width: '14%',
59
- cell: (module) => <Tag tone={module.enabled ? 'success' : 'neutral'} dot>
60
- {module.enabled
61
- ? t('system.status.enabled')
62
- : t('system.status.available')}
63
- </Tag>,
88
+ cell: (module) =>
89
+ <Tag tone={activationTone(module)} dot>{activationLabel(module)}</Tag>,
64
90
  },
65
91
  ];
66
92
  }
@@ -4,6 +4,12 @@ import type { ModuleCatalogEntry } from './api.ts';
4
4
  export type ModulesStatus = 'loading' | 'idle' | 'denied' | 'error';
5
5
  export type ModulesTab = 'catalog' | 'flags';
6
6
 
7
+ /** The activation change waiting for the owner's confirmation. */
8
+ export interface PendingActivation {
9
+ readonly moduleId: string;
10
+ readonly active: boolean;
11
+ }
12
+
7
13
  export function createModulesClientState() {
8
14
  const store = createStore({
9
15
  modules: cell<readonly ModuleCatalogEntry[]>([]),
@@ -13,6 +19,26 @@ export function createModulesClientState() {
13
19
  query: '',
14
20
  selectedModuleId: cell<string | null>(null),
15
21
  tab: cell<ModulesTab>('catalog'),
22
+ pending: cell<PendingActivation | null>(null),
23
+ busyModuleId: cell<string | null>(null),
24
+ activationError: '',
16
25
  });
17
26
  return { store, state: store.state };
18
27
  }
28
+
29
+ /** Why the row action is refused before the server is asked, else null. */
30
+ export function activationRefusal(
31
+ module: ModuleCatalogEntry,
32
+ canManage: boolean,
33
+ translate: (key: string, params?: Record<string, string>) => string,
34
+ ): string | null {
35
+ if (!module.enabled) return translate('system.activation.notComposed');
36
+ if (!canManage) return translate('system.activation.deniedManage');
37
+ if (!module.optional) return translate('system.activation.required');
38
+ if (module.active && module.dependents.length > 0) {
39
+ return translate('system.activation.dependents', {
40
+ modules: module.dependents.join(', '),
41
+ });
42
+ }
43
+ return null;
44
+ }
@@ -0,0 +1,93 @@
1
+ import { REQUIRED_MODULE_IDS } from '@flowdular/sdk/contracts';
2
+
3
+ export const SYSTEM_MODULES_CAPABILITY = 'system.modules.v1';
4
+
5
+ const REQUIRED = new Set<string>(REQUIRED_MODULE_IDS);
6
+
7
+ export function isRequiredModule(moduleId: string): boolean {
8
+ return REQUIRED.has(moduleId);
9
+ }
10
+
11
+ /** One module the application composes, with the modules it cannot run without. */
12
+ export interface ComposedModule {
13
+ readonly id: string;
14
+ readonly version: string;
15
+ readonly dependencies: readonly string[];
16
+ }
17
+
18
+ export interface ModuleActivationEntry {
19
+ readonly id: string;
20
+ readonly version: string;
21
+ readonly active: boolean;
22
+ /** False for a required module, which is never deactivated. */
23
+ readonly optional: boolean;
24
+ /** Composed modules that declare this one as a dependency. */
25
+ readonly dependents: readonly string[];
26
+ }
27
+
28
+ export interface ModuleActivationChange {
29
+ readonly moduleId: string;
30
+ readonly active: boolean;
31
+ }
32
+
33
+ /** What other composed modules a public capability is served by. */
34
+ export interface ManifestDependencies {
35
+ readonly id: string;
36
+ readonly version: string;
37
+ readonly dependencies: readonly string[];
38
+ readonly provides: readonly string[];
39
+ /** Capability ids the module cannot compose without. */
40
+ readonly requires: readonly string[];
41
+ }
42
+
43
+ /* A dependency is a declared module dependency or a required capability,
44
+ resolved to the composed module that provides it. */
45
+ export function composedModules(
46
+ manifests: readonly ManifestDependencies[],
47
+ ): readonly ComposedModule[] {
48
+ const providers = new Map<string, string>();
49
+ const ids = new Set(manifests.map((manifest) => manifest.id));
50
+ for (const manifest of manifests) {
51
+ for (const capability of manifest.provides) {
52
+ providers.set(capability, manifest.id);
53
+ }
54
+ }
55
+ return manifests.map((manifest) => {
56
+ const dependencies = new Set<string>();
57
+ for (const id of manifest.dependencies) {
58
+ if (ids.has(id) && id !== manifest.id) dependencies.add(id);
59
+ }
60
+ for (const capability of manifest.requires) {
61
+ const provider = providers.get(capability);
62
+ if (provider !== undefined && provider !== manifest.id) {
63
+ dependencies.add(provider);
64
+ }
65
+ }
66
+ return {
67
+ id: manifest.id,
68
+ version: manifest.version,
69
+ dependencies: [...dependencies].sort(),
70
+ };
71
+ });
72
+ }
73
+
74
+ export function dependentsOf(
75
+ modules: readonly ComposedModule[],
76
+ moduleId: string,
77
+ ): readonly string[] {
78
+ return modules
79
+ .filter((module) => module.dependencies.includes(moduleId))
80
+ .map((module) => module.id)
81
+ .sort();
82
+ }
83
+
84
+ /** The composed module ids that are active for a workspace, sorted. */
85
+ export function activeModuleIds(
86
+ modules: readonly ComposedModule[],
87
+ inactive: ReadonlySet<string>,
88
+ ): readonly string[] {
89
+ return modules
90
+ .map((module) => module.id)
91
+ .filter((id) => isRequiredModule(id) || !inactive.has(id))
92
+ .sort();
93
+ }
@@ -21,3 +21,16 @@ export {
21
21
  TENANT_TIME_ZONE_KEY,
22
22
  tenantTimeZone,
23
23
  } from './domain/time-zone.ts';
24
+ export {
25
+ activeModuleIds,
26
+ composedModules,
27
+ dependentsOf,
28
+ isRequiredModule,
29
+ SYSTEM_MODULES_CAPABILITY,
30
+ } from './domain/modules.ts';
31
+ export type {
32
+ ComposedModule,
33
+ ManifestDependencies,
34
+ ModuleActivationChange,
35
+ ModuleActivationEntry,
36
+ } from './domain/modules.ts';